Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions src/SharpFM.Model/Clip.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using SharpFM.Model.ClipTypes;
using SharpFM.Model.Parsing;
using SharpFM.Model.Scripting;
using SharpFM.Model.Validation;

namespace SharpFM.Model;

Expand Down Expand Up @@ -99,7 +100,23 @@ public static Clip FromXml(string name, string formatId, string xml)
name,
formatId,
canonical,
() => ClipTypeRegistry.For(formatId).Parse(canonical));
() => WithSemanticDiagnostics(formatId, ClipTypeRegistry.For(formatId).Parse(canonical)));
}

private static ClipParseResult WithSemanticDiagnostics(string formatId, ClipParseResult result)
{
if (result is not ParseSuccess success)
{
return result;
}

var semantic = SemanticValidatorRegistry.Run(formatId, success.Model);
if (semantic.Count == 0)
{
return success;
}

return success with { Report = success.Report with { SemanticDiagnostics = semantic } };
}

/// <summary>
Expand Down Expand Up @@ -130,20 +147,23 @@ public static Clip FromWireBytes(string name, string formatId, byte[] bytes)
/// </remarks>
public static Clip FromEditor(string name, string formatId, string xml, ClipModel model)
{
var report = ReportForEditorModel(model);
var report = ReportForEditorModel(formatId, model);
return new Clip(name, formatId, xml, new ParseSuccess(model, report));
}

private static ClipParseReport ReportForEditorModel(ClipModel model)
private static ClipParseReport ReportForEditorModel(string formatId, ClipModel model)
{
if (model is ScriptClipModel script)
IReadOnlyList<ClipParseDiagnostic> structural = model is ScriptClipModel script
? ClipStrategyHelpers.RawStepDiagnostics(script.Script).ToList()
: Array.Empty<ClipParseDiagnostic>();
var semantic = SemanticValidatorRegistry.Run(formatId, model);

if (structural.Count == 0 && semantic.Count == 0)
{
var diagnostics = ClipStrategyHelpers.RawStepDiagnostics(script.Script).ToList();
return diagnostics.Count == 0
? ClipParseReport.Empty
: new ClipParseReport(diagnostics);
return ClipParseReport.Empty;
}
return ClipParseReport.Empty;

return new ClipParseReport(structural) { SemanticDiagnostics = semantic };
}

/// <summary>
Expand Down
9 changes: 9 additions & 0 deletions src/SharpFM.Model/ClipData.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using SharpFM.Model.Parsing;

namespace SharpFM.Model;

Expand All @@ -18,4 +19,12 @@ public record ClipData(string Name, string ClipType, string Xml)
/// (subdirectories, record columns, URL path, etc.).
/// </summary>
public IReadOnlyList<string> FolderPath { get; init; } = [];

/// <summary>
/// Parse-fidelity report attached when the host produces this snapshot.
/// Repository-loaded clips that have never been parsed default to
/// <see cref="ClipParseReport.Empty"/>; the host populates the real report
/// when bridging from a live <see cref="Clip"/> aggregate.
/// </summary>
public ClipParseReport ParseReport { get; init; } = ClipParseReport.Empty;
}
19 changes: 16 additions & 3 deletions src/SharpFM.Model/Parsing/ClipParseReport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,27 @@ namespace SharpFM.Model.Parsing;

/// <summary>
/// Aggregate of every <see cref="ClipParseDiagnostic"/> produced by parsing one
/// clip. Lossless when empty; consumers check <see cref="IsLossless"/> rather
/// than inspecting the collection directly.
/// clip. Structural <see cref="Diagnostics"/> describe round-trip fidelity loss
/// detected by <see cref="XmlRoundTripDiff"/>; <see cref="SemanticDiagnostics"/>
/// describe domain-rule violations emitted by validators registered in
/// <see cref="Validation.SemanticValidatorRegistry"/>. The two axes are kept
/// distinct so consumers (status bar, tree glyph, plugins) can render structural
/// fidelity and semantic correctness independently.
/// </summary>
public sealed record ClipParseReport(IReadOnlyList<ClipParseDiagnostic> Diagnostics)
{
/// <summary>Shared empty report used by clean parses to avoid allocating.</summary>
public static ClipParseReport Empty { get; } = new([]);

/// <summary>True if the parse produced no diagnostics of any kind.</summary>
/// <summary>
/// Domain-rule violations from semantic validators. Defaults to empty so
/// the existing positional constructor stays source-compatible.
/// </summary>
public IReadOnlyList<ClipParseDiagnostic> SemanticDiagnostics { get; init; } = [];

/// <summary>True if the parse produced no structural diagnostics.</summary>
public bool IsLossless => Diagnostics.Count == 0;

/// <summary>True if no semantic validators flagged the parsed model.</summary>
public bool IsSemanticallyValid => SemanticDiagnostics.Count == 0;
}
28 changes: 28 additions & 0 deletions src/SharpFM.Model/Validation/IClipSemanticValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Collections.Generic;
using SharpFM.Model.Parsing;

