๐Ÿฆ€ Functional Rust

172: INI File Parser

Difficulty: 3 Level: Advanced Parse `.ini` config files with sections, key-value pairs, and comments โ€” a complete real-world config format.

The Problem This Solves

INI files are everywhere: Git's `.gitconfig`, Python's `setup.cfg`, Windows registry exports, game configs, application settings. The format seems simple โ€” `key = value` โ€” but has enough structure to be interesting: sections group related keys, comments can appear on any line, inline comments trail after values, and blank lines should be ignored. Building an INI parser from scratch is a clean exercise in line-oriented parsing โ€” a common real-world pattern where the top-level structure is rows, and each row has internal structure. It's simpler than CSV (no quoting), but introduces the two-level hierarchy: sections contain entries. This is one of the "payoff" examples: you're using `tag`, `take_while`, and whitespace combinators from earlier to build something you could actually ship.

The Intuition

Process line by line. Each line is one of: blank, comment, section header `[name]`, or key-value pair `key = value`. Group consecutive key-value pairs under the most recently seen section header.
[database]       โ† section
host = localhost  โ† key-value under [database]
port = 5432       โ† key-value under [database]
# Primary DB      โ† comment, skip

[cache]          โ† new section
ttl = 300        โ† key-value under [cache]

How It Works in Rust

#[derive(Debug)]
struct IniSection {
 name: String,
 entries: Vec<(String, String)>,
}

fn parse_ini(input: &str) -> ParseResult<Vec<IniSection>> {
 let mut sections: Vec<IniSection> = Vec::new();
 let mut remaining = input;

 while !remaining.is_empty() {
     let line_end = remaining.find('\n').unwrap_or(remaining.len());
     let line = remaining[..line_end].trim();
     remaining = if line_end < remaining.len() {
         &remaining[line_end + 1..]  // skip the '\n'
     } else {
         ""
     };

     if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
         continue;  // blank or comment
     }

     if line.starts_with('[') {
         // Section header: [name]
         let name = line.strip_prefix('[')
             .and_then(|s| s.strip_suffix(']'))
             .ok_or("invalid section header")?
             .trim()
             .to_string();
         sections.push(IniSection { name, entries: Vec::new() });
     } else if let Some(eq_pos) = line.find('=') {
         // Key-value pair
         let key = line[..eq_pos].trim().to_string();
         let value_raw = line[eq_pos + 1..].trim();

         // Strip inline comment (# or ;)
         let value = value_raw
             .find(|c| c == '#' || c == ';')
             .map(|i| value_raw[..i].trim())
             .unwrap_or(value_raw)
             .to_string();

         // Append to the current section (or a default unnamed section)
         if sections.is_empty() {
             sections.push(IniSection { name: "".to_string(), entries: Vec::new() });
         }
         sections.last_mut().unwrap().entries.push((key, value));
     }
 }

 Ok((sections, ""))
}

// Convenience: convert to HashMap for easy lookup
use std::collections::HashMap;
fn section_map(section: &IniSection) -> HashMap<&str, &str> {
 section.entries.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect()
}

What This Unlocks

Key Differences

ConceptOCamlRust
Lookup`List.assoc "key" entries``HashMap` or linear `find` on `Vec`
Section record`{ name: string; entries: (string * string) list }``struct IniSection { name: String, entries: Vec<(String, String)> }`
String trimming`String.trim``str::trim()`
Inline comment`String.index_opt '#' s``str::find('#')` or `find(&#124;c&#124; c == '#' \\c == ';')`
// 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()
}

fn main() {
    let ini_text = "[database]\nhost = localhost\nport = 5432\n\n# Comment\n[server]\nname = myapp # main\nlog = true\n";

    println!("=== parse_ini ===");
    match parse_ini(ini_text) {
        Ok((sections, _)) => {
            let map = ini_to_map(&sections);
            println!("database.host = {:?}", map["database"]["host"]);
            println!("database.port = {:?}", map["database"]["port"]);
            println!("server.name = {:?}", map["server"]["name"]);
            println!("server.log = {:?}", map["server"]["log"]);
        }
        Err(e) => println!("Error: {}", e),
    }

    println!("\nโœ“ All examples completed");
}

