Skip to content

Commit eba95cf

Browse files
committed
perf: trusted-edit path skips strategy parse on editor edits
- Editors expose GetModel() so the aggregate can take their parsed state directly. ClipViewModel.HandleEditorContentChanged routes through Clip.FromEditor which wraps the model in ParseSuccess without running the strategy or the round-trip diff. Per-keystroke cost on a 1000-step script drops from ~500ms-2.5s of XML work to the editor's own re-render. - FmScript.ToElement / FmTable.ToElement skip the parse-and-prettify round-trip strategies were doing on the diff path. - ClipDataExtensions routes through the registry instead of calling FmScript.FromXml / FmTable.FromXml directly so plugin-side parsing shares the host's dispatch. - ClipTypeRegistry collapses to an immutable static array. No Register, RegisterBuiltIns, Reset, no _initialized flag, no test isolation collection — clip strategies are fully owned by SharpFM and known at compile time.
1 parent c0326e3 commit eba95cf

20 files changed

Lines changed: 293 additions & 265 deletions

src/SharpFM.Model/Clip.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Linq;
34
using System.Text;
45
using System.Threading;
@@ -113,6 +114,38 @@ public static Clip FromWireBytes(string name, string formatId, byte[] bytes)
113114
return FromXml(name, formatId, xml);
114115
}
115116

117+
/// <summary>
118+
/// Construct a clip from a model the editor already holds. Skips the
119+
/// strategy parse + round-trip diff entirely — the editor's domain model
120+
/// is the source of truth, the XML it emits is lossless to that model
121+
/// by definition. Only kind-specific diagnostics that aren't structural
122+
/// (e.g. <see cref="ParseDiagnosticKind.UnknownStep"/> for <c>RawStep</c>)
123+
/// are derived from the model directly.
124+
/// </summary>
125+
/// <remarks>
126+
/// This is the typing hot path for large scripts. Going through
127+
/// <see cref="FromXml"/> on every debounced edit re-parses the XML
128+
/// (~ N steps) plus serialises and structurally diffs (~ N more) on
129+
/// the UI thread. <c>FromEditor</c> drops all of that.
130+
/// </remarks>
131+
public static Clip FromEditor(string name, string formatId, string xml, ClipModel model)
132+
{
133+
var report = ReportForEditorModel(model);
134+
return new Clip(name, formatId, xml, new ParseSuccess(model, report));
135+
}
136+
137+
private static ClipParseReport ReportForEditorModel(ClipModel model)
138+
{
139+
if (model is ScriptClipModel script)
140+
{
141+
var diagnostics = ClipStrategyHelpers.RawStepDiagnostics(script.Script).ToList();
142+
return diagnostics.Count == 0
143+
? ClipParseReport.Empty
144+
: new ClipParseReport(diagnostics);
145+
}
146+
return ClipParseReport.Empty;
147+
}
148+
116149
/// <summary>
117150
/// Return a fresh clip with replacement XML. The parse runs lazily on
118151
/// the new instance. Returns <c>this</c> when <paramref name="newXml"/>
Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
using System.Collections.Generic;
2+
using SharpFM.Model.ClipTypes;
3+
using SharpFM.Model.Parsing;
24
using SharpFM.Model.Schema;
35
using SharpFM.Model.Scripting;
46

57
namespace SharpFM.Model;
68

79
/// <summary>
810
/// Convenience extensions on <see cref="ClipData"/> for parsing into the
9-
/// appropriate domain model based on the clip's format.
11+
/// appropriate domain model based on the clip's format. Typed accessors
12+
/// dispatch through <see cref="ClipTypeRegistry"/> so adding a new format
13+
/// with a registered strategy automatically lights up the helpers.
1014
/// </summary>
1115
public static class ClipDataExtensions
1216
{
@@ -16,27 +20,23 @@ public static bool IsScript(this ClipData clip) =>
1620
public static bool IsTable(this ClipData clip) =>
1721
clip.ClipType is "Mac-XMTB" or "Mac-XMFD";
1822

19-
/// <summary>
20-
/// Parse this clip as a script. Returns null if the clip is not a script type.
21-
/// </summary>
23+
/// <summary>Parse this clip as a script; null if the clip is not a script type.</summary>
2224
public static FmScript? AsScript(this ClipData clip) =>
23-
clip.IsScript() ? FmScript.FromXml(clip.Xml) : null;
25+
ClipTypeRegistry.For(clip.ClipType).Parse(clip.Xml) is ParseSuccess { Model: ScriptClipModel s }
26+
? s.Script
27+
: null;
2428

25-
/// <summary>
26-
/// Parse this clip as a table. Returns null if the clip is not a table type.
27-
/// </summary>
29+
/// <summary>Parse this clip as a table; null if the clip is not a table type.</summary>
2830
public static FmTable? AsTable(this ClipData clip) =>
29-
clip.IsTable() ? FmTable.FromXml(clip.Xml) : null;
31+
ClipTypeRegistry.For(clip.ClipType).Parse(clip.Xml) is ParseSuccess { Model: TableClipModel t }
32+
? t.Table
33+
: null;
3034

31-
/// <summary>
32-
/// Get the script's steps as a snapshot list. Returns null if the clip is not a script type.
33-
/// </summary>
35+
/// <summary>Get the script's steps as a snapshot list; null if the clip is not a script type.</summary>
3436
public static IReadOnlyList<ScriptStep>? GetScriptSteps(this ClipData clip) =>
3537
clip.AsScript()?.Steps;
3638

37-
/// <summary>
38-
/// Get the table's fields as a snapshot list. Returns null if the clip is not a table type.
39-
/// </summary>
39+
/// <summary>Get the table's fields as a snapshot list; null if the clip is not a table type.</summary>
4040
public static IReadOnlyList<FmField>? GetTableFields(this ClipData clip) =>
4141
clip.AsTable()?.Fields;
4242
}