namespace SharpFM.Model.Validation;

/// <summary>
/// Inspects a parsed <see cref="ClipModel"/> and emits diagnostics for
/// domain-rule violations that <see cref="XmlRoundTripDiff"/> cannot detect —
/// e.g. FileMaker variable-name conventions or calculation-syntax errors.
/// Validators are pure, must not throw, and run on every successful parse.
/// </summary>
public interface IClipSemanticValidator
{
/// <summary>
/// Sentinel <see cref="FormatIds"/> entry that opts the validator into
/// every clip type.
/// </summary>
public const string AllFormats = "*";

/// <summary>
/// Format ids this validator applies to (e.g. <c>"Mac-XMSS"</c>). Use
/// <see cref="AllFormats"/> to opt into every clip type.
/// </summary>
IReadOnlyCollection<string> FormatIds { get; }

/// <summary>Return any domain-rule violations found in <paramref name="model"/>.</summary>
IReadOnlyList<ClipParseDiagnostic> Validate(ClipModel model);
}
90 changes: 90 additions & 0 deletions src/SharpFM.Model/Validation/SemanticValidatorRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using SharpFM.Model.Parsing;

namespace SharpFM.Model.Validation;

/// <summary>
/// Static registry of <see cref="IClipSemanticValidator"/>s the host runs after
/// every successful clip parse. <see cref="BuiltIns"/> is the production set;
/// the registry deliberately ships empty so domain rules can land one at a time
/// without bundling the framework PR.
/// </summary>
public static class SemanticValidatorRegistry
{
[ThreadStatic]
private static IReadOnlyList<IClipSemanticValidator>? _overrideForTest;

/// <summary>Validators that run on every parse in production.</summary>
public static IReadOnlyList<IClipSemanticValidator> BuiltIns { get; } = [];

/// <summary>
/// Run the built-in validators for <paramref name="formatId"/> against
/// <paramref name="model"/> and return the combined diagnostics. Returns
/// a shared empty list when nothing matches.
/// </summary>
public static IReadOnlyList<ClipParseDiagnostic> Run(string formatId, ClipModel model) =>
Run(formatId, model, _overrideForTest ?? BuiltIns);

/// <summary>
/// Per-thread override of <see cref="BuiltIns"/> for integration tests
/// that need to assert validators actually run through the parse pipeline.
/// Dispose to restore. Production code never sets this.
/// </summary>
internal static IDisposable OverrideForTest(IReadOnlyList<IClipSemanticValidator> validators)
{
_overrideForTest = validators;
return new Resetter();
}

private sealed class Resetter : IDisposable
{
public void Dispose() => _overrideForTest = null;
}

/// <summary>
/// Same as <see cref="Run(string, ClipModel)"/> but with an explicit
/// validator set — exposed for tests so the public <see cref="BuiltIns"/>
/// stays static and immutable.
/// </summary>
internal static IReadOnlyList<ClipParseDiagnostic> Run(
string formatId,
ClipModel model,
IReadOnlyList<IClipSemanticValidator> validators)
{
if (validators.Count == 0)
{
return Array.Empty<ClipParseDiagnostic>();
}

List<ClipParseDiagnostic>? acc = null;
foreach (var v in validators)
{
if (!Applies(v, formatId))
{
continue;
}

var diags = v.Validate(model);
if (diags.Count == 0)
{
continue;
}

(acc ??= []).AddRange(diags);
}
return (IReadOnlyList<ClipParseDiagnostic>?)acc ?? Array.Empty<ClipParseDiagnostic>();
}

private static bool Applies(IClipSemanticValidator validator, string formatId)
{
foreach (var id in validator.FormatIds)
{
if (id == IClipSemanticValidator.AllFormats || id == formatId)
{
return true;
}
}
return false;
}
}
10 changes: 10 additions & 0 deletions src/SharpFM.Plugin/IPluginHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using SharpFM.Model;
using SharpFM.Model.Parsing;
using SharpFM.Model.Schema;
using SharpFM.Model.Scripting;

