diff --git a/src/SharpFM.Model/Clip.cs b/src/SharpFM.Model/Clip.cs index 058a7d8..8c340ff 100644 --- a/src/SharpFM.Model/Clip.cs +++ b/src/SharpFM.Model/Clip.cs @@ -6,6 +6,7 @@ using SharpFM.Model.ClipTypes; using SharpFM.Model.Parsing; using SharpFM.Model.Scripting; +using SharpFM.Model.Validation; namespace SharpFM.Model; @@ -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 } }; } /// @@ -130,20 +147,23 @@ public static Clip FromWireBytes(string name, string formatId, byte[] bytes) /// 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 structural = model is ScriptClipModel script + ? ClipStrategyHelpers.RawStepDiagnostics(script.Script).ToList() + : Array.Empty(); + 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 }; } /// diff --git a/src/SharpFM.Model/ClipData.cs b/src/SharpFM.Model/ClipData.cs index 4b2d307..65f0e6a 100644 --- a/src/SharpFM.Model/ClipData.cs +++ b/src/SharpFM.Model/ClipData.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using SharpFM.Model.Parsing; namespace SharpFM.Model; @@ -18,4 +19,12 @@ public record ClipData(string Name, string ClipType, string Xml) /// (subdirectories, record columns, URL path, etc.). /// public IReadOnlyList FolderPath { get; init; } = []; + + /// + /// Parse-fidelity report attached when the host produces this snapshot. + /// Repository-loaded clips that have never been parsed default to + /// ; the host populates the real report + /// when bridging from a live aggregate. + /// + public ClipParseReport ParseReport { get; init; } = ClipParseReport.Empty; } diff --git a/src/SharpFM.Model/Parsing/ClipParseReport.cs b/src/SharpFM.Model/Parsing/ClipParseReport.cs index b3e083a..09aca5a 100644 --- a/src/SharpFM.Model/Parsing/ClipParseReport.cs +++ b/src/SharpFM.Model/Parsing/ClipParseReport.cs @@ -4,14 +4,27 @@ namespace SharpFM.Model.Parsing; /// /// Aggregate of every produced by parsing one -/// clip. Lossless when empty; consumers check rather -/// than inspecting the collection directly. +/// clip. Structural describe round-trip fidelity loss +/// detected by ; +/// describe domain-rule violations emitted by validators registered in +/// . The two axes are kept +/// distinct so consumers (status bar, tree glyph, plugins) can render structural +/// fidelity and semantic correctness independently. /// public sealed record ClipParseReport(IReadOnlyList Diagnostics) { /// Shared empty report used by clean parses to avoid allocating. public static ClipParseReport Empty { get; } = new([]); - /// True if the parse produced no diagnostics of any kind. + /// + /// Domain-rule violations from semantic validators. Defaults to empty so + /// the existing positional constructor stays source-compatible. + /// + public IReadOnlyList SemanticDiagnostics { get; init; } = []; + + /// True if the parse produced no structural diagnostics. public bool IsLossless => Diagnostics.Count == 0; + + /// True if no semantic validators flagged the parsed model. + public bool IsSemanticallyValid => SemanticDiagnostics.Count == 0; } diff --git a/src/SharpFM.Model/Validation/IClipSemanticValidator.cs b/src/SharpFM.Model/Validation/IClipSemanticValidator.cs new file mode 100644 index 0000000..da587eb --- /dev/null +++ b/src/SharpFM.Model/Validation/IClipSemanticValidator.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using SharpFM.Model.Parsing; + +namespace SharpFM.Model.Validation; + +/// +/// Inspects a parsed and emits diagnostics for +/// domain-rule violations that cannot detect — +/// e.g. FileMaker variable-name conventions or calculation-syntax errors. +/// Validators are pure, must not throw, and run on every successful parse. +/// +public interface IClipSemanticValidator +{ + /// + /// Sentinel entry that opts the validator into + /// every clip type. + /// + public const string AllFormats = "*"; + + /// + /// Format ids this validator applies to (e.g. "Mac-XMSS"). Use + /// to opt into every clip type. + /// + IReadOnlyCollection FormatIds { get; } + + /// Return any domain-rule violations found in . + IReadOnlyList Validate(ClipModel model); +} diff --git a/src/SharpFM.Model/Validation/SemanticValidatorRegistry.cs b/src/SharpFM.Model/Validation/SemanticValidatorRegistry.cs new file mode 100644 index 0000000..d11cdda --- /dev/null +++ b/src/SharpFM.Model/Validation/SemanticValidatorRegistry.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using SharpFM.Model.Parsing; + +namespace SharpFM.Model.Validation; + +/// +/// Static registry of s the host runs after +/// every successful clip parse. is the production set; +/// the registry deliberately ships empty so domain rules can land one at a time +/// without bundling the framework PR. +/// +public static class SemanticValidatorRegistry +{ + [ThreadStatic] + private static IReadOnlyList? _overrideForTest; + + /// Validators that run on every parse in production. + public static IReadOnlyList BuiltIns { get; } = []; + + /// + /// Run the built-in validators for against + /// and return the combined diagnostics. Returns + /// a shared empty list when nothing matches. + /// + public static IReadOnlyList Run(string formatId, ClipModel model) => + Run(formatId, model, _overrideForTest ?? BuiltIns); + + /// + /// Per-thread override of for integration tests + /// that need to assert validators actually run through the parse pipeline. + /// Dispose to restore. Production code never sets this. + /// + internal static IDisposable OverrideForTest(IReadOnlyList validators) + { + _overrideForTest = validators; + return new Resetter(); + } + + private sealed class Resetter : IDisposable + { + public void Dispose() => _overrideForTest = null; + } + + /// + /// Same as but with an explicit + /// validator set — exposed for tests so the public + /// stays static and immutable. + /// + internal static IReadOnlyList Run( + string formatId, + ClipModel model, + IReadOnlyList validators) + { + if (validators.Count == 0) + { + return Array.Empty(); + } + + List? 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?)acc ?? Array.Empty(); + } + + private static bool Applies(IClipSemanticValidator validator, string formatId) + { + foreach (var id in validator.FormatIds) + { + if (id == IClipSemanticValidator.AllFormats || id == formatId) + { + return true; + } + } + return false; + } +} diff --git a/src/SharpFM.Plugin/IPluginHost.cs b/src/SharpFM.Plugin/IPluginHost.cs index 8a8e4fe..7998a02 100644 --- a/src/SharpFM.Plugin/IPluginHost.cs +++ b/src/SharpFM.Plugin/IPluginHost.cs @@ -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; @@ -63,6 +64,15 @@ public interface IPluginHost /// ClipData? GetClip(string clipName); + /// + /// Parse with the strategy registered for + /// 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 + /// or . + /// + ClipParseReport ValidateClipXml(string clipType, string xml); + /// /// Replace the XML content of any loaded clip by name. /// If the clip is currently selected, syncs the change to the editor. diff --git a/src/SharpFM/App.axaml.cs b/src/SharpFM/App.axaml.cs index dd02f7f..211c402 100644 --- a/src/SharpFM/App.axaml.cs +++ b/src/SharpFM/App.axaml.cs @@ -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; @@ -41,13 +42,15 @@ public override void OnFrameworkInitializationCompleted() services.AddSingleton(x => new ClipboardService(desktop.MainWindow)); Services = services.BuildServiceProvider(); + var inputPrompt = new WindowInputPrompt(desktop.MainWindow); var viewModel = new MainWindowViewModel( logger, Services.GetRequiredService(), - Services.GetRequiredService()); + Services.GetRequiredService(), + 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); diff --git a/src/SharpFM/Dialogs/IInputPrompt.cs b/src/SharpFM/Dialogs/IInputPrompt.cs new file mode 100644 index 0000000..a9cc024 --- /dev/null +++ b/src/SharpFM/Dialogs/IInputPrompt.cs @@ -0,0 +1,17 @@ +using System.Threading.Tasks; + +namespace SharpFM.Dialogs; + +/// +/// 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. +/// +public interface IInputPrompt +{ + /// + /// Show a modal prompt and return the user's input, or null if + /// they cancelled. + /// + Task PromptAsync(string title, string prompt, string defaultValue); +} diff --git a/src/SharpFM/Dialogs/InputDialog.axaml b/src/SharpFM/Dialogs/InputDialog.axaml new file mode 100644 index 0000000..f87787f --- /dev/null +++ b/src/SharpFM/Dialogs/InputDialog.axaml @@ -0,0 +1,43 @@ + + + + + + + + + +