src/SharpFM.Model/ClipTypes/ClipStrategyHelpers.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
using System.Collections.Generic;
12
using System.Xml;
23
using System.Xml.Linq;
34
using SharpFM.Model.Parsing;
5+
using SharpFM.Model.Scripting;
6+
using SharpFM.Model.Scripting.Steps;
47

58
namespace SharpFM.Model.ClipTypes;
69

@@ -59,4 +62,27 @@ public static bool TryParseFmxmlsnippet(string xml, out XElement root, out Parse
5962

6063
return true;
6164
}
65+
66+
/// <summary>
67+
/// Walk a script's steps and emit one Info-severity <see cref="ParseDiagnosticKind.UnknownStep"/>
68+
/// diagnostic per <see cref="RawStep"/>. Used by both the script strategy
69+
/// (after a fresh parse) and the trusted-edit path (after a model-only
70+
/// reuse) so the same UI signal surfaces regardless of source.
71+
/// </summary>
72+
public static IEnumerable<ClipParseDiagnostic> RawStepDiagnostics(FmScript script)
73+
{
74+
var index = 0;
75+
foreach (var step in script.Steps)
76+
{
77+
index++;
78+
if (step is RawStep raw)
79+
{
80+
yield return new ClipParseDiagnostic(
81+
ParseDiagnosticKind.UnknownStep,
82+
ParseDiagnosticSeverity.Info,
83+
$"/fmxmlsnippet/Step[{index}]",
84+
$"step '{raw.Name}' is not modeled by the host; preserved verbatim as RawStep");
85+
}
86+
}
87+
}
6288
}

src/SharpFM.Model/ClipTypes/ClipTypeRegistry.cs

Lines changed: 27 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -4,87 +4,41 @@
44
namespace SharpFM.Model.ClipTypes;
55

