Skip to main content

hk_parser/
lib.rs

1use indexmap::IndexMap;
2use lazy_static::lazy_static;
3use regex::Regex;
4use std::collections::HashSet;
5use std::env;
6use std::fs::File;
7use std::io::{self, BufRead, BufReader, Write};
8use std::path::Path;
9use std::str::FromStr;
10use thiserror::Error;
11use colored::Colorize;
12
13/// Represents the structure of a .hk file.
14/// Sections are top-level keys in the outer IndexMap to preserve order.
15pub type HkConfig = IndexMap<String, HkValue>;
16
17lazy_static! {
18    static ref INTERPOL_RE: Regex = Regex::new(r"\$\{([^}]+)\}").unwrap();
19}
20
21/// Enum for values in the .hk config: supports strings, numbers, booleans, arrays, and maps.
22#[derive(Debug, Clone, PartialEq)]
23pub enum HkValue {
24    String(String),
25    Number(f64),
26    Bool(bool),
27    Array(Vec<HkValue>),
28    Map(IndexMap<String, HkValue>),
29}
30
31impl HkValue {
32    pub fn as_string(&self) -> Result<String, HkError> {
33        match self {
34            Self::String(s) => Ok(s.clone()),
35            Self::Number(n) => Ok(n.to_string()),
36            Self::Bool(b) => Ok(b.to_string()),
37            _ => Err(HkError::TypeMismatch {
38                expected: "string".to_string(),
39                found: format!("{:?}", self),
40            }),
41        }
42    }
43
44    pub fn as_number(&self) -> Result<f64, HkError> {
45        if let Self::Number(n) = self {
46            Ok(*n)
47        } else {
48            Err(HkError::TypeMismatch {
49                expected: "number".to_string(),
50                found: format!("{:?}", self),
51            })
52        }
53    }
54
55    pub fn as_bool(&self) -> Result<bool, HkError> {
56        if let Self::Bool(b) = self {
57            Ok(*b)
58        } else {
59            Err(HkError::TypeMismatch {
60                expected: "bool".to_string(),
61                found: format!("{:?}", self),
62            })
63        }
64    }
65
66    pub fn as_array(&self) -> Result<&Vec<HkValue>, HkError> {
67        if let Self::Array(a) = self {
68            Ok(a)
69        } else {
70            Err(HkError::TypeMismatch {
71                expected: "array".to_string(),
72                found: format!("{:?}", self),
73            })
74        }
75    }
76
77    pub fn as_map(&self) -> Result<&IndexMap<String, HkValue>, HkError> {
78        if let Self::Map(m) = self {
79            Ok(m)
80        } else {
81            Err(HkError::TypeMismatch {
82                expected: "map".to_string(),
83                found: format!("{:?}", self),
84            })
85        }
86    }
87}
88
89/// Custom error type for parsing .hk files.
90#[derive(Error, Debug)]
91pub enum HkError {
92    #[error("IO error: {0}")]
93    Io(#[from] io::Error),
94    #[error("Parse error at line {line}, column {column}: {message}")]
95    Parse {
96        line: u32,
97        column: usize,
98        message: String,
99    },
100    #[error("Type mismatch: expected {expected}, found {found}")]
101    TypeMismatch { expected: String, found: String },
102    #[error("Missing field: {0}")]
103    MissingField(String),
104    #[error("Invalid reference: {0}")]
105    InvalidReference(String),
106    #[error("Cyclic reference detected: {0}")]
107    CyclicReference(String),
108    #[error("Key conflict: {0}")]
109    KeyConflict(String),
110}
111
112impl HkError {
113    pub fn pretty_print(&self, source: &str) {
114        match self {
115            Self::Parse { line, column, message } => {
116                eprintln!("{} {}", "error:".red().bold(), "parse error".red().bold());
117                eprintln!("  {} at {}:{}", "→".red(), line, column);
118                if let Some(line_content) = source.lines().nth((*line - 1) as usize) {
119                    eprintln!("\n  {}", line_content);
120                    eprintln!("  {}{}", " ".repeat(*column), "^".red().bold());
121                    eprintln!("  {}", message.red());
122                } else {
123                    eprintln!("  {}", message.red());
124                }
125
126                if message.contains("tag \"=>\"") {
127                    eprintln!("\n{} {}", "Hint:".yellow().bold(), "Try: key => value".cyan());
128                } else if message.contains("tag \"[\"") {
129                    eprintln!("\n{} {}", "Hint:".yellow().bold(), "Sections must start with [name]".cyan());
130                } else if message.contains("take_while1") {
131                    eprintln!("\n{} {}", "Hint:".yellow().bold(), "Keys can only contain letters, digits, '_', '-', '.'".cyan());
132                } else if message.contains("Inconsistent nesting level") {
133                    eprintln!("\n{} {}", "Hint:".yellow().bold(), "Each nesting level must add exactly one dash: '->', then '-->', then '--->', etc. Don't skip a level.".cyan());
134                }
135            }
136            Self::TypeMismatch { expected, found } => {
137                eprintln!("{} {}", "error:".red().bold(), "type mismatch".red().bold());
138                eprintln!("  expected {}, got {}", expected.cyan(), found.red());
139            }
140            Self::InvalidReference(ref_var) => {
141                eprintln!("{} {}", "error:".red().bold(), "invalid reference".red().bold());
142                eprintln!("  {}", ref_var.red());
143                eprintln!("\n{} {}", "Hint:".yellow().bold(), "Check if the referenced key exists and is accessible".cyan());
144            }
145            Self::CyclicReference(path) => {
146                eprintln!("{} {}", "error:".red().bold(), "cyclic reference".red().bold());
147                eprintln!("  {}", path.red());
148            }
149            Self::KeyConflict(key) => {
150                eprintln!("{} {}", "error:".red().bold(), "key conflict".red().bold());
151                eprintln!("  Duplicate key '{}' in nested structure", key.red());
152            }
153            _ => eprintln!("{}", self.to_string().red()),
154        }
155    }
156}
157
158/// Parses a .hk file from a string input.
159pub fn parse_hk(input: &str) -> Result<HkConfig, HkError> {
160    let lines: Vec<&str> = input.lines().collect();
161    let mut config = IndexMap::new();
162    let mut i = 0;
163
164    while i < lines.len() {
165        let line = lines[i].trim_start();
166        if line.is_empty() || line.starts_with('!') {
167            i += 1;
168            continue;
169        }
170
171        if line.starts_with('[') {
172            let close = line.find(']').ok_or_else(|| HkError::Parse {
173                line: (i + 1) as u32,
174                column: line.find('[').unwrap() + 1,
175                message: "Unclosed section header".to_string(),
176            })?;
177            let section_name = line[1..close].trim();
178            if section_name.is_empty() {
179                return Err(HkError::Parse {
180                    line: (i + 1) as u32,
181                    column: close + 1,
182                    message: "Empty section name".to_string(),
183                });
184            }
185
186            // Find the end of this section (next section or EOF)
187            let mut end = i + 1;
188            while end < lines.len() {
189                let next_line = lines[end].trim_start();
190                if next_line.starts_with('[') {
191                    break;
192                }
193                end += 1;
194            }
195
196            let section_lines = &lines[i + 1..end];
197            let map = parse_map(1, section_lines, i + 1)?;
198            config.insert(section_name.to_string(), HkValue::Map(map));
199            i = end;
200        } else {
201            return Err(HkError::Parse {
202                line: (i + 1) as u32,
203                column: 1,
204                message: "Expected section header".to_string(),
205            });
206        }
207    }
208
209    Ok(config)
210}
211
212/// Parse a map from a slice of lines, starting with a given indentation level (number of dashes).
213/// level: the number of dashes expected for the current depth (e.g., 1 for "->", 2 for "-->")
214/// Returns the map and the index of the next line to process.
215fn parse_map(level: usize, lines: &[&str], start_line: usize) -> Result<IndexMap<String, HkValue>, HkError> {
216    let mut map = IndexMap::new();
217    let mut i = 0;
218
219    while i < lines.len() {
220        let line = lines[i];
221        let trimmed = line.trim_start();
222        if trimmed.is_empty() || trimmed.starts_with('!') {
223            i += 1;
224            continue;
225        }
226
227        // Count leading dashes
228        let dash_count = trimmed.chars().take_while(|c| *c == '-').count();
229        if dash_count == 0 {
230            return Err(HkError::Parse {
231                line: (start_line + i) as u32,
232                column: 1,
233                message: "Expected key or map header".to_string(),
234            });
235        }
236        if dash_count < level {
237            // Shallower level – legitimately return control to the caller.
238            break;
239        }
240        if dash_count > level {
241            // The line is MORE indented than expected at this depth. This means
242            // one or more nesting levels were skipped (e.g. jumping from "-->"
243            // straight to "---->" without a "--->" level in between). Previously
244            // this was silently treated the same as "end of this map", which made
245            // the parser drop the entire mismatched sub-tree without any warning.
246            // That is a data-loss bug, so we now report it as a proper parse error.
247            return Err(HkError::Parse {
248                line: (start_line + i) as u32,
249                column: 1,
250                message: format!(
251                    "Inconsistent nesting level: expected {} dash(es) (\"{}\") at this depth, found {} (\"{}\"). Nesting must increase by exactly one dash per level.",
252                    level,
253                    "-".repeat(level),
254                    dash_count,
255                    "-".repeat(dash_count)
256                ),
257            });
258        }
259
260        // After dashes, skip any spaces, expect '>', then skip spaces
261        let after_dashes = &trimmed[dash_count..];
262        let rest = after_dashes.trim_start();
263        if !rest.starts_with('>') {
264            return Err(HkError::Parse {
265                line: (start_line + i) as u32,
266                column: dash_count + 1,
267                message: "Expected '>' after dashes".to_string(),
268            });
269        }
270        let after_gt = &rest[1..].trim_start();
271        if after_gt.is_empty() {
272            return Err(HkError::Parse {
273                line: (start_line + i) as u32,
274                column: dash_count + 1,
275                message: "Missing key after '>'".to_string(),
276            });
277        }
278
279        // Check if it's a key-value line (contains "=>")
280        if let Some(arrow_pos) = after_gt.find("=>") {
281            let key = after_gt[..arrow_pos].trim();
282            let value_part = after_gt[arrow_pos + 2..].trim();
283            let key = unquote_key(key);
284            if key.is_empty() {
285                return Err(HkError::Parse {
286                    line: (start_line + i) as u32,
287                    column: dash_count + 1,
288                    message: "Empty key".to_string(),
289                });
290            }
291            let value = parse_value(value_part, start_line + i, arrow_pos + dash_count + 2)?;
292            insert_key(&mut map, &key, value)?;
293            i += 1;
294        } else {
295            // It's a map header: "- > key" without "=>"
296            let key = after_gt.trim();
297            let key = unquote_key(key);
298            if key.is_empty() {
299                return Err(HkError::Parse {
300                    line: (start_line + i) as u32,
301                    column: dash_count + 1,
302                    message: "Empty map key".to_string(),
303                });
304            }
305
306            // Find the sub-lines that belong to this map (higher level)
307            let next_level = level + 1;
308            let mut j = i + 1;
309            while j < lines.len() {
310                let sub_line = lines[j];
311                let sub_trimmed = sub_line.trim_start();
312                if sub_trimmed.is_empty() || sub_trimmed.starts_with('!') {
313                    j += 1;
314                    continue;
315                }
316                let sub_dash_count = sub_trimmed.chars().take_while(|c| *c == '-').count();
317                if sub_dash_count < next_level {
318                    break;
319                }
320                j += 1;
321            }
322
323            let sub_lines = &lines[i + 1..j];
324            let sub_map = parse_map(next_level, sub_lines, start_line + i + 1)?;
325            insert_key(&mut map, &key, HkValue::Map(sub_map))?;
326            i = j;
327        }
328    }
329
330    Ok(map)
331}
332
333/// Insert a key (which may contain dots for nesting) into the map.
334/// Keys that start or end with a dot are treated as literal keys (no nesting).
335fn insert_key(map: &mut IndexMap<String, HkValue>, key: &str, value: HkValue) -> Result<(), HkError> {
336    // If the key contains dots but not at the start or end, split and nest.
337    if key.contains('.') && !key.starts_with('.') && !key.ends_with('.') {
338        let parts: Vec<&str> = key.split('.').collect();
339        insert_nested(map, parts, value)
340    } else {
341        // Otherwise, treat as a single key.
342        if map.contains_key(key) {
343            return Err(HkError::KeyConflict(key.to_string()));
344        }
345        map.insert(key.to_string(), value);
346        Ok(())
347    }
348}
349
350/// Insert a nested key using the split parts.
351fn insert_nested(map: &mut IndexMap<String, HkValue>, keys: Vec<&str>, value: HkValue) -> Result<(), HkError> {
352    let mut current = map;
353    for key in &keys[0..keys.len() - 1] {
354        let entry = current
355            .entry(key.to_string())
356            .or_insert(HkValue::Map(IndexMap::new()));
357        if let HkValue::Map(submap) = entry {
358            current = submap;
359        } else {
360            return Err(HkError::KeyConflict(key.to_string()));
361        }
362    }
363    if let Some(last_key) = keys.last() {
364        current.insert(last_key.to_string(), value);
365    }
366    Ok(())
367}
368
369/// Remove surrounding quotes from a key (if present) and unescape inner quotes.
370fn unquote_key(s: &str) -> String {
371    let s = s.trim();
372    if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 {
373        let inner = &s[1..s.len() - 1];
374        inner.replace("\\\"", "\"")
375    } else {
376        s.to_string()
377    }
378}
379
380fn parse_value(s: &str, line: usize, column: usize) -> Result<HkValue, HkError> {
381    let s = s.trim();
382    if s.is_empty() {
383        return Err(HkError::Parse {
384            line: line as u32,
385            column,
386            message: "Empty value".to_string(),
387        });
388    }
389
390    // Array
391    if s.starts_with('[') && s.ends_with(']') {
392        let inner = &s[1..s.len() - 1];
393        let mut items = Vec::new();
394        let mut current = String::new();
395        let mut in_quotes = false;
396        let mut escape = false;
397        for c in inner.chars() {
398            if escape {
399                current.push(c);
400                escape = false;
401                continue;
402            }
403            match c {
404                '\\' => escape = true,
405                '"' => in_quotes = !in_quotes,
406                ',' if !in_quotes => {
407                    if !current.trim().is_empty() {
408                        let item = parse_simple_value(current.trim(), line, column)?;
409                        items.push(item);
410                        current.clear();
411                    }
412                }
413                _ => current.push(c),
414            }
415        }
416        if !current.trim().is_empty() {
417            let item = parse_simple_value(current.trim(), line, column)?;
418            items.push(item);
419        }
420        Ok(HkValue::Array(items))
421    } else {
422        parse_simple_value(s, line, column)
423    }
424}
425
426fn parse_simple_value(s: &str, line: usize, column: usize) -> Result<HkValue, HkError> {
427    let s = s.trim();
428    if s.is_empty() {
429        return Err(HkError::Parse {
430            line: line as u32,
431            column,
432            message: "Empty value".to_string(),
433        });
434    }
435
436    // Boolean
437    if s.eq_ignore_ascii_case("true") {
438        return Ok(HkValue::Bool(true));
439    }
440    if s.eq_ignore_ascii_case("false") {
441        return Ok(HkValue::Bool(false));
442    }
443
444    // Number
445    if let Ok(n) = f64::from_str(s) {
446        return Ok(HkValue::Number(n));
447    }
448
449    // Quoted string
450    if s.starts_with('"') && s.ends_with('"') {
451        let inner = &s[1..s.len() - 1];
452        let mut result = String::new();
453        let mut chars = inner.chars();
454        while let Some(c) = chars.next() {
455            if c == '\\' {
456                if let Some(next) = chars.next() {
457                    match next {
458                        'n' => result.push('\n'),
459                        'r' => result.push('\r'),
460                        't' => result.push('\t'),
461                        '"' => result.push('"'),
462                        '\\' => result.push('\\'),
463                        _ => result.push(next),
464                    }
465                }
466            } else {
467                result.push(c);
468            }
469        }
470        Ok(HkValue::String(result))
471    } else {
472        // Plain string
473        Ok(HkValue::String(s.to_string()))
474    }
475}
476
477/// Loads and parses a .hk file from the given path.
478pub fn load_hk_file<P: AsRef<Path>>(path: P) -> Result<HkConfig, HkError> {
479    let file = File::open(path)?;
480    let reader = BufReader::new(file);
481    let mut contents = String::new();
482    for line in reader.lines() {
483        let line = line?;
484        contents.push_str(&line);
485        contents.push('\n');
486    }
487    parse_hk(&contents)
488}
489
490/// Resolves interpolations in the config, including env vars and references.
491pub fn resolve_interpolations(config: &mut HkConfig) -> Result<(), HkError> {
492    let context = config.clone();
493    let mut resolved = HashSet::new();
494    let mut resolving = Vec::new();
495    for (section, value) in config.iter_mut() {
496        if let HkValue::Map(map) = value {
497            resolve_map(map, &context, &mut resolved, &mut resolving, &format!("{}", section))?;
498        }
499    }
500    Ok(())
501}
502
503fn resolve_map(
504    map: &mut IndexMap<String, HkValue>,
505    top: &HkConfig,
506    resolved: &mut HashSet<String>,
507    resolving: &mut Vec<String>,
508    path: &str,
509) -> Result<(), HkError> {
510    for (key, v) in map.iter_mut() {
511        let new_path = format!("{}.{}", path, key);
512        if resolved.contains(&new_path) {
513            continue;
514        }
515        resolving.push(new_path.clone());
516        resolve_value(v, top, resolved, resolving, &new_path)?;
517        resolving.pop();
518        resolved.insert(new_path);
519    }
520    Ok(())
521}
522
523fn resolve_value(
524    v: &mut HkValue,
525    top: &HkConfig,
526    resolved: &mut HashSet<String>,
527    resolving: &mut Vec<String>,
528    path: &str,
529) -> Result<(), HkError> {
530    match v {
531        HkValue::String(s) => {
532            let mut new_s = String::new();
533            let mut last = 0;
534            for cap in INTERPOL_RE.captures_iter(s) {
535                let m = cap.get(0).unwrap();
536                new_s.push_str(&s[last..m.start()]);
537                let var = &cap[1];
538                let repl = if var.starts_with("env:") {
539                    env::var(&var[4..]).unwrap_or_default()
540                } else {
541                    // Resolve the reference recursively, detecting cycles
542                    if resolving.contains(&var.to_string()) {
543                        return Err(HkError::CyclicReference(var.to_string()));
544                    }
545                    resolve_reference(var, top, resolved, resolving)?
546                };
547                new_s.push_str(&repl);
548                last = m.end();
549            }
550            new_s.push_str(&s[last..]);
551            *s = new_s;
552        }
553        HkValue::Array(a) => {
554            for (i, item) in a.iter_mut().enumerate() {
555                resolve_value(item, top, resolved, resolving, &format!("{}[{}]", path, i))?;
556            }
557        }
558        HkValue::Map(m) => {
559            resolve_map(m, top, resolved, resolving, path)?;
560        }
561        _ => {}
562    }
563    Ok(())
564}
565
566fn resolve_reference(
567    path: &str,
568    top: &HkConfig,
569    resolved: &mut HashSet<String>,
570    resolving: &mut Vec<String>,
571) -> Result<String, HkError> {
572    // Check if the reference is already in the resolving stack (cycle)
573    if resolving.contains(&path.to_string()) {
574        return Err(HkError::CyclicReference(path.to_string()));
575    }
576
577    // Get the raw value from the config
578    let raw_value = get_value_by_path(path, top).ok_or_else(|| HkError::InvalidReference(path.to_string()))?;
579    // Clone the value so we can resolve it without affecting the original
580    let mut cloned_value = raw_value.clone();
581
582    // Push the path onto the resolving stack
583    resolving.push(path.to_string());
584
585    // Resolve the cloned value recursively
586    resolve_value(&mut cloned_value, top, resolved, resolving, path)?;
587
588    // Pop the path from the stack
589    resolving.pop();
590
591    // Convert the resolved value to a string
592    cloned_value.as_string()
593}
594
595fn get_value_by_path<'a>(path: &str, config: &'a HkConfig) -> Option<&'a HkValue> {
596    let bracket_re = Regex::new(r"([^\[\].]+)(?:\[(\d+)\])?").unwrap();
597    let mut parts = Vec::new();
598    for cap in bracket_re.captures_iter(path) {
599        let key = cap.get(1).map(|m| m.as_str()).unwrap();
600        let idx = cap.get(2).map(|m| m.as_str().parse::<usize>().ok());
601        parts.push((key, idx.flatten()));
602    }
603
604    if parts.is_empty() {
605        return None;
606    }
607
608    let (first_key, _) = parts[0];
609    let mut current_value: Option<&'a HkValue> = config.get(first_key);
610    for (key, idx) in parts.iter().skip(1) {
611        match current_value {
612            Some(HkValue::Map(map)) => {
613                current_value = map.get(*key);
614            }
615            Some(HkValue::Array(arr)) if idx.is_some() => {
616                if let Some(i) = idx {
617                    if *i < arr.len() {
618                        current_value = Some(&arr[*i]);
619                        continue;
620                    } else {
621                        return None;
622                    }
623                } else {
624                    return None;
625                }
626            }
627            _ => return None,
628        }
629        if let Some(idx) = idx {
630            if let Some(HkValue::Array(arr)) = current_value {
631                if *idx < arr.len() {
632                    current_value = Some(&arr[*idx]);
633                } else {
634                    return None;
635                }
636            } else {
637                return None;
638            }
639        }
640    }
641    current_value
642}
643
644/// Serializes a HkConfig back to a .hk string, preserving key order.
645pub fn serialize_hk(config: &HkConfig) -> String {
646    let mut output = String::new();
647    for (section, value) in config.iter() {
648        output.push_str(&format!("[{}]\n", section));
649        if let HkValue::Map(map) = value {
650            serialize_map(map, 1, &mut output);
651        }
652        output.push('\n');
653    }
654    output.trim_end().to_string()
655}
656
657fn serialize_map(map: &IndexMap<String, HkValue>, level: usize, output: &mut String) {
658    let prefix = "-".repeat(level) + " > ";
659    for (key, value) in map.iter() {
660        match value {
661            HkValue::Map(submap) => {
662                output.push_str(&format!("{}{}\n", prefix, key));
663                serialize_map(submap, level + 1, output);
664            }
665            _ => {
666                let val = serialize_value(value);
667                output.push_str(&format!("{}{} => {}\n", prefix, key, val));
668            }
669        }
670    }
671}
672
673fn serialize_value(value: &HkValue) -> String {
674    match value {
675        HkValue::String(s) => {
676            if s.is_empty() || s.contains(',') || s.contains(' ') || s.contains(']') || s.contains('"') || s.contains('\n') {
677                format!("\"{}\"", s.replace("\"", "\\\""))
678            } else {
679                s.clone()
680            }
681        }
682        HkValue::Number(n) => n.to_string(),
683        HkValue::Bool(b) => if *b { "true".to_string() } else { "false".to_string() },
684        HkValue::Array(a) => format!(
685            "[{}]",
686            a.iter()
687                .map(serialize_value)
688                .collect::<Vec<_>>()
689                .join(", ")
690        ),
691        HkValue::Map(_) => "<map>".to_string(),
692    }
693}
694
695pub fn write_hk_file<P: AsRef<Path>>(path: P, config: &HkConfig) -> io::Result<()> {
696    let mut file = File::create(path)?;
697    file.write_all(serialize_hk(config).as_bytes())
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703    use pretty_assertions::assert_eq;
704
705    #[test]
706    fn test_parse_libraries_repo() {
707        let input = r#"
708! Repozytorium bibliotek dla Hacker Lang
709
710[libraries]
711-> obsidian
712--> version => 0.2
713--> description => Biblioteka inspirowana zenity.
714--> authors => ["HackerOS Team <hackeros068@gmail.com>"]
715--> so-download => https://github.com/Bytes-Repository/obsidian-lib/releases/download/v0.2/libobsidian_lib.so
716--> .hl-download => https://github.com/Bytes-Repository/obsidian-lib/blob/main/obsidian.hl
717
718-> yuy
719--> version => 0.2
720--> description => Twórz ładne interfejsy cli
721"#;
722        let result = parse_hk(input).expect("Failed to parse libraries file");
723        assert!(result.contains_key("libraries"));
724        let libraries = result["libraries"].as_map().unwrap();
725        assert!(libraries.contains_key("obsidian"));
726        let obsidian = libraries["obsidian"].as_map().unwrap();
727        assert_eq!(obsidian["version"].as_number().unwrap(), 0.2);
728        assert_eq!(obsidian["description"].as_string().unwrap(), "Biblioteka inspirowana zenity.");
729        assert!(obsidian.contains_key("so-download"));
730        assert!(obsidian.contains_key(".hl-download"));
731        assert_eq!(
732            obsidian[".hl-download"].as_string().unwrap(),
733            "https://github.com/Bytes-Repository/obsidian-lib/blob/main/obsidian.hl"
734        );
735
736        assert!(libraries.contains_key("yuy"));
737        let yuy = libraries["yuy"].as_map().unwrap();
738        assert_eq!(yuy["version"].as_number().unwrap(), 0.2);
739    }
740
741    #[test]
742    fn test_parse_hk_with_comments_and_types() {
743        let input = r#"
744        ! Globalne informacje o projekcie
745        [metadata]
746        -> name => Hacker Lang
747        -> version => 1.5
748        -> list => [1, 2.5, true, "four"]
749        "#;
750        let result = parse_hk(input).unwrap();
751        assert!(result.contains_key("metadata"));
752        let metadata = result["metadata"].as_map().unwrap();
753        assert_eq!(metadata["name"].as_string().unwrap(), "Hacker Lang");
754        assert_eq!(metadata["version"].as_number().unwrap(), 1.5);
755        let list = metadata["list"].as_array().unwrap();
756        assert_eq!(list.len(), 4);
757    }
758
759    #[test]
760    fn test_edge_cases() {
761        // Empty section
762        let input = "[empty]\n";
763        let config = parse_hk(input).unwrap();
764        assert!(config.contains_key("empty"));
765        assert_eq!(config["empty"].as_map().unwrap().len(), 0);
766
767        // Section with only comments
768        let input = "[comments]\n! comment\n! another\n";
769        let config = parse_hk(input).unwrap();
770        assert!(config.contains_key("comments"));
771        assert_eq!(config["comments"].as_map().unwrap().len(), 0);
772
773        // Nested map with dots in keys
774        let input = r#"
775[config]
776-> a.b.c => 42
777"#;
778        let config = parse_hk(input).unwrap();
779        let a = config["config"].as_map().unwrap().get("a").unwrap().as_map().unwrap();
780        let b = a.get("b").unwrap().as_map().unwrap();
781        let c = b.get("c").unwrap().as_number().unwrap();
782        assert_eq!(c, 42.0);
783    }
784
785    #[test]
786    fn test_array_reference() {
787        let input = r#"
788[data]
789-> numbers => [10, 20, 30]
790-> first => ${data.numbers[0]}
791"#;
792        let mut config = parse_hk(input).unwrap();
793        resolve_interpolations(&mut config).unwrap();
794        let first = config["data"].as_map().unwrap()["first"].as_string().unwrap();
795        assert_eq!(first, "10");
796    }
797
798    #[test]
799    fn test_cyclic_reference_detection() {
800        let input = r#"
801[a]
802-> b => ${a.c}
803-> c => ${a.b}
804"#;
805        let mut config = parse_hk(input).unwrap();
806        let err = resolve_interpolations(&mut config).unwrap_err();
807        match err {
808            HkError::CyclicReference(path) => {
809                assert!(path.contains("a.b") || path.contains("a.c"));
810            }
811            _ => panic!("Expected cyclic reference error, got {:?}", err),
812        }
813    }
814
815    #[test]
816    fn test_key_conflict() {
817        let input = r#"
818[conflict]
819-> a => 1
820-> a.b => 2
821"#;
822        let result = parse_hk(input);
823        assert!(result.is_err());
824    }
825
826    #[test]
827    fn test_invalid_reference() {
828        let input = r#"
829[a]
830-> b => ${a.missing}
831"#;
832        let mut config = parse_hk(input).unwrap();
833        let err = resolve_interpolations(&mut config).unwrap_err();
834        match err {
835            HkError::InvalidReference(var) => {
836                assert_eq!(var, "a.missing");
837            }
838            _ => panic!("Expected invalid reference error"),
839        }
840    }
841
842    #[test]
843    fn test_serialize_roundtrip() {
844        let input = r#"
845[test]
846-> key => value
847-> array => [1, "two", true]
848-> nested
849--> sub => 42
850"#;
851        let config = parse_hk(input).unwrap();
852        let serialized = serialize_hk(&config);
853        let parsed_again = parse_hk(&serialized).unwrap();
854        assert_eq!(config, parsed_again);
855    }
856
857    #[test]
858    fn test_skipped_nesting_level_is_rejected() {
859        // Reproduces the exact "logging" bug: a level-2 map header ("-->")
860        // whose children jump straight to level 4 ("---->"), skipping level 3.
861        // Previously this silently produced an empty map instead of erroring.
862        let input = r#"
863[global]
864-> features
865--> logging
866----> level => debug
867----> file => /var/log/app.log
868"#;
869        let result = parse_hk(input);
870        assert!(result.is_err(), "Expected a parse error for skipped nesting level, got Ok");
871        match result.unwrap_err() {
872            HkError::Parse { message, .. } => {
873                assert!(message.contains("Inconsistent nesting level"), "unexpected message: {message}");
874            }
875            other => panic!("Expected HkError::Parse, got {:?}", other),
876        }
877    }
878
879    #[test]
880    fn test_skipped_nesting_level_in_dotted_key_header_is_rejected() {
881        // Reproduces the "array.of.objects" bug: a level-1 dotted-key map header
882        // whose children jump straight to level 3 ("--->"), skipping level 2.
883        let input = r#"
884[deep]
885-> array.of.objects
886---> item => value
887"#;
888        let result = parse_hk(input);
889        assert!(result.is_err(), "Expected a parse error for skipped nesting level, got Ok");
890    }
891
892    #[test]
893    fn test_correct_incremental_nesting_still_works() {
894        // Sanity check: properly incremented nesting (one extra dash per level)
895        // must keep working exactly as before.
896        let input = r#"
897[global]
898-> features
899--> logging
900---> level => debug
901---> file => /var/log/app.log
902"#;
903        let config = parse_hk(input).unwrap();
904        let logging = config["global"]
905            .as_map().unwrap()["features"]
906            .as_map().unwrap()["logging"]
907            .as_map().unwrap();
908        assert_eq!(logging["level"].as_string().unwrap(), "debug");
909        assert_eq!(logging["file"].as_string().unwrap(), "/var/log/app.log");
910    }
911
912    #[test]
913    fn test_shallower_return_to_caller_still_works() {
914        // Sanity check: legitimately dedenting back to a shallower level
915        // (the normal "end of this nested map" case) must NOT error.
916        let input = r#"
917[a]
918-> x
919--> y => 1
920-> z => 2
921"#;
922        let config = parse_hk(input).unwrap();
923        let a = config["a"].as_map().unwrap();
924        assert_eq!(a["x"].as_map().unwrap()["y"].as_number().unwrap(), 1.0);
925        assert_eq!(a["z"].as_number().unwrap(), 2.0);
926    }
927
928    #[test]
929    fn test_empty_string_roundtrip() {
930        // An empty string must serialize back to a quoted "" so it can be
931        // re-parsed, instead of silently disappearing.
932        let input = r#"
933[a]
934-> key => ""
935"#;
936        let config = parse_hk(input).unwrap();
937        assert_eq!(config["a"].as_map().unwrap()["key"].as_string().unwrap(), "");
938        let serialized = serialize_hk(&config);
939        assert!(serialized.contains("\"\""), "expected empty string to serialize as \"\", got: {serialized}");
940        let parsed_again = parse_hk(&serialized).unwrap();
941        assert_eq!(config, parsed_again);
942    }
943}