Files
dnsmasq-webui/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/DnsmasqConfig/HostsFileLineParser.cs
T
alex eae29de045 Hosts: inline file comments (hosts(5)) and Comment column
- HostEntry.InlineComment; parse at first # on data lines; ToLine + SanitizeInlineComment
- HostsPageRow + HostsCache; pending change equality and save diff include comments
- Hosts table: dedicated Comment column; edit in own cell; empty shown as em dash
- Parser and pending-change tests
2026-04-01 11:52:21 +10:00

125 lines
5.0 KiB
C#

using DnsmasqWebUI.Models.Hosts;
using Superpower;
using Superpower.Model;
using Superpower.Parsers;
namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Parses a single line of an /etc/hosts-style file. Format: IP_address canonical_hostname [aliases...]
/// Fields separated by blanks/tabs; text from '#' to EOL is comment (hosts(5), RFC 952).
/// </summary>
public static class HostsFileLineParser
{
// Token: non-whitespace, non-# (stops at inline comment)
private static readonly TextParser<string> TokenField =
Character.Matching(c => !char.IsWhiteSpace(c) && c != '#', "token")
.AtLeastOnce().Text();
// Optional leading # (comment line); when present, rest of line is comment text, not data
private static readonly TextParser<bool> OptionalComment =
ConfParserHelpers.Token(Character.EqualTo('#')).Select(_ => true).OptionalOrDefault(false);
// Content: address (first token) then one or more names (tokens). Per hosts(5): IP then canonical name [aliases...].
private static readonly TextParser<(string address, List<string> names)> Content =
from address in TokenField
from _ in Span.WhiteSpace
from names in TokenField.AtLeastOnce()
select (address, names.ToList());
// Full line: if starts with #, treat entire line as comment (do not parse rest as address/names); else parse Content.
private static readonly TextParser<(bool isComment, string address, List<string> names)> LineContent =
OptionalComment.Then(hasComment =>
hasComment
? Character.AnyChar.Many().Text().Select(_ => (true, "", new List<string>()))
: Content.Select(c => (false, c.address, c.names)));
public static HostEntry? ParseLine(string line, int lineNumber)
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed))
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
if (trimmed.StartsWith('#'))
{
var result = LineContent.TryParse(trimmed);
if (!result.HasValue)
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
var (isComment, address, names) = result.Value;
if (string.IsNullOrEmpty(address) && names.Count == 0)
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsComment = isComment, IsPassthrough = true };
return new HostEntry
{
LineNumber = lineNumber,
Address = address,
Names = names,
RawLine = line,
IsComment = isComment
};
}
string? inlineComment = null;
var dataPart = trimmed;
var hashIdx = trimmed.IndexOf('#');
if (hashIdx >= 0)
{
inlineComment = hashIdx + 1 < trimmed.Length ? trimmed[(hashIdx + 1)..].Trim() : null;
if (string.IsNullOrEmpty(inlineComment))
inlineComment = null;
dataPart = trimmed[..hashIdx].TrimEnd();
}
if (string.IsNullOrEmpty(dataPart))
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
var dataResult = LineContent.TryParse(dataPart);
if (!dataResult.HasValue)
return new HostEntry { LineNumber = lineNumber, RawLine = line, IsPassthrough = true };
var (isCommentData, addressData, namesData) = dataResult.Value;
if (string.IsNullOrEmpty(addressData) && namesData.Count == 0)
return new HostEntry
{
LineNumber = lineNumber,
RawLine = line,
IsComment = isCommentData,
IsPassthrough = true,
InlineComment = inlineComment
};
return new HostEntry
{
LineNumber = lineNumber,
Address = addressData,
Names = namesData,
RawLine = line,
IsComment = isCommentData,
InlineComment = inlineComment
};
}
public static string ToLine(HostEntry entry)
{
if (entry.IsPassthrough)
return entry.RawLine;
var prefix = entry.IsComment ? "# " : "";
var core = prefix + entry.Address + " " + string.Join(" ", entry.Names);
var sanitized = SanitizeInlineComment(entry.InlineComment);
if (sanitized != null)
return core.TrimEnd() + " # " + sanitized;
return core;
}
/// <summary>Strip characters that would break a hosts line; drop <c>#</c> (comment delimiter).</summary>
public static string? SanitizeInlineComment(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
var flattened = text.Replace('\r', ' ').Replace('\n', ' ').Replace('#', ' ');
var collapsed = string.Join(' ', flattened.Split(' ', StringSplitOptions.RemoveEmptyEntries)).Trim();
return collapsed.Length == 0 ? null : collapsed;
}
}