66
/// <summary>
7-
/// Static, explicitly-populated registry of <see cref="IClipTypeStrategy"/>
8-
/// implementations keyed by <c>Mac-XM*</c> format id. Built-in strategies are
9-
/// registered once at startup via <see cref="RegisterBuiltIns"/>; tests
10-
/// reset and re-register through <see cref="Reset"/>.
7+
/// Compile-time registry of <see cref="IClipTypeStrategy"/> implementations
8+
/// keyed by <c>Mac-XM*</c> format id. Strategies are fully owned by SharpFM
9+
/// (no plugin extension point), so the table is built once from a static
10+
/// list and is read-only thereafter — no locks, no bootstrap, no reset.
11+
/// Adding a new clip type means writing the strategy and adding it to
12+
/// <see cref="BuiltIns"/>.
1113
/// </summary>
12-
/// <remarks>
13-
/// Reflection-based auto-discovery (the pattern used by <c>StepRegistry</c>)
14-
/// is deliberately not used here — clip types are few, low-cardinality, and
15-
/// explicit registration makes the bootstrapping order obvious.
16-
/// </remarks>
1714
public static class ClipTypeRegistry
1815
{
19-
private static readonly object _gate = new();
20-
private static readonly Dictionary<string, IClipTypeStrategy> _strategies = new();
16+
public static IReadOnlyList<IClipTypeStrategy> BuiltIns { get; } =
17+
[
18+
ScriptClipStrategy.Steps,
19+
ScriptClipStrategy.Script,
20+
TableClipStrategy.Table,
21+
TableClipStrategy.Field,
22+
LayoutClipStrategy.Instance,
23+
];
2124

22-
/// <summary>Register a strategy. A duplicate <see cref="IClipTypeStrategy.FormatId"/> overwrites the prior entry.</summary>
23-
public static void Register(IClipTypeStrategy strategy)
24-
{
25-
lock (_gate)
26-
{
27-
_strategies[strategy.FormatId] = strategy;
28-
}
29-
}
25+
private static readonly Dictionary<string, IClipTypeStrategy> _byFormatId =
26+
BuiltIns.ToDictionary(s => s.FormatId);
27+
28+
/// <summary>All built-in strategies (excludes the opaque fallback).</summary>
29+
public static IReadOnlyList<IClipTypeStrategy> All => BuiltIns;
3030

3131
/// <summary>
3232
/// Resolve a strategy for the given format id. Unknown ids fall back to
3333
/// <see cref="OpaqueClipStrategy.Instance"/> so callers always receive a
3434
/// usable strategy.
3535
/// </summary>
36-
public static IClipTypeStrategy For(string formatId)
37-
{
38-
lock (_gate)
39-
{
40-
return _strategies.TryGetValue(formatId, out var strategy)
41-
? strategy
42-
: OpaqueClipStrategy.Instance;
43-
}
44-
}
45-
46-
/// <summary>True if the given format id has a dedicated strategy registered.</summary>
47-
public static bool IsRegistered(string formatId)
48-
{
49-
lock (_gate)
50-
{
51-
return _strategies.ContainsKey(formatId);
52-
}
53-
}
54-
55-
/// <summary>All explicitly-registered strategies, in registration order.</summary>
56-
public static IReadOnlyList<IClipTypeStrategy> All
57-
{
58-
get
59-
{
60-
lock (_gate)
61-
{
62-
return _strategies.Values.ToList();
63-
}
64-
}
65-
}
66-
67-
/// <summary>
68-
/// Register every built-in clip-type strategy. Called once at host startup;
69-
/// idempotent thanks to <see cref="Register"/>'s overwrite semantics. Adding
70-
/// a new <c>Mac-XM*</c> format is a single additional <see cref="Register"/>
71-
/// call here. Opaque is the implicit fallback and is not registered.
72-
/// </summary>
73-
public static void RegisterBuiltIns()
74-
{
75-
Register(ScriptClipStrategy.Steps);
76-
Register(ScriptClipStrategy.Script);
77-
Register(TableClipStrategy.Table);
78-
Register(TableClipStrategy.Field);
79-
Register(LayoutClipStrategy.Instance);
80-
}
81-
82-
/// <summary>Clear the registry. Tests use this to isolate from production registrations.</summary>
83-
internal static void Reset()
84-
{
85-
lock (_gate)
86-
{
87-
_strategies.Clear();
88-
}
89-
}
36+
public static IClipTypeStrategy For(string formatId) =>
37+
_byFormatId.TryGetValue(formatId, out var strategy)
38+
? strategy
39+
: OpaqueClipStrategy.Instance;
40+
41+
/// <summary>True if the given format id has a dedicated built-in strategy.</summary>
42+
public static bool IsRegistered(string formatId) =>
43+
_byFormatId.ContainsKey(formatId);
9044
}

src/SharpFM.Model/ClipTypes/ScriptClipStrategy.cs

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
using System;
22
using System.Collections.Generic;
3-
using System.Xml.Linq;
43
using SharpFM.Model.Parsing;
54
using SharpFM.Model.Scripting;
6-
using SharpFM.Model.Scripting.Steps;
75

86
namespace SharpFM.Model.ClipTypes;
97

@@ -52,22 +50,9 @@ public ClipParseResult Parse(string xml)
5250
ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse script");
5351
}
5452

55-
var output = XElement.Parse(script.ToXml());
56-
var diagnostics = new List<ClipParseDiagnostic>(XmlRoundTripDiff.Compute(input, output));
57-
58-
var rawStepIndex = 0;
59-
foreach (var step in script.Steps)
60-
{
61-
rawStepIndex++;
62-
if (step is RawStep raw)
63-
{
64-
diagnostics.Add(new ClipParseDiagnostic(
65-
ParseDiagnosticKind.UnknownStep,
66-
ParseDiagnosticSeverity.Info,
67-
$"/fmxmlsnippet/Step[{rawStepIndex}]",
68-
$"step '{raw.Name}' is not modeled by the host; preserved verbatim as RawStep"));
69-
}
70-
}
53+
var diagnostics = new List<ClipParseDiagnostic>(
54+
XmlRoundTripDiff.Compute(input, script.ToElement()));
55+
diagnostics.AddRange(ClipStrategyHelpers.RawStepDiagnostics(script));
7156