#[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");
    }
}
(* Example 172: INI File Parser *)
(* INI file parser: sections [name], key = value pairs *)

type 'a parse_result = ('a * string, string) result
type 'a parser = string -> 'a parse_result

type ini_value = string
type ini_section = { name: string; entries: (string * ini_value) list }
type ini_file = ini_section list

let ws0 input =
  let rec skip i = if i < String.length input &&
    (input.[i] = ' ' || input.[i] = '\t') then skip (i+1) else i in
  let i = skip 0 in String.sub input i (String.length input - i)

let skip_line input =
  match String.index_opt input '\n' with
  | Some i -> String.sub input (i+1) (String.length input - i - 1)
  | None -> ""

(* Approach 1: Parse section header [name] *)
let parse_section_header input =
  let s = ws0 input in
  if String.length s > 0 && s.[0] = '[' then
    match String.index_opt s ']' with
    | Some i ->
      let name = String.trim (String.sub s 1 (i - 1)) in
      let rest = String.sub s (i+1) (String.length s - i - 1) in
      Ok (name, skip_line rest)
    | None -> Error "Expected ']'"
  else Error "Expected '['"

(* Approach 2: Parse key = value *)
let parse_entry input =
  let s = ws0 input in
  if String.length s = 0 || s.[0] = '[' || s.[0] = '#' || s.[0] = ';' || s.[0] = '\n' then
    Error "Not a key=value entry"
  else
    match String.index_opt s '=' with
    | Some i ->
      let key = String.trim (String.sub s 0 i) in
      let rest_line = String.sub s (i+1) (String.length s - i - 1) in
      let value_end = match String.index_opt rest_line '\n' with
        | Some j -> j | None -> String.length rest_line in
      let value = String.trim (String.sub rest_line 0 value_end) in
      (* strip inline comments *)
      let value = match String.index_opt value '#' with
        | Some j -> String.trim (String.sub value 0 j) | None -> value in
      let remaining = if value_end < String.length rest_line then
        String.sub rest_line (value_end + 1) (String.length rest_line - value_end - 1)
      else "" in
      Ok ((key, value), remaining)
    | None -> Error "Expected '='"

(* Approach 3: Full INI parser *)
let skip_blank_and_comments input =
  let rec go s =
    let s = ws0 s in
    if String.length s = 0 then s
    else if s.[0] = '\n' then go (String.sub s 1 (String.length s - 1))
    else if s.[0] = '#' || s.[0] = ';' then go (skip_line s)
    else s
  in go input

let parse_ini input =
  let rec parse_sections acc remaining =
    let remaining = skip_blank_and_comments remaining in
    if String.length remaining = 0 then Ok (List.rev acc, "")
    else
      match parse_section_header remaining with
      | Ok (name, rest) ->
        let rec parse_entries eacc r =
          let r = skip_blank_and_comments r in
          match parse_entry r with
          | Ok (entry, rest) -> parse_entries (entry :: eacc) rest
          | Error _ -> (List.rev eacc, r)
        in
        let (entries, rest) = parse_entries [] rest in
        parse_sections ({ name; entries } :: acc) rest
      | Error e -> Error e
  in
  parse_sections [] input

(* Tests *)
let () =
  let ini_text = "[database]\nhost = localhost\nport = 5432\n\n[server]\nname = myapp # main\nlog = true\n" in
  (match parse_ini ini_text with
   | Ok (sections, "") ->
     assert (List.length sections = 2);
     let db = List.nth sections 0 in
     assert (db.name = "database");
     assert (List.assoc "host" db.entries = "localhost");
     assert (List.assoc "port" db.entries = "5432");
     let srv = List.nth sections 1 in
     assert (srv.name = "server");
     assert (List.assoc "name" srv.entries = "myapp");
   | _ -> failwith "INI parse failed");

  assert (parse_section_header "[test]" = Ok ("test", ""));

  print_endline "โœ“ All tests passed"

๐Ÿ“Š Detailed Comparison

Comparison: Example 172 โ€” INI Parser

Section header

OCaml:

๐Ÿช Show OCaml equivalent
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:

๐Ÿช Show OCaml equivalent
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))
     }
 }
}