Scaffold AvParser: Avalonia 12 shell with adaptive layout
Greenfield skeleton for a parser desktop app. The domain is deliberately a placeholder — IParser<TIn,TOut> plus two sample parsers — so the shell is runnable and verifiable end to end before real logic lands. Layers run one way: Core (no Avalonia, no IO) <- Infrastructure <- UI <- Desktop. UI is a class library rather than the exe so headless tests build real views without dragging in Program.cs, Serilog or the container. Adaptive layout is built from what Avalonia actually offers, since it has no AdaptiveTrigger or media queries: ResponsiveLayout observes Visual.Bounds and projects a breakpoint onto both an attached property and :compact/:medium/ :expanded pseudoclasses, with 24px hysteresis so dragging a window edge cannot make the layout flap. Pane state lives in the view model because a style setter loses to a local value permanently; styles own only the visual variance. Stack notes worth remembering: Avalonia.ReactiveUI is deprecated in favour of ReactiveUI.Avalonia, and ReactiveUI 24 runs on the Primitives engine (RxVoid, ISequencer, Signal<T>) and no longer self-initialises. Avalonia.Headless.XUnit 12.x requires xUnit v3. InvariantGlobalization must stay false or Semi.Avalonia throws in its static constructor. 102 tests across three projects, including headless guards for the two failures that are otherwise completely silent: a stylesheet whose selectors match nothing, and a light palette too low-contrast for cards to read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// The pluggable unit of the whole application: turns one input into a stream of outcomes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Results are streamed rather than returned as a batch so that the UI can render partial
|
||||
/// results, report progress and honour cancellation on inputs of arbitrary size.
|
||||
/// </remarks>
|
||||
public interface IParser<in TInput, TOutput>
|
||||
{
|
||||
/// <summary>Stable identifier used for persistence and lookup. Never localise this.</summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>Human-readable name shown in the UI.</summary>
|
||||
string DisplayName { get; }
|
||||
|
||||
/// <summary>One-line explanation of what this parser accepts.</summary>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>Cheap structural check — must not throw and must not do IO.</summary>
|
||||
bool CanParse(TInput input);
|
||||
|
||||
/// <summary>Streams one outcome per logical record.</summary>
|
||||
IAsyncEnumerable<ParseOutcome<TOutput>> ParseAsync(
|
||||
TInput input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>Read-only view over every registered text parser.</summary>
|
||||
public interface IParserCatalog
|
||||
{
|
||||
/// <summary>All registered parsers, ordered by <see cref="IParser{TInput,TOutput}.DisplayName"/>.</summary>
|
||||
IReadOnlyList<ITextParser> Parsers { get; }
|
||||
|
||||
/// <summary>The parser used when nothing has been chosen yet.</summary>
|
||||
ITextParser DefaultParser { get; }
|
||||
|
||||
/// <summary>Finds a parser by its stable id; <see langword="null"/> when unknown.</summary>
|
||||
ITextParser? Find(string? id);
|
||||
|
||||
/// <summary>Finds a parser by id, falling back to <see cref="DefaultParser"/>.</summary>
|
||||
ITextParser FindOrDefault(string? id) => Find(id) ?? DefaultParser;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Closed, non-generic facade over <see cref="IParser{TInput, TOutput}"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Open generic interfaces cannot be resolved as <c>IEnumerable<T></c> by the DI container,
|
||||
/// so every text-shaped parser implements this closed interface and gets registered under it.
|
||||
/// </remarks>
|
||||
public interface ITextParser : IParser<string, ParsedRecord>;
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>A recoverable problem with a single record. Parsing continues after one of these.</summary>
|
||||
/// <param name="LineNumber">1-based position of the offending record in the input.</param>
|
||||
/// <param name="Message">What went wrong, phrased for a user rather than a developer.</param>
|
||||
public sealed record ParseError(int LineNumber, string Message)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => $"Line {LineNumber}: {Message}";
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a single record: either a value or a recoverable <see cref="ParseError"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A struct rather than a class hierarchy: parsers emit one of these per line, and on a
|
||||
/// million-line input the allocation difference is the whole cost of the parse.
|
||||
/// </remarks>
|
||||
public readonly record struct ParseOutcome<T>
|
||||
{
|
||||
private ParseOutcome(T? value, ParseError? error)
|
||||
{
|
||||
Value = value;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>The parsed value, or <see langword="null"/> when <see cref="IsSuccess"/> is false.</summary>
|
||||
public T? Value { get; }
|
||||
|
||||
/// <summary>The failure, or <see langword="null"/> when <see cref="IsSuccess"/> is true.</summary>
|
||||
public ParseError? Error { get; }
|
||||
|
||||
/// <summary><see langword="true"/> when a value was produced.</summary>
|
||||
[MemberNotNullWhen(false, nameof(Error))]
|
||||
public bool IsSuccess => Error is null;
|
||||
|
||||
/// <summary>Creates a successful outcome.</summary>
|
||||
public static ParseOutcome<T> Success(T value) => new(value, null);
|
||||
|
||||
/// <summary>Creates a failed outcome.</summary>
|
||||
public static ParseOutcome<T> Failure(ParseError error) =>
|
||||
new(default, error ?? throw new ArgumentNullException(nameof(error)));
|
||||
|
||||
/// <summary>Creates a failed outcome from its parts.</summary>
|
||||
public static ParseOutcome<T> Failure(int lineNumber, string message) =>
|
||||
Failure(new ParseError(lineNumber, message));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>Progress snapshot reported while a parse is running.</summary>
|
||||
/// <param name="Processed">Records handled so far.</param>
|
||||
/// <param name="Total">Expected total, or <c>0</c> when the size is not known up front.</param>
|
||||
public readonly record struct ParseProgress(int Processed, int Total)
|
||||
{
|
||||
/// <summary>Completion in the range <c>0.0 .. 1.0</c>; <c>0</c> when the total is unknown.</summary>
|
||||
public double Fraction => Total <= 0 ? 0d : Math.Clamp((double)Processed / Total, 0d, 1d);
|
||||
|
||||
/// <summary><see langword="true"/> when the total is unknown and the UI should show a busy indicator.</summary>
|
||||
public bool IsIndeterminate => Total <= 0;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>One named field of a <see cref="ParsedRecord"/>.</summary>
|
||||
/// <param name="Name">Column name, or the positional index rendered as text.</param>
|
||||
/// <param name="Value">Raw field value, already trimmed of surrounding whitespace.</param>
|
||||
public readonly record struct ParsedField(string Name, string Value)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => $"{Name}={Value}";
|
||||
}
|
||||
|
||||
/// <summary>A single successfully parsed record.</summary>
|
||||
/// <param name="LineNumber">1-based position of the record in the source input.</param>
|
||||
/// <param name="Fields">The record's fields, in source order.</param>
|
||||
public sealed record ParsedRecord(int LineNumber, IReadOnlyList<ParsedField> Fields)
|
||||
{
|
||||
/// <summary>Flattened <c>key=value</c> rendering, used by the results list.</summary>
|
||||
public string Summary => string.Join(" ", Fields);
|
||||
|
||||
/// <summary>Looks a field up by name; <see langword="null"/> when absent.</summary>
|
||||
public string? this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var field in Fields)
|
||||
{
|
||||
if (string.Equals(field.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return field.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <inheritdoc cref="IParserCatalog" />
|
||||
public sealed class ParserCatalog : IParserCatalog
|
||||
{
|
||||
private readonly Dictionary<string, ITextParser> _byId;
|
||||
|
||||
/// <summary>Builds a catalog from every parser the container resolved.</summary>
|
||||
/// <exception cref="ArgumentException">No parsers were registered, or two share an id.</exception>
|
||||
public ParserCatalog(IEnumerable<ITextParser> parsers)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parsers);
|
||||
|
||||
Parsers = parsers.OrderBy(p => p.DisplayName, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
if (Parsers.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one parser must be registered.", nameof(parsers));
|
||||
}
|
||||
|
||||
_byId = new Dictionary<string, ITextParser>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var parser in Parsers)
|
||||
{
|
||||
if (!_byId.TryAdd(parser.Id, parser))
|
||||
{
|
||||
throw new ArgumentException($"Duplicate parser id '{parser.Id}'.", nameof(parsers));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ITextParser> Parsers { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITextParser DefaultParser => Parsers[0];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITextParser? Find(string? id) => id is not null && _byId.TryGetValue(id, out var parser) ? parser : null;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AvParser.Core.Parsing.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Sample parser: header row plus delimited data rows. Auto-detects the delimiter from the header.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Intentionally simple — no quoting, no escapes. It exists to exercise the
|
||||
/// <see cref="IParser{TInput,TOutput}"/> contract end to end, not to replace a CSV library.
|
||||
/// </remarks>
|
||||
public sealed class DelimitedTextParser : ITextParser
|
||||
{
|
||||
private static readonly char[] Candidates = [',', ';', '\t', '|'];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => "delimited";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Delimited text";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description =>
|
||||
"First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |).";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Candidates) >= 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
|
||||
string input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
var lines = TextLines.Split(input);
|
||||
var total = lines.Length;
|
||||
string[]? header = null;
|
||||
char separator = ',';
|
||||
var processed = 0;
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var line = lines[i];
|
||||
var lineNumber = i + 1;
|
||||
processed++;
|
||||
|
||||
if (TextLines.IsSkippable(line))
|
||||
{
|
||||
ReportEvery(progress, processed, total);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header is null)
|
||||
{
|
||||
separator = DetectSeparator(line);
|
||||
header = SplitTrimmed(line, separator);
|
||||
ReportEvery(progress, processed, total);
|
||||
continue;
|
||||
}
|
||||
|
||||
var values = SplitTrimmed(line, separator);
|
||||
|
||||
if (values.Length != header.Length)
|
||||
{
|
||||
yield return ParseOutcome<ParsedRecord>.Failure(
|
||||
lineNumber,
|
||||
$"Expected {header.Length} field(s) but found {values.Length}."
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
var fields = new ParsedField[values.Length];
|
||||
for (var f = 0; f < values.Length; f++)
|
||||
{
|
||||
fields[f] = new ParsedField(header[f], values[f]);
|
||||
}
|
||||
|
||||
yield return ParseOutcome<ParsedRecord>.Success(new ParsedRecord(lineNumber, fields));
|
||||
}
|
||||
|
||||
ReportEvery(progress, processed, total);
|
||||
|
||||
if (processed % TextLines.YieldInterval == 0)
|
||||
{
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
if (header is null)
|
||||
{
|
||||
yield return ParseOutcome<ParsedRecord>.Failure(1, "Input contains no header row.");
|
||||
}
|
||||
|
||||
progress?.Report(new ParseProgress(total, total));
|
||||
}
|
||||
|
||||
private static void ReportEvery(IProgress<ParseProgress>? progress, int processed, int total)
|
||||
{
|
||||
if (progress is not null && processed % TextLines.ProgressInterval == 0)
|
||||
{
|
||||
progress.Report(new ParseProgress(processed, total));
|
||||
}
|
||||
}
|
||||
|
||||
private static char DetectSeparator(string headerLine)
|
||||
{
|
||||
var best = Candidates[0];
|
||||
var bestCount = 0;
|
||||
|
||||
foreach (var candidate in Candidates)
|
||||
{
|
||||
var count = headerLine.Count(c => c == candidate);
|
||||
if (count > bestCount)
|
||||
{
|
||||
bestCount = count;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static string[] SplitTrimmed(string line, char separator)
|
||||
{
|
||||
var parts = line.Split(separator);
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
parts[i] = parts[i].Trim();
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AvParser.Core.Parsing.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Sample parser: <c>key=value</c> / <c>key: value</c> lines, ini/env style.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A second sample with a different input shape, so the abstraction is proven against more
|
||||
/// than one implementation before the real domain arrives.
|
||||
/// </remarks>
|
||||
public sealed class KeyValueTextParser : ITextParser
|
||||
{
|
||||
private static readonly char[] Separators = ['=', ':'];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => "key-value";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Key / value pairs";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "One pair per line, separated by '=' or ':'. Lines starting with '#' are comments.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Separators) >= 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
|
||||
string input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
var lines = TextLines.Split(input);
|
||||
var total = lines.Length;
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var line = lines[i];
|
||||
var lineNumber = i + 1;
|
||||
var processed = i + 1;
|
||||
|
||||
if (!TextLines.IsSkippable(line))
|
||||
{
|
||||
var separatorIndex = line.IndexOfAny(Separators);
|
||||
|
||||
if (separatorIndex <= 0)
|
||||
{
|
||||
yield return ParseOutcome<ParsedRecord>.Failure(lineNumber, "No '=' or ':' separator found.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var key = line[..separatorIndex].Trim();
|
||||
var value = line[(separatorIndex + 1)..].Trim();
|
||||
|
||||
yield return key.Length == 0
|
||||
? ParseOutcome<ParsedRecord>.Failure(lineNumber, "Key is empty.")
|
||||
: ParseOutcome<ParsedRecord>.Success(
|
||||
new ParsedRecord(lineNumber, [new ParsedField("Key", key), new ParsedField("Value", value)])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (progress is not null && processed % TextLines.ProgressInterval == 0)
|
||||
{
|
||||
progress.Report(new ParseProgress(processed, total));
|
||||
}
|
||||
|
||||
if (processed % TextLines.YieldInterval == 0)
|
||||
{
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
progress?.Report(new ParseProgress(total, total));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace AvParser.Core.Parsing.Samples;
|
||||
|
||||
/// <summary>Line-splitting helpers shared by the sample parsers.</summary>
|
||||
internal static class TextLines
|
||||
{
|
||||
/// <summary>How many records to process between progress reports.</summary>
|
||||
internal const int ProgressInterval = 256;
|
||||
|
||||
/// <summary>How many records to process between cooperative yields.</summary>
|
||||
internal const int YieldInterval = 1024;
|
||||
|
||||
/// <summary>Splits input into lines, normalising CRLF and stripping a trailing empty line.</summary>
|
||||
internal static string[] Split(string input)
|
||||
{
|
||||
var lines = input.Split('\n');
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
lines[i] = lines[i].TrimEnd('\r');
|
||||
}
|
||||
|
||||
// A file that ends with a newline yields a phantom trailing empty line; drop it so
|
||||
// progress totals and line numbers match what the user sees in their editor.
|
||||
return lines is [.., ""] ? lines[..^1] : lines;
|
||||
}
|
||||
|
||||
/// <summary>Blank lines and <c>#</c> comments carry no records.</summary>
|
||||
internal static bool IsSkippable(string line) =>
|
||||
string.IsNullOrWhiteSpace(line) || line.AsSpan().TrimStart()[0] == '#';
|
||||
}
|
||||
Reference in New Issue
Block a user