//// Minimal INI parser, sized for the AWS shared credentials file and the //// flat parts of `~/.aws/config`. Supports the subset: //// //// - `[section-name]` headers //// - `key = value` properties (whitespace around `=` trimmed) //// - `#` and `;` whole-line comments //// - blank lines //// //// Out of scope for now (we'll grow into these when SSO and assume-role //// providers need them): //// //// - line continuation for multi-line values //// - sub-properties (nested key/value inside a property) //// - inline `#`/`;` comments mid-value //// //// Errors carry the offending line number so the surfaced message can //// point the user at the broken line of their credentials file. import gleam/dict.{type Dict} import gleam/list import gleam/result import gleam/string /// Parsed INI representation: section name -> property name -> value. pub type Ini = Dict(String, Dict(String, String)) pub type ParseError { ParseError(line: Int, message: String) } pub fn parse(text: String) -> Result(Ini, ParseError) { text |> string.split("\n") |> list.index_map(fn(line, i) { #(i + 1, line) }) |> parse_lines("", dict.new(), dict.new()) } fn parse_lines( lines: List(#(Int, String)), current_section: String, current_props: Dict(String, String), ini: Ini, ) -> Result(Ini, ParseError) { case lines { [] -> Ok(commit_section(current_section, current_props, ini)) [#(line_no, raw), ..rest] -> { let trimmed = string.trim(raw) case classify(trimmed) { Blank | Comment -> parse_lines(rest, current_section, current_props, ini) Section(name) -> { let new_ini = commit_section(current_section, current_props, ini) parse_lines(rest, name, dict.new(), new_ini) } Property(key, value) -> case current_section { "" -> Error(ParseError( line: line_no, message: "property '" <> key <> "' outside any section", )) _ -> parse_lines( rest, current_section, dict.insert(current_props, key, value), ini, ) } Malformed(message) -> Error(ParseError(line: line_no, message: message)) } } } } type LineKind { Blank Comment Section(String) Property(String, String) Malformed(String) } fn classify(line: String) -> LineKind { case line { "" -> Blank _ -> case string.slice(line, 0, 1) { "#" | ";" -> Comment "[" -> classify_section(line) _ -> classify_property(line) } } } fn classify_section(line: String) -> LineKind { case string.ends_with(line, "]") { False -> Malformed("section header missing closing ']'") True -> { // Strip the surrounding brackets and trim any inner whitespace so // `[ default ]` and `[default]` both map to the section "default". let inner = string.slice(line, 1, string.length(line) - 2) Section(string.trim(inner)) } } } fn classify_property(line: String) -> LineKind { case string.split_once(line, "=") { Error(_) -> Malformed("expected 'key = value'") Ok(#(key, value)) -> { let key = string.trim(key) let value = string.trim(value) case key { "" -> Malformed("property name is empty") _ -> Property(key, value) } } } } fn commit_section(name: String, props: Dict(String, String), ini: Ini) -> Ini { case name { "" -> ini _ -> dict.insert(ini, name, props) } } /// Look up a single property value inside a section. pub fn get_property( ini: Ini, section section: String, key key: String, ) -> Result(String, Nil) { use props <- result.try(dict.get(ini, section)) dict.get(props, key) }