ExamplesBy LevelBy TopicLearning Paths
172 Advanced

INI File Parser

Functional Programming

Tutorial

The Problem

INI files are used for configuration in Windows, Python's configparser, Git's .gitconfig, and countless other tools. The format has sections [name], key-value pairs key = value, comments ; ... or # ..., and optional whitespace. Building an INI parser demonstrates real-world parsing with multiple line types requiring different handling, and produces a structured result (HashMap<String, HashMap<String, String>>) directly usable by applications.

🎯 Learning Outcomes

  • • Parse a multi-line format with heterogeneous line types (section headers, key-value pairs, comments, blanks)
  • • Learn how choice selects the correct parser for each line type
  • • See how parsing produces structured output: a map of section names to key-value maps
  • • Practice combining all previously learned combinators into a complete, useful parser
  • Code Example

    fn parse_section_header(input: &str) -> ParseResult<String> {
        let s = input.trim_start();
        if !s.starts_with('[') { return Err(...); }
        match s.find(']') {
            Some(i) => {
                let name = s[1..i].trim().to_string();
                Ok((name, skip_line(&s[i + 1..])))
            }
            None => Err("Expected ']'".into()),
        }
    }

    Key Differences

  • Line-oriented vs. token-oriented: INI files are fundamentally line-oriented; the combinator approach handles this by including newline matching in each line parser.
  • Comments: Both discard comment lines; combining comment skipping with whitespace skipping produces a clean value stream.
  • Multi-line values: Some INI variants support continuation lines (value = ...\n continuation); neither basic parser handles this.
  • Encoding: Both parse UTF-8; section names and keys with non-ASCII characters require explicit allowance in the character predicates.
  • OCaml Approach

    OCaml's Str module provides regex-based line parsing — a simpler approach:

    let section_re = Str.regexp "\\[\\([^]]+\\)\\]"
    let kv_re = Str.regexp "\\([^=]+\\)=\\(.*\\)"
    

    This is less composable but practical for simple INI files. Combinator-based OCaml INI parsers exist but are less common than regex-based approaches for configuration file parsing.

    Full Source

    #![allow(clippy::all)]
    // Example 172: INI File Parser
    // INI file parser: sections [name], key = value pairs
    
    use std::collections::HashMap;
    
    type ParseResult<'a, T> = Result<(T, &'a str), String>;
    
    #[derive(Debug, Clone)]
    struct IniSection {
        name: String,
        entries: Vec<(String, String)>,
    }
    
    type IniFile = Vec<IniSection>;
    
    fn skip_line(input: &str) -> &str {
        match input.find('\n') {
            Some(i) => &input[i + 1..],
            None => "",
        }
    }
    
    fn skip_blank_and_comments(mut input: &str) -> &str {
        loop {
            input = input.trim_start_matches(|c: char| c == ' ' || c == '\t');
            if input.starts_with('\n') {
                input = &input[1..];
            } else if input.starts_with('#') || input.starts_with(';') {
                input = skip_line(input);
            } else {
                return input;
            }
        }
    }
    
    // ============================================================
    // Approach 1: Parse section header [name]
    // ============================================================
    
    fn parse_section_header(input: &str) -> ParseResult<String> {
        let s = input.trim_start();
        if !s.starts_with('[') {
            return Err("Expected '['".to_string());
        }
        match s.find(']') {
            Some(i) => {
                let name = s[1..i].trim().to_string();
                let rest = skip_line(&s[i + 1..]);
                Ok((name, rest))
            }
            None => Err("Expected ']'".to_string()),
        }
    }
    
    // ============================================================
    // Approach 2: Parse key = value
    // ============================================================
    
    fn parse_entry(input: &str) -> ParseResult<(String, String)> {
        let s = input.trim_start_matches(|c: char| c == ' ' || c == '\t');
        if s.is_empty()
            || s.starts_with('[')
            || s.starts_with('#')
            || s.starts_with(';')
            || s.starts_with('\n')
        {
            return Err("Not a key=value entry".to_string());
        }
        let line_end = s.find('\n').unwrap_or(s.len());
        let line = &s[..line_end];
        match line.find('=') {
            Some(eq_pos) => {
                let key = line[..eq_pos].trim().to_string();
                let mut value = line[eq_pos + 1..].trim().to_string();
                // Strip inline comments
                if let Some(hash) = value.find('#') {
                    value = value[..hash].trim().to_string();
                }
                if let Some(semi) = value.find(';') {
                    value = value[..semi].trim().to_string();
                }
                let rest = if line_end < s.len() {
                    &s[line_end + 1..]
                } else {
                    ""
                };
                Ok(((key, value), rest))
            }
            None => Err("Expected '='".to_string()),
        }
    }
    
    // ============================================================
    // Approach 3: Full INI parser
    // ============================================================
    
    fn parse_ini(input: &str) -> ParseResult<IniFile> {
        let mut sections = Vec::new();
        let mut remaining = skip_blank_and_comments(input);
    
        while !remaining.is_empty() {
            let (name, rest) = parse_section_header(remaining)?;
            let mut entries = Vec::new();
            remaining = skip_blank_and_comments(rest);
    
            while let Ok(((key, value), rest)) = parse_entry(remaining) {
                entries.push((key, value));
                remaining = skip_blank_and_comments(rest);
            }
    
            sections.push(IniSection { name, entries });
        }
    
        Ok((sections, ""))
    }
    
    /// Convert to HashMap for easy lookup
    fn ini_to_map(sections: &[IniSection]) -> HashMap<String, HashMap<String, String>> {
        sections
            .iter()
            .map(|s| {
                let entries: HashMap<String, String> = s.entries.iter().cloned().collect();
                (s.name.clone(), entries)
            })
            .collect()
    }
    
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_section_header() {
            let (name, _) = parse_section_header("[database]").unwrap();
            assert_eq!(name, "database");
        }
    
        #[test]
        fn test_section_header_spaces() {
            let (name, _) = parse_section_header("[ my section ]").unwrap();
            assert_eq!(name, "my section");
        }
    
        #[test]
        fn test_entry() {
            let ((k, v), _) = parse_entry("host = localhost\n").unwrap();
            assert_eq!(k, "host");
            assert_eq!(v, "localhost");
        }
    
        #[test]
        fn test_entry_inline_comment() {
            let ((k, v), _) = parse_entry("name = myapp # comment\n").unwrap();
            assert_eq!(k, "name");
            assert_eq!(v, "myapp");
        }
    
        #[test]
        fn test_full_ini() {
            let input = "[db]\nhost = localhost\nport = 5432\n\n[app]\nname = test\n";
            let (sections, _) = parse_ini(input).unwrap();
            assert_eq!(sections.len(), 2);
            assert_eq!(sections[0].name, "db");
            assert_eq!(sections[0].entries.len(), 2);
            assert_eq!(sections[1].name, "app");
        }
    
        #[test]
        fn test_comments_skipped() {
            let input = "# header comment\n[s]\n; another comment\nk = v\n";
            let (sections, _) = parse_ini(input).unwrap();
            assert_eq!(sections.len(), 1);
            assert_eq!(sections[0].entries.len(), 1);
        }
    
        #[test]
        fn test_ini_to_map() {
            let input = "[db]\nhost = localhost\n";
            let (sections, _) = parse_ini(input).unwrap();
            let map = ini_to_map(&sections);
            assert_eq!(map["db"]["host"], "localhost");
        }
    }
    ✓ Tests Rust test suite
    #[cfg(test)]
    mod tests {
        use super::*;
    
        #[test]
        fn test_section_header() {
            let (name, _) = parse_section_header("[database]").unwrap();
            assert_eq!(name, "database");
        }
    
        #[test]
        fn test_section_header_spaces() {
            let (name, _) = parse_section_header("[ my section ]").unwrap();
            assert_eq!(name, "my section");
        }
    
        #[test]
        fn test_entry() {
            let ((k, v), _) = parse_entry("host = localhost\n").unwrap();
            assert_eq!(k, "host");
            assert_eq!(v, "localhost");
        }
    
        #[test]
        fn test_entry_inline_comment() {
            let ((k, v), _) = parse_entry("name = myapp # comment\n").unwrap();
            assert_eq!(k, "name");
            assert_eq!(v, "myapp");
        }
    
        #[test]
        fn test_full_ini() {
            let input = "[db]\nhost = localhost\nport = 5432\n\n[app]\nname = test\n";
            let (sections, _) = parse_ini(input).unwrap();
            assert_eq!(sections.len(), 2);
            assert_eq!(sections[0].name, "db");
            assert_eq!(sections[0].entries.len(), 2);
            assert_eq!(sections[1].name, "app");
        }
    
        #[test]
        fn test_comments_skipped() {
            let input = "# header comment\n[s]\n; another comment\nk = v\n";
            let (sections, _) = parse_ini(input).unwrap();
            assert_eq!(sections.len(), 1);
            assert_eq!(sections[0].entries.len(), 1);
        }
    
        #[test]
        fn test_ini_to_map() {
            let input = "[db]\nhost = localhost\n";
            let (sections, _) = parse_ini(input).unwrap();
            let map = ini_to_map(&sections);
            assert_eq!(map["db"]["host"], "localhost");
        }
    }

    Deep Comparison

    Comparison: Example 172 — INI Parser

    Section header

    OCaml:

    let parse_section_header input =
      let s = ws0 input in
      if s.[0] = '[' then
        match String.index_opt s ']' with
        | Some i ->
          let name = String.trim (String.sub s 1 (i - 1)) in
          Ok (name, skip_line (String.sub s (i+1) ...))
    

    Rust:

    fn parse_section_header(input: &str) -> ParseResult<String> {
        let s = input.trim_start();
        if !s.starts_with('[') { return Err(...); }
        match s.find(']') {
            Some(i) => {
                let name = s[1..i].trim().to_string();
                Ok((name, skip_line(&s[i + 1..])))
            }
            None => Err("Expected ']'".into()),
        }
    }
    

    Key-value entry

    OCaml:

    let parse_entry input =
      match String.index_opt s '=' with
      | Some i ->
        let key = String.trim (String.sub s 0 i) in
        let value = String.trim (String.sub rest_line 0 value_end) in
        Ok ((key, value), remaining)
    

    Rust:

    fn parse_entry(input: &str) -> ParseResult<(String, String)> {
        let line = &s[..line_end];
        match line.find('=') {
            Some(eq_pos) => {
                let key = line[..eq_pos].trim().to_string();
                let mut value = line[eq_pos + 1..].trim().to_string();
                Ok(((key, value), rest))
            }
        }
    }
    

    Exercises

  • Add support for #-prefixed comments in addition to ;-prefixed ones.
  • Handle inline comments: key = value ; this is a comment where the value is everything before the ;.
  • Implement a global section for key-value pairs before the first [section] header.
  • Open Source Repos