7257
var report = diagnostics.Count == 0
7358
? ClipParseReport.Empty

src/SharpFM.Model/ClipTypes/TableClipStrategy.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System;
22
using System.Collections.Generic;
3-
using System.Xml.Linq;
43
using SharpFM.Model.Parsing;
54
using SharpFM.Model.Schema;
65

@@ -52,8 +51,8 @@ public ClipParseResult Parse(string xml)
5251
ParseDiagnosticKind.XmlMalformed, "/", ex.Message, "failed to parse table");
5352
}
5453

55-
var output = XElement.Parse(table.ToXml());
56-
var diagnostics = new List<ClipParseDiagnostic>(XmlRoundTripDiff.Compute(input, output));
54+
var diagnostics = new List<ClipParseDiagnostic>(
55+
XmlRoundTripDiff.Compute(input, table.ToElement()));
5756

5857
var report = diagnostics.Count == 0
5958
? ClipParseReport.Empty

src/SharpFM.Model/Schema/FmTable.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ public static FmTable FromXml(string xml)
3939
return new FmTable(tableName, fields) { Id = tableId };
4040
}
4141

42-
public string ToXml()
42+
/// <summary>
43+
/// Build the table's XML as an <see cref="XElement"/> directly, skipping
44+
/// the <c>ToString</c> + pretty-print round-trip <see cref="ToXml"/> does.
45+
/// </summary>
46+
public XElement ToElement()
4347
{
4448
var root = new XElement("fmxmlsnippet", new XAttribute("type", "FMObjectList"));
4549
var baseTable = new XElement("BaseTable", new XAttribute("name", Name));
@@ -51,9 +55,11 @@ public string ToXml()
5155

5256
root.Add(baseTable);
5357

54-
return XmlHelpers.PrettyPrint(root.ToString());
58+
return root;
5559
}
5660

61+
public string ToXml() => XmlHelpers.PrettyPrint(ToElement().ToString());
62+
5763
public void AddField(FmField field)
5864
{
5965
Fields.Add(field);

src/SharpFM.Model/Scripting/FmScript.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ public static FmScript FromXml(string xml)
6060

6161
// --- Serialize to FM XML ---
6262

63-
public string ToXml()
63+
/// <summary>
64+
/// Build the script's XML as an <see cref="XElement"/> directly, skipping
65+
/// the <c>ToString</c> + pretty-print round-trip <see cref="ToXml"/> does.
66+
/// Use this when the caller needs an element to walk (e.g. round-trip
67+
/// diffing) rather than a serialised string.
68+
/// </summary>
69+
public XElement ToElement()
6470
{
6571
var root = new XElement("fmxmlsnippet", new XAttribute("type", "FMObjectList"));
6672

@@ -77,9 +83,11 @@ public string ToXml()
7783
root.Add(step.ToXml());
7884
}
7985

80-
return XmlHelpers.PrettyPrint(root.ToString());
86+
return root;
8187
}
8288

89+
public string ToXml() => XmlHelpers.PrettyPrint(ToElement().ToString());
90+
8391
// --- Render to display text ---
8492

8593
public string ToDisplayText()

src/SharpFM/App.axaml.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
using Avalonia;
55
using Avalonia.Controls.ApplicationLifetimes;
66
using Avalonia.Markup.Xaml;
7-
using SharpFM.Model.ClipTypes;
87
using SharpFM.Model.Scripting.Registry;
98
using SharpFM.Models;
109
using SharpFM.Plugin;
@@ -33,11 +32,6 @@ public override void OnFrameworkInitializationCompleted()
3332
// same initialization runs lazily on first registry access.
3433
StepRegistry.Initialize();
3534

36-
// Clip-type strategies are explicitly registered (no reflection); do
37-
// it once at startup so paste / file load / plugin push all see the
38-
// built-in formats from the first request onward.
39-
ClipTypeRegistry.RegisterBuiltIns();
40-
4135
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
4236
{
4337
desktop.MainWindow = new MainWindow();

src/SharpFM/Editors/FallbackXmlEditor.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using AvaloniaEdit.Document;
3+
using SharpFM.Model.Parsing;
34

45
namespace SharpFM.Editors;
56

@@ -27,4 +28,6 @@ public FallbackXmlEditor(string? xml)
2728
}
2829

2930
public string ToXml() => Document.Text;
31+
32+
public ClipModel GetModel() => new OpaqueClipModel(Document.Text);
3033
}

0 commit comments

Comments
 (0)