Expand Down Expand Up @@ -63,6 +64,15 @@ public interface IPluginHost
/// </summary>
ClipData? GetClip(string clipName);

/// <summary>
/// Parse <paramref name="xml"/> with the strategy registered for
/// <paramref name="clipType"/> and return the resulting fidelity report.
/// Pure — does not load or mutate any clip in the host. Lets plugins
/// pre-flight XML before pushing it through
/// <see cref="UpdateClipXml"/> or <see cref="UpdateSelectedClipXml"/>.
/// </summary>
ClipParseReport ValidateClipXml(string clipType, string xml);

/// <summary>
/// Replace the XML content of any loaded clip by name.
/// If the clip is currently selected, syncs the change to the editor.
Expand Down
7 changes: 5 additions & 2 deletions src/SharpFM/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using SharpFM.Dialogs;
using SharpFM.Model.Scripting.Registry;
using SharpFM.Models;
using SharpFM.Plugin;
Expand Down Expand Up @@ -41,13 +42,15 @@ public override void OnFrameworkInitializationCompleted()
services.AddSingleton<IClipboardService>(x => new ClipboardService(desktop.MainWindow));
Services = services.BuildServiceProvider();

var inputPrompt = new WindowInputPrompt(desktop.MainWindow);
var viewModel = new MainWindowViewModel(
logger,
Services.GetRequiredService<IClipboardService>(),
Services.GetRequiredService<IFolderService>());
Services.GetRequiredService<IFolderService>(),
inputPrompt);

// Load plugins
var pluginHost = new PluginHost(viewModel, loggerFactory);
var pluginHost = new PluginHost(viewModel, loggerFactory, inputPrompt);
var pluginUIHost = new PluginUIHost(pluginHost);
var pluginConfigService = new PluginConfigService(logger);
var pluginService = new PluginService(logger, pluginConfigService);
Expand Down
17 changes: 17 additions & 0 deletions src/SharpFM/Dialogs/IInputPrompt.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System.Threading.Tasks;

namespace SharpFM.Dialogs;

/// <summary>
/// Host-supplied input prompt used by view-models (and the plugin host) to
/// ask the user for a single line of text. Abstracted so view-model tests
/// can substitute a fake without standing up an Avalonia window.
/// </summary>
public interface IInputPrompt
{
/// <summary>
/// Show a modal prompt and return the user's input, or <c>null</c> if
/// they cancelled.
/// </summary>
Task<string?> PromptAsync(string title, string prompt, string defaultValue);
}
43 changes: 43 additions & 0 deletions src/SharpFM/Dialogs/InputDialog.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<Window
x:Class="SharpFM.Dialogs.InputDialog"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Input"
Width="420"
SizeToContent="Height"
WindowStartupLocation="CenterOwner">

<Grid Margin="16" RowDefinitions="Auto,Auto,Auto">

<TextBlock
x:Name="promptLabel"
Grid.Row="0"
Margin="0,0,0,8"
Classes="Fluent2Body"
Text=""
TextWrapping="Wrap" />

<TextBox
x:Name="inputBox"
Grid.Row="1"
Margin="0,0,0,12" />

<StackPanel
Grid.Row="2"
HorizontalAlignment="Right"
Orientation="Horizontal"
Spacing="8">
<Button
x:Name="cancelButton"
Classes="Fluent2"
Content="Cancel" />
<Button
x:Name="okButton"
Classes="Fluent2Primary"
Content="OK"
IsDefault="True" />
</StackPanel>

</Grid>

</Window>
Loading
Loading