LANCommander/LANCommander.SDK/Parsers/IniParser.cs
2026-05-16 18:14:41 -05:00

290 lines
11 KiB
C#

using Superpower;
using Superpower.Display;
using Superpower.Model;
using Superpower.Parsers;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace LANCommander.SDK.Parsers.Ini
{
enum IniToken
{
[Token(Example = "[")]
SectionHeaderStart,
[Token(Example = "]")]
SectionHeaderEnd,
[Token(Example = ";")]
CommentStart,
[Token(Example = "=")]
KeyValueDelimiter
}
static class IniTokenizer
{
static TextParser<Unit> IniStringToken { get; } =
from open in Character.EqualTo('"')
from content in Character.EqualTo('\\').IgnoreThen(Character.AnyChar).Value(Unit.Value).Try()
.Or(Character.Except('"').Value(Unit.Value))
.IgnoreMany()
from close in Character.EqualTo('"')
select Unit.Value;
}
// ── Data model ───────────────────────────────────────────────────────────────
/// <summary>Represents a single key-value pair inside an INI section.</summary>
public class IniKey
{
public string Name { get; }
public string? Value { get; set; }
public IniKey(string name, string? value = null)
{
Name = name;
Value = value;
}
}
/// <summary>An ordered, LINQ-queryable collection of <see cref="IniKey"/> entries.</summary>
public class IniKeyCollection : IEnumerable<IniKey>
{
private readonly List<IniKey> _keys = new();
/// <summary>Total number of keys, including duplicates.</summary>
public int Count => _keys.Count;
/// <summary>Returns <c>true</c> if any key has the given name (case-insensitive).</summary>
public bool Contains(string name) =>
_keys.Any(k => string.Equals(k.Name, name, StringComparison.OrdinalIgnoreCase));
/// <summary>Returns the first key with the given name, or <c>null</c>.</summary>
public IniKey? this[string name] =>
_keys.FirstOrDefault(k => string.Equals(k.Name, name, StringComparison.OrdinalIgnoreCase));
public void Add(IniKey key) => _keys.Add(key);
public void Add(string name, string? value) => _keys.Add(new IniKey(name, value));
public void Insert(int index, IniKey key) => _keys.Insert(index, key);
public void Insert(int index, string name, string? value) => _keys.Insert(index, new IniKey(name, value));
public bool Remove(IniKey key) => _keys.Remove(key);
public IEnumerator<IniKey> GetEnumerator() => _keys.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
/// <summary>Represents an INI section with a name and a collection of keys.</summary>
public class IniSection
{
public string Name { get; }
public IniKeyCollection Keys { get; } = new();
public IniSection(string name) => Name = name;
}
/// <summary>An ordered, LINQ-queryable collection of <see cref="IniSection"/> entries.</summary>
public class IniSectionCollection : IEnumerable<IniSection>
{
private readonly List<IniSection> _sections = new();
/// <summary>Total number of sections.</summary>
public int Count => _sections.Count;
/// <summary>Returns <c>true</c> if any section has the given name (case-insensitive).</summary>
public bool Contains(string name) =>
_sections.Any(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase));
/// <summary>Returns the first section with the given name, or <c>null</c>.</summary>
public IniSection? this[string name] =>
_sections.FirstOrDefault(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase));
public void Add(IniSection section) => _sections.Add(section);
public bool Remove(IniSection section) => _sections.Remove(section);
public IEnumerator<IniSection> GetEnumerator() => _sections.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
/// <summary>The parsed representation of an INI document.</summary>
public class IniDocument
{
public IniSectionCollection Sections { get; } = new();
/// <summary>Serializes the document back to INI format.</summary>
public string Serialize()
{
var sb = new StringBuilder();
foreach (var section in Sections)
{
sb.AppendLine($"[{section.Name}]");
foreach (var key in section.Keys)
{
sb.AppendLine($"{key.Name}={key.Value}");
}
sb.AppendLine();
}
return sb.ToString();
}
}
/// <summary>Options controlling INI parse/write behavior.</summary>
public class IniParseOptions
{
/// <summary>If false, duplicate keys within a section are merged (first value wins).</summary>
public bool AllowDuplicateKeys { get; set; } = true;
/// <summary>If false, duplicate sections are merged (keys combined).</summary>
public bool AllowDuplicateSections { get; set; } = true;
/// <summary>Encoding for file I/O operations.</summary>
public Encoding Encoding { get; set; } = Encoding.Default;
}
// ── Parser ───────────────────────────────────────────────────────────────────
/// <summary>
/// A Superpower-based parser for INI-formatted text.
/// <para>
/// Supports:
/// <list type="bullet">
/// <item>Sections: <c>[SectionName]</c> (names may contain <c>.</c>)</item>
/// <item>Key-value pairs: <c>key = value</c> or <c>key=value</c></item>
/// <item>Keys with brackets: <c>Aliases[0]</c>, bare digits: <c>0</c></item>
/// <item>Values containing <c>=</c>, <c>"</c>, <c>()</c>, <c>|</c> — split on first <c>=</c></item>
/// <item>Duplicate keys within the same section (multi-value)</item>
/// <item>Comment lines beginning with <c>;</c> or <c>#</c></item>
/// <item>Blank lines (ignored)</item>
/// </list>
/// </para>
/// </summary>
public class IniParser
{
// Consume any characters that are not a line-ending character.
private static readonly TextParser<char[]> RestOfLine =
Character.ExceptIn('\r', '\n').Many();
// Section header: optional leading whitespace, '[', section name (trimmed), ']',
// then any trailing content on the same line is consumed and discarded.
private static readonly TextParser<string> SectionHeaderParser =
from _ws in Character.In(' ', '\t').Many()
from _ob in Character.EqualTo('[')
from name in Character.ExceptIn(']', '\r', '\n').AtLeastOnce()
.Select(chars => new string(chars).Trim())
from _cb in Character.EqualTo(']')
from _rest in RestOfLine
select name;
// Key-value line.
// Key = everything before the first '=' on the line, trimmed.
// Value = everything after the first '=' on the line, trimmed.
// The Where guard rejects lines whose key part is entirely whitespace
// (e.g. a stray '=' with no key), letting them fall through to the
// comment/blank skip path.
private static readonly TextParser<(string Key, string Value)> KeyValueLineParser =
from key in Character.ExceptIn('=', '\r', '\n').AtLeastOnce()
.Select(chars => new string(chars).Trim())
.Where(s => !string.IsNullOrWhiteSpace(s))
from _eq in Character.EqualTo('=')
from value in RestOfLine.Select(chars => new string(chars).Trim())
select (key, value);
/// <summary>
/// Parses an INI-formatted string and returns an <see cref="IniDocument"/>.
/// </summary>
/// <param name="text">The INI file content to parse.</param>
/// <returns>A parsed <see cref="IniDocument"/>.</returns>
public static IniDocument Parse(string text) => Parse(text, new IniParseOptions());
/// <summary>
/// Parses an INI-formatted string with options and returns an <see cref="IniDocument"/>.
/// </summary>
public static IniDocument Parse(string text, IniParseOptions options)
{
var doc = new IniDocument();
IniSection? currentSection = null;
var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None);
foreach (var rawLine in lines)
{
var span = new TextSpan(rawLine);
// 1. Section header?
var sectionResult = SectionHeaderParser(span);
if (sectionResult.HasValue)
{
var sectionName = sectionResult.Value;
if (!options.AllowDuplicateSections)
{
var existing = doc.Sections[sectionName];
if (existing != null)
{
currentSection = existing;
continue;
}
}
currentSection = new IniSection(sectionName);
doc.Sections.Add(currentSection);
continue;
}
// 2. Key-value pair (only inside a section)?
var kvResult = KeyValueLineParser(span);
if (kvResult.HasValue && currentSection is not null)
{
if (!options.AllowDuplicateKeys)
{
var existingKey = currentSection.Keys[kvResult.Value.Key];
if (existingKey != null)
{
// Merge: keep first value (ignore duplicates)
continue;
}
}
currentSection.Keys.Add(new IniKey(kvResult.Value.Key, kvResult.Value.Value));
continue;
}
// 3. Comment or blank line — skip.
}
return doc;
}
/// <summary>
/// Loads and parses an INI file from disk.
/// </summary>
public static IniDocument Load(string filePath, IniParseOptions? options = null)
{
var opts = options ?? new IniParseOptions();
var text = File.ReadAllText(filePath, opts.Encoding);
return Parse(text, opts);
}
/// <summary>
/// Saves an <see cref="IniDocument"/> to disk.
/// </summary>
public static void Save(IniDocument document, string filePath, Encoding? encoding = null)
{
File.WriteAllText(filePath, document.Serialize(), encoding ?? Encoding.Default);
}
}
}