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:
Leonid Pershin
2026-08-13 16:07:08 +03:00
co-authored by Claude Opus 5
commit 3db9d4dfc6
94 changed files with 5966 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>AvParser.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Deliberately the ONLY dependency: the domain must stay hostable from a CLI,
a worker service or a benchmark without dragging in a UI stack. -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
using Microsoft.Extensions.DependencyInjection;
namespace AvParser.Core.DependencyInjection;
/// <summary>Composition root for the domain layer.</summary>
public static class CoreServiceCollectionExtensions
{
/// <summary>
/// Registers every parser plus the catalog that indexes them.
/// </summary>
/// <remarks>
/// Adding a parser is a one-line change here — that is the whole point of the
/// <see cref="ITextParser"/> / <see cref="IParserCatalog"/> split.
/// </remarks>
public static IServiceCollection AddAvParserCore(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<ITextParser, DelimitedTextParser>();
services.AddSingleton<ITextParser, KeyValueTextParser>();
services.AddSingleton<IParserCatalog, ParserCatalog>();
return services;
}
}
+30
View File
@@ -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;
}
+10
View File
@@ -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&lt;T&gt;</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>;
+10
View File
@@ -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}";
}
+40
View File
@@ -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;
}
+36
View File
@@ -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] == '#';
}
+42
View File
@@ -0,0 +1,42 @@
namespace AvParser.Core.Settings;
/// <summary>Theme preference. <see cref="System"/> follows the OS setting.</summary>
public enum AppTheme
{
/// <summary>Follow the operating system.</summary>
System = 0,
/// <summary>Always light.</summary>
Light = 1,
/// <summary>Always dark.</summary>
Dark = 2,
}
/// <summary>
/// Everything the app remembers between runs. Persisted verbatim as JSON.
/// </summary>
/// <remarks>
/// A mutable record with defaults on every property: a settings file written by an older
/// version must still deserialise, so no property may be required.
/// </remarks>
public sealed record AppSettings
{
/// <summary>Chosen theme variant.</summary>
public AppTheme Theme { get; init; } = AppTheme.System;
/// <summary>Id of the parser selected last time; resolved leniently on load.</summary>
public string? LastParserId { get; init; }
/// <summary>Last main-window width in device-independent pixels.</summary>
public double WindowWidth { get; init; } = 1280;
/// <summary>Last main-window height in device-independent pixels.</summary>
public double WindowHeight { get; init; } = 800;
/// <summary>Whether the main window was maximised on exit.</summary>
public bool WindowMaximized { get; init; }
/// <summary>Minimum Serilog level, as a Serilog level name.</summary>
public string MinimumLogLevel { get; init; } = "Information";
}
@@ -0,0 +1,22 @@
namespace AvParser.Core.Settings;
/// <summary>Reads and persists <see cref="AppSettings"/>.</summary>
/// <remarks>
/// <see cref="Update"/> is fire-and-forget on purpose: writes are debounced by the
/// implementation so that dragging a window or flicking a toggle does not hit the disk
/// on every change. Call <see cref="FlushAsync"/> on shutdown to force the pending write out.
/// </remarks>
public interface ISettingsService
{
/// <summary>The current in-memory settings. Never <see langword="null"/>.</summary>
AppSettings Current { get; }
/// <summary>Fires after <see cref="Current"/> changes, including the initial load.</summary>
IObservable<AppSettings> Changes { get; }
/// <summary>Applies a change and schedules a debounced save.</summary>
void Update(Func<AppSettings, AppSettings> mutate);
/// <summary>Writes any pending change immediately.</summary>
Task FlushAsync(CancellationToken cancellationToken = default);
}