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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SharpFM/Dialogs/InputDialog.axaml.cs b/src/SharpFM/Dialogs/InputDialog.axaml.cs
new file mode 100644
index 0000000..ce62658
--- /dev/null
+++ b/src/SharpFM/Dialogs/InputDialog.axaml.cs
@@ -0,0 +1,55 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace SharpFM.Dialogs;
+
+///
+/// Single-line text-input modal. The host application's production
+/// opens this dialog over the main window; tests
+/// use a fake prompt and never construct this class.
+///
+[ExcludeFromCodeCoverage]
+public partial class InputDialog : Window
+{
+ private readonly TextBlock _promptLabel;
+ private readonly TextBox _inputBox;
+
+ public string? Result { get; private set; }
+
+ public InputDialog()
+ {
+ AvaloniaXamlLoader.Load(this);
+ _promptLabel = this.FindControl("promptLabel")!;
+ _inputBox = this.FindControl("inputBox")!;
+
+ this.FindControl