using System.Text;
namespace Sunfish.Config;
/// <summary>
/// Fichero INI que conserva comentarios, lineas en blanco y orden al reescribirse.
/// Comentarios: lineas que empiezan por '#' o ';'. Claves y secciones sin distinguir mayusculas.
/// </summary>
internal sealed class IniDocument
{
private readonly List<IniSection> _sections = new();
public IniDocument() => _sections.Add(new IniSection(null, ""));
/// <summary>Lineas que no se pudieron interpretar (numero de linea, texto).</summary>
public List<(int Line, string Text)> Errors { get; } = new();
/// <summary>Lo que hay antes de la primera seccion.</summary>
public IniSection Preamble => _sections[0];
public IEnumerable<IniSection> Sections => _sections.Skip(1);
public static IniDocument Parse(string text)
{
var doc = new IniDocument();
IniSection current = doc._sections[0];
int number = 0;
foreach (var rawLine in text.Split('\n'))
{
number++;
string line = rawLine.TrimEnd('\r');
string trimmed = line.Trim();
if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == ';')
{
current.Lines.Add(new IniLine(line, null, null, number));
}
else if (trimmed[0] == '[' && trimmed[^1] == ']')
{
current = new IniSection(line, trimmed[1..^1].Trim()) { LineNumber = number };
doc._sections.Add(current);
}
else
{
int eq = line.IndexOf('=');
if (eq <= 0)
{
doc.Errors.Add((number, line));
current.Lines.Add(new IniLine(line, null, null, number));
}
else
{
current.Lines.Add(new IniLine(line, line[..eq].Trim(), line[(eq + 1)..].Trim(), number));
}
}
}
// Split deja una linea vacia final si el texto acaba en salto de linea: no se duplica al guardar.
var last = doc._sections[^1].Lines;
if (last.Count > 0 && last[^1].Raw.Length == 0 && text.EndsWith('\n'))
last.RemoveAt(last.Count - 1);
return doc;
}
public IniSection? Find(string name) =>
Sections.FirstOrDefault(s => string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase));
public IniSection GetOrAdd(string name)
{
var section = Find(name);
if (section is not null)
return section;
// Separa la seccion nueva de la anterior con una linea en blanco.
var previous = _sections[^1];
if (_sections.Count > 1 || previous.Lines.Count > 0)
{
if (previous.Lines.Count == 0 || previous.Lines[^1].Raw.Trim().Length > 0)
previous.Lines.Add(new IniLine("", null, null, 0));
}
section = new IniSection($"[{name}]", name);
_sections.Add(section);
return section;
}
public void Remove(IniSection section)
{
if (section != _sections[0])
_sections.Remove(section);
}
public string ToText(string newLine)
{
var sb = new StringBuilder();
foreach (var section in _sections)
{
if (section.Header is not null)
sb.Append(section.Header).Append(newLine);
foreach (var line in section.Lines)
sb.Append(line.Raw).Append(newLine);
}
return sb.ToString();
}
}
internal sealed class IniSection
{
public IniSection(string? header, string name)
{
Header = header;
Name = name;
}
/// <summary>Linea original de la cabecera; null en el preambulo.</summary>
public string? Header { get; }
/// <summary>Texto entre corchetes, p. ej. "Share fotos".</summary>
public string Name { get; }
public int LineNumber { get; init; }
public List<IniLine> Lines { get; } = new();
/// <summary>Primera palabra del nombre: "Share" en "[Share fotos]".</summary>
public string Kind
{
get
{
int sp = Name.IndexOf(' ');
return sp < 0 ? Name : Name[..sp];
}
}
/// <summary>Resto del nombre: "fotos" en "[Share fotos]".</summary>
public string Title
{
get
{
int sp = Name.IndexOf(' ');
return sp < 0 ? "" : Name[(sp + 1)..].Trim();
}
}
public IEnumerable<IniLine> Entries => Lines.Where(l => l.Key is not null);
public IniLine? FindEntry(string key) =>
Entries.FirstOrDefault(l => string.Equals(l.Key, key, StringComparison.OrdinalIgnoreCase));
public string? Get(string key) => FindEntry(key)?.Value;
public bool Has(string key) => FindEntry(key) is not null;
/// <summary>Cambia el valor conservando la posicion de la clave; si no existe, la anade al final.</summary>
public void Set(string key, string value)
{
int index = Lines.FindIndex(l => string.Equals(l.Key, key, StringComparison.OrdinalIgnoreCase));
if (index >= 0)
{
var old = Lines[index];
Lines[index] = new IniLine($"{old.Key} = {value}", old.Key, value, old.Number);
// Una clave repetida se queda solo con su primera aparicion.
for (int i = Lines.Count - 1; i > index; i--)
if (string.Equals(Lines[i].Key, key, StringComparison.OrdinalIgnoreCase))
Lines.RemoveAt(i);
return;
}
// Detras de la ultima clave, para no quedar despues de los blancos que separan secciones.
int insert = Lines.FindLastIndex(l => l.Key is not null) + 1;
Lines.Insert(insert, new IniLine($"{key} = {value}", key, value, 0));
}
public void Remove(string key) =>
Lines.RemoveAll(l => string.Equals(l.Key, key, StringComparison.OrdinalIgnoreCase));
}
internal sealed record IniLine(string Raw, string? Key, string? Value, int Number);