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
- Real config parsing โ read `.gitconfig`, `setup.cfg`, application settings without a dependency.
- Line-oriented parsing pattern โ works for log files, `.env` files, and many other formats.
- Two-level structure โ sections containing entries is a pattern that generalizes (e.g., TOML is similar).
Key Differences
| Concept | OCaml | Rust | ||
|---|---|---|---|---|
| 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(|c| 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(§ions);
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(§ions);
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))
}
}
}