Fini 0.5.0

dotnet add package Fini --version 0.5.0
                    
NuGet\Install-Package Fini -Version 0.5.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Fini" Version="0.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Fini" Version="0.5.0" />
                    
Directory.Packages.props
<PackageReference Include="Fini" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Fini --version 0.5.0
                    
#r "nuget: Fini, 0.5.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Fini@0.5.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Fini&version=0.5.0
                    
Install as a Cake Addin
#tool nuget:?package=Fini&version=0.5.0
                    
Install as a Cake Tool

Fini

NuGet Build License: MIT

A simple INI parser written in F#, usable from F# and C# alike.

Version 0.5.0 removes parser-function lookups tryFindWith, tryFindNestedWith, findWith and findNestedWith (TryFindWith, TryFindNestedWith, FindWith and FindNestedWith in C#) and adds indexers ini[key] and ini[section, parameter].

Documents are immutable: any operation that changes a document returns a new one instead. Two documents are equal if they have the same keys and values. The structure is flat: each value is stored under one Key, made of a section name and a parameter name and written section:parameter.

Install

dotnet add package Fini

Targets net10.0 and net48. The only dependency is FSharp.Core.

Quick start

Given app.ini:

; a parameter before the first header belongs to the root section
timeout = 30

[server]
host = localhost
port = 8080

[server.dev]
port = 5000

F#:

open Fini

let ini =
    match Ini.fromFile "app.ini" with
    | Ok ini -> ini
    | Error err -> failwith $"%A{err}"                         // InvalidLine "oops"

ini |> Ini.tryFind (Key.parse "server:host")                   // Some "localhost"
ini |> Ini.tryFind (Key.parse "server.dev:host")               // None — exact lookup
ini |> Ini.tryFind (Key.parse "timeout")                       // Some "30" — no colon means the root section

ini |> Ini.tryFindNested (Key.parse "server.dev:port")         // Some "5000"      — from the section itself
ini |> Ini.tryFindNested (Key.parse "server.dev:host")         // Some "localhost" — inherited from [server]
ini |> Ini.tryFindNested (Key.parse "server.dev:timeout")      // Some "30"        — inherited from the root

ini |> Ini.find (Key.parse "server:host")                      // "localhost" — throws if missing
ini |> Ini.tryFindAs<int> (Key.parse "server:port")            // Some (Ok 8080) — net10.0 only

C#:

using Fini;

Ini ini = Ini.FromFile("app.ini") switch
{
    { ResultValue: { } ok } => ok,
    { ErrorValue: var err } => throw new Exception(err.ToString()) // InvalidLine "oops"
};

string? host = ini.TryFind("server:host")?.Value;                  // "localhost"
string? dHost = ini.TryFind("server.dev:host")?.Value;             // null — exact lookup
string? timeout = ini.TryFind("timeout")?.Value;                   // "30" — no colon means the root section

string? dPort = ini.TryFindNested("server.dev:port")?.Value;       // "5000"      — from the section itself
string? iHost = ini.TryFindNested("server.dev:host")?.Value;       // "localhost" — inherited from [server]
string? iTimeout = ini.TryFindNested("server.dev:timeout")?.Value; // "30"        — inherited from the root

string sHost = ini.Find("server:host");                            // "localhost" — throws if missing
int port = ini.FindAs<int>("server:port");                         // 8080 — net10.0 only
string sHost2 = ini["server:host"];                                // "localhost" — indexer, same as Find
string sHost3 = ini["server", "host"];                             // "localhost" — section and parameter

F# code uses the functions in the Ini, Key and Name modules. They take the document as the last argument, so they work with |>. C# code uses the methods and properties of the Ini, Key and Name types. Both return the same F# option and Result types. In C# these are FSharpOption<T> and FSharpResult<T, TError> from Microsoft.FSharp.Core, and you can match on them with any C# pattern matching: switch expressions, switch statements and is. Every example below is shown in both languages.

Keys and names

A Name is a section or parameter name. It may not be empty or contain whitespace or any of =, :, ;, #, [, ]. The one exception is Name.empty, the empty name of the root section.

A Key is a section name and a parameter name, written section:parameter. The parameter name can't be empty. The section name can be, which means the root section, so x and :x are the same key. Names and keys are case-insensitive (OrdinalIgnoreCase) but keep their spelling; values are case-sensitive.

Key
server:host valid
host, :host valid, the root section
server.dev:port valid — a dot is part of the name
a b InvalidName "a b" — whitespace
server: InvalidName "" — empty name
a:b:c InvalidName "a:b:c" — two colons

Names and keys are never trimmed or corrected: " a" and "a\n" are rejected, not turned into a. Each type has a checked and a throwing way to build it:

F# C# Invalid input
Name.tryGet "server" Name.TryGet("server") Error (InvalidName name)
Name.get "server" Name.Get("server") throws ArgumentException
Key.tryParse "s:x" Key.TryParse("s:x") Error (InvalidName name)
Key.parse "s:x" Key.Parse("s:x") throws ArgumentException
Key.tryGet "s" "x" Key.TryGet("s", "x") Error (InvalidName name)
Key.get "s" "x" Key.Get("s", "x") throws ArgumentException

Both types convert implicitly from and to string. The conversion from a string uses Parse / Get, so an invalid string throws ArgumentException. Use TryParse / TryGet for input you don't control.

F#: the conversion applies to method arguments, so ini.TryFind "server:host" works. Module functions take a Key, so pass Key.parse "server:host". string key gives the text back.

let key =
    match Key.tryParse userInput with
    | Ok key -> key
    | Error (InvalidName name) -> failwith $"bad name: {name}"
    | Error err -> failwith $"%A{err}"

let port = ini |> Ini.tryFind key

C#: every method that takes a Key or a Name also accepts a string.

Key key = Key.TryParse(userInput) switch
{
    { ResultValue: { } k } => k,
    { ErrorValue: ParserError.InvalidName { Name: var name } } => throw new ArgumentException($"bad name: {name}"),
    { ErrorValue: var err } => throw new ArgumentException(err.ToString())
};

string? port = ini.TryFind(key)?.Value;
string? host = ini.TryFind("server:host")?.Value; // implicit conversion

A Key exposes its two parts as Section and Parameter, both Name values. A key without a section, like host, has Section equal to Name.empty.

let k = Key.parse "server:host"
k.Section          // Name.get "server"
k.Parameter        // Name.get "host"
Key.section k      // Name.get "server" — equivalent module function
Key.parameter k    // Name.get "host"
Key k = "server:host";
Name section = k.Section;     // "server"
Name parameter = k.Parameter; // "host"

Root keys are always written with the colon, so keys and enumeration give :timeout, not timeout.

Lookups return an option

A missing key is not an error. tryFind / TryFind and tryFindNested / TryFindNested return Some value if the key exists and None if it doesn't.

F#:

let host =
    match ini |> Ini.tryFind (Key.parse "server:host") with
    | Some host -> host
    | None -> "0.0.0.0"

let timeout =
    match ini |> Ini.tryFind (Key.parse "timeout") with
    | Some "0" -> "no timeout"
    | Some t -> $"timeout: {t}"
    | None -> "default timeout"

C#: an option is a class, and None is null. Match Some with a property pattern on Value, and None with null. A null never matches a property pattern, so these arms cover every case and no _ arm is needed.

string host = ini.TryFind("server:host") switch
{
    { Value: var h } => h,
    null => "0.0.0.0"
};

string timeout = ini.TryFind("timeout") switch
{
    { Value: "0" } => "no timeout",
    { Value: var t } => $"timeout: {t}",
    null => "default timeout"
};

Reading Value on a None throws NullReferenceException, and the null arm is what prevents that. The IsSome and IsNone members can't be called from C#, because F# compiles them as static helpers.

If a missing key is a bug rather than an expected case, find / Find and findNested / FindNested return the value directly and throw KeyNotFoundException when it's missing. The indexer on Ini does the same as Find: ini[key] or ini[section, parameter].

Nested lookup

Sections don't nest in the file: [server.dev] is just a section named server.dev. The dots matter only to the nested lookups, which look in server.dev, then server, then the root, and return the first value found. A missing section in between is skipped, so server.a.b:host also finds server:host. Nested lookups never look into child sections.

Parsing returns a result

The four functions that read lines can reject a line, so they return Ok ini or Error (InvalidLine line). These are fromLines, fromFile, appendLines and appendFile.

F#:

let config =
    match Ini.fromFile "app.ini" with
    | Ok ini -> ini
    | Error _ -> Ini.empty

// chaining — each step runs only if the previous one succeeded, so you get the first error
let layered =
    Ini.fromFile "defaults.ini"
    |> Result.bind (Ini.appendFile "app.ini")

C#: a result is a struct, so it is never null, and it has no case classes to match on. Instead it has both a ResultValue and an ErrorValue property, and the one that isn't set is default. On success the document is never null, so { ResultValue: { } ini } matches Ok, and { ErrorValue: var err } after it matches Error.

Ini config = Ini.FromFile("app.ini") switch
{
    { ResultValue: { } ini } => ini,
    { ErrorValue: _ } => Ini.Empty
};

// chaining — each step runs only if the previous one succeeded, so you get the first error
var layered = Ini.FromFile("defaults.ini");
layered = layered switch
{
    { ResultValue: { } ini } => ini.AppendFile("app.ini"),
    var error => error
};

This doesn't work when the result holds a value type, as with the typed lookups below. On an error, ResultValue is then 0 or false, not null, so the Ok arm also checks IsOk: true.

Building

add, append, ofSeq and remove take keys that are already valid, so they can't fail and return the new document directly.

F#:

let ini =
    Ini.empty
    |> Ini.add (Key.parse "timeout") "30"
    |> Ini.add (Key.parse "server:host") "localhost"
    |> Ini.remove (Key.parse "timeout")

let other = Ini.ofSeq [ Key.parse "server:port", "8080" ]

C#:

Ini ini = Ini.Empty
    .Add("timeout", "30")
    .Add("server:host", "localhost")
    .Remove("timeout");

Ini other = Ini.Create([new KeyValuePair<Key, string>("server:port", "8080")]);

add replaces an existing value, and the key takes the spelling of the latest add. Values are stored exactly as given; only values read from lines are trimmed. remove of a missing key does nothing.

Errors

Problems with the content are returned as errors, not thrown:

type ParserError =
    | InvalidName of Name: string
    | InvalidLine of Line: string
    | InvalidValue of Value: string

Each case holds the exact text that caused the problem. Name.tryGet, Key.tryParse and Key.tryGet return InvalidName, the four parsing functions return InvalidLine, and the try…As lookups return InvalidValue. A null argument, a file system problem or a throwing function (get, parse, find…, implicit conversions) throws an exception.

F#:

let describe err =
    match err with
    | InvalidName name -> $"bad name: {name}"
    | InvalidLine line -> $"bad line: {line}"
    | InvalidValue value -> $"bad value: {value}"

C#: each case is a nested type, and its text is a property named after the field. Match the type and the property together:

static string Describe(ParserError err) => err switch
{
    ParserError.InvalidName { Name: var name } => $"bad name: {name}",
    ParserError.InvalidLine { Line: var line } => $"bad line: {line}",
    ParserError.InvalidValue { Value: var value } => $"bad value: {value}",
    _ => "unreachable"
};

ParserError expected = ParserError.NewInvalidLine("oops");

The C# compiler can't tell that the switch covers every ParserError case, so the _ arm is needed. Errors are compared by value — ParserError.NewInvalidLine("oops") is equal to any other InvalidLine "oops" — and print as InvalidLine "oops".

Typed lookup

…As<'T> converts a value with IParsable<'T>: the primitive types, Guid, DateTime, TimeSpan, and your own types. Parsing uses CultureInfo.InvariantCulture. These exist only in the net10.0 build. For other conversions, look up the string and parse it yourself.

The try forms return an option that contains a result, so the two kinds of failure stay separate: None means the key is missing, and Some (Error (InvalidValue value)) means the value couldn't be parsed. The parser isn't called for a missing key. The forms without try return the value and throw: KeyNotFoundException for a missing key, and FormatException for a bad value.

IParsable
exact tryFindAs
findAs
nested tryFindNestedAs
findNestedAs

F#:

let port =
    match ini |> Ini.tryFindAs<int> (Key.parse "server:port") with
    | Some (Ok port) -> port
    | Some (Error err) -> failwith (describe err)
    | None -> 8080

let timeout = ini |> Ini.findNestedAs<TimeSpan> (Key.parse "server.dev:timeout")

C#: the option's Value is the result, so the patterns are nested. int is a value type, so the Ok arm checks IsOk: true instead of checking that ResultValue isn't null.

int port = ini.TryFindAs<int>("server:port") switch
{
    { Value: { IsOk: true, ResultValue: var p } } => p,
    { Value.ErrorValue: var err } => throw new FormatException(Describe(err)),
    null => 8080
};

TimeSpan timeout = ini.FindNestedAs<TimeSpan>("server.dev:timeout");

IParsable<T> doesn't accept NumberStyles, so each type uses its own default rules. For example, float / double allow a thousands separator and read "1,234" as 1234.0, but int doesn't and returns InvalidValue "1,234". Look up the string with find and parse it yourself when you need other rules.

File format

Line Meaning
[name] opens a section
name = value a parameter in the current section
; text comment
# text comment
blank ignored
anything else InvalidLine

Names follow the rules above, with whitespace allowed around them. The value is everything after the first =, with whitespace trimmed from both ends. It can contain =, :, ;, # and [, and it can be empty. If the same key appears twice, the later value wins, and a section can be opened again further down the file. Lines are read one at a time, and reading stops at the first invalid line — if line 3 of a 10,000-line file is invalid, only three lines are read.

Layering

appendLines / appendFile read new parameters on top of an existing document. If both have the same key, the new value wins; all other existing parameters are kept. Each new file or list of lines starts in the root section, no matter which section the previous one ended in. So the load order decides which value wins, but never changes which key a line is stored under.

F#:

let config =
    Ini.fromFile "defaults.ini"
    |> Result.bind (Ini.appendFile "app.ini")
    |> Result.bind (Ini.appendLines [ "[server]"; $"port = {portFromArgs}" ])

C#:

var config = Ini.FromFile("defaults.ini");
config = config switch { { ResultValue: { } ini } => ini.AppendFile("app.ini"), var error => error };
config = config switch { { ResultValue: { } ini } => ini.AppendLines(["[server]", $"port = {portFromArgs}"]), var error => error };

append / Append layers key/value pairs instead of lines. If a key appears twice, the later value wins. It accepts the same pairs that toSeq returns and that enumerating a document gives, so you can merge one document into another.

F#:

// values of `overrides` win
let merged = defaults |> Ini.append (Ini.toSeq overrides)

C#:

// values of `overrides` win
Ini merged = defaults.Append(overrides);

Sections

section returns one section as a new document, and removeSection returns everything except that section. sections lists the section names in order, with Name.empty (printed as "") first if there are root parameters. A dotted section like server.dev is separate from server, so removing server keeps server.dev. To remove both, remove each one by name.

F#:

ini |> Ini.section (Name.get "server") |> Ini.toLines // seq [ "[server]"; "host = localhost"; "port = 8080" ]

let withoutServer =
    ini
    |> Ini.sections
    |> Seq.filter (fun name -> name = Name.get "server" || (string name).StartsWith "server.")
    |> Seq.fold (fun doc name -> Ini.removeSection name doc) ini

C#:

ini.Section("server").ToLines(); // [ "[server]", "host = localhost", "port = 8080" ]

Ini withoutServer = ini.Sections
    .Where(name => name.Equals((Name)"server") || name.ToString().StartsWith("server."))
    .Aggregate(ini, (doc, name) => doc.RemoveSection(name));

Enumeration and rendering

In F#, Ini.toSeq returns (Key * string) tuples in key order and Ini.ofSeq builds a document from them. In C#, the document is itself an IEnumerable<KeyValuePair<Key, string>>, so you can pass it straight to foreach, LINQ and Ini.Create.

for key, value in Ini.toSeq ini do
    printfn $"{key} = {value}"
foreach (var (key, value) in ini)
{
    Console.WriteLine($"{key} = {value}");
}

toLines / ToLines write the document in a standard layout. Root parameters come first, without a header. Then each section follows as a [section] block, with a blank line between blocks. Sections are sorted case-insensitively, so a parent comes before its children (server before server.dev), and parameters are sorted within each section. An empty value is written as x =. toString / ToString joins the lines with Environment.NewLine, and toFile / ToFile writes them to a file.

The output doesn't reproduce the original file: comments, blank lines, the original order and duplicate keys are lost. All values are kept, so writing a document and reading it back gives an equal document.

Equality and comparison

Ini implements IEquatable<Ini>, IComparable<Ini> and IComparable, so documents work with =, compare, Set and Map in F#, and with Equals, HashSet, Dictionary and Order() in C#. Documents are compared by their keys first and then by their values, and equal documents have the same hash code. Letter case in names, the order of entries, comments and blank lines don't affect the comparison. Key and Name are equatable and comparable in the same way.

In C#, == on Ini, Key or Name compares references, not values. Use Equals.

API

Ini member Ini function Result
Ini.Empty Ini.empty Ini
Ini.Create pairs Ini.ofSeq pairs Ini
Ini.FromLines lines Ini.fromLines lines Result<Ini, ParserError>
Ini.FromFile path Ini.fromFile path Result<Ini, ParserError>
ini.Append pairs Ini.append pairs ini Ini
ini.AppendLines lines Ini.appendLines lines ini Result<Ini, ParserError>
ini.AppendFile path Ini.appendFile path ini Result<Ini, ParserError>
ini.Add(key, value) Ini.add key value ini Ini
ini.Remove key Ini.remove key ini Ini
ini.TryFind key Ini.tryFind key ini string option
ini.TryFindNested key Ini.tryFindNested key ini string option
ini.Find key Ini.find key ini string
ini.FindNested key Ini.findNested key ini string
ini.TryFindAs<T> key Ini.tryFindAs<'T> key ini Result<'T, ParserError> option — net10.0 only
ini.TryFindNestedAs<T> key Ini.tryFindNestedAs<'T> key ini Result<'T, ParserError> option — net10.0 only
ini.FindAs<T> key Ini.findAs<'T> key ini 'T — net10.0 only
ini.FindNestedAs<T> key Ini.findNestedAs<'T> key ini 'T — net10.0 only
ini[key], ini[section, parameter] Ini.find key ini string — indexer, same as Find
ini.ContainsKey key Ini.containsKey key ini bool
ini.IsEmpty Ini.isEmpty ini bool
ini.Count Ini.count ini int
ini.Keys Ini.keys ini seq<Key>
ini.Values Ini.values ini seq<string>
foreach / LINQ over ini Ini.toSeq ini pairs, in key order
ini.Sections Ini.sections ini seq<Name>
ini.Section name Ini.section name ini Ini
ini.RemoveSection name Ini.removeSection name ini Ini
ini.ToLines() Ini.toLines ini seq<string>
ini.ToFile path Ini.toFile path ini unit
ini.ToString() Ini.toString ini string
Key / Name member Function Result
Key.TryParse key Key.tryParse key Result<Key, ParserError>
Key.Parse key Key.parse key Key
Key.TryGet(section, name) Key.tryGet section name Result<Key, ParserError>
Key.Get(section, name) Key.get section name Key
key.Section Key.section key Name
key.Parameter Key.parameter key Name
Name.Empty Name.empty Name
Name.TryGet name Name.tryGet name Result<Name, ParserError>
Name.Get name Name.get name Name
name.Name Name.name name string

From C#, string option is FSharpOption<string> and Result<Ini, ParserError> is FSharpResult<Ini, ParserError>.

Build

dotnet build
dotnet test

License

MIT

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Framework net48 is compatible.  net481 was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.5.0 86 11/5/2026
0.4.0 122 10/29/2026
0.3.0 104 10/21/2026
Loading failed