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
87 changes: 87 additions & 0 deletions src/SharpFM.Model/Parsing/ClipParseLocationResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using System.Xml.Linq;

namespace SharpFM.Model.Parsing;

/// <summary>
/// Resolves a <see cref="ClipParseDiagnostic.Location"/> path back to the XML
/// it points at, so a diagnostic can show its actual context. Walks the same
/// <c>/Root/Child[n]/@Attr</c> grammar <see cref="XmlRoundTripDiff"/> emits
/// against a freshly parsed copy of the clip's current XML.
/// </summary>
public static class ClipParseLocationResolver
{
private const string NotFound = "(not found in current XML)";
private const int MaxLength = 300;

private static readonly Regex IndexedSegment = new(
@"^(?<name>[^\[\]/@]+)\[(?<index>\d+)\]$",
RegexOptions.Compiled);

/// <summary>
/// Resolve <paramref name="location"/> against <paramref name="rawXml"/>.
/// Never throws: malformed XML, a path whose root name doesn't match, or a
/// segment that no longer resolves (e.g. it named something only present
/// in the round-tripped output, never in the source XML this walks) all
/// fall back to a short placeholder.
/// </summary>
public static string Resolve(string rawXml, string location)
{
XElement root;
try
{
root = XElement.Parse(rawXml);
}
catch (Exception)
{
return NotFound;
}

var segments = location.TrimStart('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
if (segments.Length == 0)
{
return root.ToString().Truncate(MaxLength);
}

if (segments[0] != root.Name.LocalName)
{
return NotFound;
}

var current = root;
for (var i = 1; i < segments.Length; i++)
{
var segment = segments[i];

if (segment.StartsWith('@'))
{
var attr = current.Attribute(segment[1..]);
return attr is null ? NotFound : $"{attr.Name.LocalName}=\"{attr.Value}\"".Truncate(MaxLength);
}

var match = IndexedSegment.Match(segment);
if (!match.Success)
{
return NotFound;
}

var name = match.Groups["name"].Value;
var index = int.Parse(match.Groups["index"].Value);
var candidate = current.Elements()
.Where(e => e.Name.LocalName == name)
.Skip(index - 1)
.FirstOrDefault();

if (candidate is null)
{
return NotFound;
}

current = candidate;
}

return current.ToString().Truncate(MaxLength);
}
}
12 changes: 12 additions & 0 deletions src/SharpFM.Model/Parsing/ClipParseReport.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;

namespace SharpFM.Model.Parsing;

Expand Down Expand Up @@ -27,4 +28,15 @@ public sealed record ClipParseReport(IReadOnlyList<ClipParseDiagnostic> Diagnost

/// <summary>True if no semantic validators flagged the parsed model.</summary>
public bool IsSemanticallyValid => SemanticDiagnostics.Count == 0;

/// <summary>
/// Most severe diagnostic across both <see cref="Diagnostics"/> and
/// <see cref="SemanticDiagnostics"/>, or null when there are none. Relies
/// on <see cref="ParseDiagnosticSeverity"/>'s declared order (see that
/// enum's doc comment) so the numerically smallest value is the worst;
/// <see cref="Enumerable.Min{T}(IEnumerable{T})"/> on a nullable sequence
/// returns null for an empty sequence, so no separate empty-check is needed.
/// </summary>
public ParseDiagnosticSeverity? HighestSeverity =>
Diagnostics.Concat(SemanticDiagnostics).Select(d => (ParseDiagnosticSeverity?)d.Severity).Min();
}
6 changes: 6 additions & 0 deletions src/SharpFM.Model/Parsing/ParseDiagnosticSeverity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ namespace SharpFM.Model.Parsing;
/// <see cref="SharpFM.Model.Scripting.DiagnosticSeverity"/>: that type belongs to
/// the display-text validator (display→XML), this one to XML→domain parse fidelity.
/// </summary>
/// <remarks>
/// Declared worst-to-least-severe on purpose: <see cref="ClipParseReport.HighestSeverity"/>
/// and the Problems panel's sort/breakdown (<c>ProblemsPanelViewModel</c>) both
/// rely on the ordinal — the numerically smallest value wins. Inserting a new
/// value out of severity order, or reordering these, will silently change both.
/// </remarks>
public enum ParseDiagnosticSeverity
{
Error,
Expand Down
23 changes: 23 additions & 0 deletions src/SharpFM.Model/Parsing/ParseDiagnosticSeverityExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace SharpFM.Model.Parsing;

/// <summary>
/// Human-readable labels for <see cref="ParseDiagnosticSeverity"/>. Lives next
/// to the enum so any consumer surfacing a severity breakdown (Problems panel
/// header, future MCP tools) shares the same wording. Mirrors
/// <see cref="ParseDiagnosticKindExtensions"/>'s convention for the sibling
/// <see cref="ParseDiagnosticKind"/> enum.
/// </summary>
public static class ParseDiagnosticSeverityExtensions
{
/// <summary>
/// Format <paramref name="severity"/> as a noun phrase, choosing the
/// singular form when <paramref name="count"/> is 1 and the plural
/// otherwise. <c>Info</c> doesn't pluralize.
/// </summary>
public static string Noun(this ParseDiagnosticSeverity severity, int count) => severity switch
{
ParseDiagnosticSeverity.Error => count == 1 ? "error" : "errors",
ParseDiagnosticSeverity.Warning => count == 1 ? "warning" : "warnings",
_ => "info",
};
}
5 changes: 2 additions & 3 deletions src/SharpFM.Model/Parsing/XmlRoundTripDiff.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace SharpFM.Model.Parsing;
/// The comparison is name-based and order-insensitive within a parent (an
/// out-of-order rewrite of children is not flagged as a loss); attribute order
/// is ignored; element-text is compared trimmed (whitespace-only differences
/// are noise from pretty-printing). Differences are categorised by parent
/// are noise from pretty-printing). Differences are categorized by parent
/// element so consumers see <see cref="ParseDiagnosticKind.UnknownStepElement"/>
/// vs. <see cref="ParseDiagnosticKind.UnknownClipElement"/> rather than a flat
/// "something differs" stream.
Expand Down Expand Up @@ -170,6 +170,5 @@ private static ParseDiagnosticKind KindForUnmodeledAttribute(string parentLocalN

private const int TruncateLength = 60;

private static string Truncate(string s) =>
s.Length <= TruncateLength ? s : s[..TruncateLength] + "…";
private static string Truncate(string s) => s.Truncate(TruncateLength);
}
12 changes: 12 additions & 0 deletions src/SharpFM.Model/Parsing/XmlTextTruncation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace SharpFM.Model.Parsing;

/// <summary>
/// Shared truncation for XML text/attribute values surfaced in diagnostic
/// messages or resolved snippets, so a large value doesn't blow up a message
/// or the Problems panel's detail pane.
/// </summary>
internal static class XmlTextTruncation
{
public static string Truncate(this string s, int maxLength) =>
s.Length <= maxLength ? s : s[..maxLength] + "…";
}
9 changes: 8 additions & 1 deletion src/SharpFM.Model/Validation/IClipSemanticValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ public interface IClipSemanticValidator
/// </summary>
IReadOnlyCollection<string> FormatIds { get; }

/// <summary>Return any domain-rule violations found in <paramref name="model"/>.</summary>
/// <summary>
/// Return any domain-rule violations found in <paramref name="model"/>.
/// Each diagnostic's <see cref="ClipParseDiagnostic.Location"/> must use
/// the same XPath-style grammar <see cref="Parsing.XmlRoundTripDiff"/>
/// emits (<c>/Root/Child[n]/@Attr</c>) — the Problems panel resolves it
/// back to XML via <see cref="Parsing.ClipParseLocationResolver"/>, which
/// only understands that grammar.
/// </summary>
IReadOnlyList<ClipParseDiagnostic> Validate(ClipModel model);
}
118 changes: 103 additions & 15 deletions src/SharpFM/MainWindow.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<KeyBinding Command="{Binding NewScriptCommand}" Gesture="Ctrl+N" />
<KeyBinding Command="{Binding NewTableCommand}" Gesture="Ctrl+Shift+N" />
<KeyBinding Command="{Binding CopySelectedToClip}" Gesture="Ctrl+Shift+C" />
<KeyBinding Command="{Binding ToggleProblemsPanel}" Gesture="Ctrl+Shift+M" />
<!--
Ctrl+V is wired in code-behind via a tunnel-phase preview
handler so it can defer to the focused text editor (TextBox /
Expand Down Expand Up @@ -69,6 +70,12 @@
<MenuItem Command="{Binding RenameSelectedClip}" Header="Rename..." />
<MenuItem Command="{Binding DeleteSelectedClip}" Header="Delete Clip" />
</MenuItem>
<MenuItem Header="_View">
<MenuItem
Command="{Binding ToggleProblemsPanel}"
Header="Problems"
InputGesture="Ctrl+Shift+M" />
</MenuItem>
<MenuItem Header="_Tools">
<MenuItem x:Name="rawClipboardMenuItem" Header="Raw Clipboard Viewer..." />
</MenuItem>
Expand All @@ -92,21 +99,26 @@
Classes="Fluent2Caption"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Text="{Binding StatusMessage}" />
<StackPanel
<Button
HorizontalAlignment="Center"
Padding="0"
Background="Transparent"
BorderThickness="0"
Command="{Binding ToggleProblemsPanel}"
IsVisible="{Binding ParseFidelityVisible}"
Orientation="Horizontal"
Spacing="6">
<TextBlock
Classes="Fluent2Caption"
Foreground="DarkOrange"
IsVisible="{Binding !ParseFidelityIsLossless}"
Text="!" />
<TextBlock
Classes="Fluent2Caption"
Opacity="0.7"
Text="{Binding ParseFidelitySummary}" />
</StackPanel>
ToolTip.Tip="Show the Problems panel (Ctrl+Shift+M)">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock
Classes="Fluent2Caption"
Foreground="{Binding ParseFidelityBrush}"
IsVisible="{Binding !ParseFidelityIsLossless}"
Text="{Binding ParseFidelityGlyph}" />
<TextBlock
Classes="Fluent2Caption"
Opacity="0.7"
Text="{Binding ParseFidelitySummary}" />
</StackPanel>
</Button>
<TextBlock
HorizontalAlignment="Right"
Classes="Fluent2Caption"
Expand All @@ -115,6 +127,82 @@
</Grid>
</Border>

<!-- Problems panel: VS Code-style bottom dock listing every parse diagnostic for the selected clip. -->
<Border
x:Name="problemsPanelBorder"
Height="220"
Padding="16,8"
Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderBrush="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
BorderThickness="0,1,0,0"
DockPanel.Dock="Bottom"
IsVisible="{Binding ProblemsPanel.IsPanelVisible}">
<Grid RowDefinitions="Auto,*,Auto">
<DockPanel Grid.Row="0" Margin="0,0,0,8">
<Button
DockPanel.Dock="Right"
Padding="6,2"
Background="Transparent"
BorderThickness="0"
Command="{Binding ToggleProblemsPanel}"
Content="✕" />
<TextBlock Classes="Fluent2Subtitle" Text="{Binding ProblemsPanel.HeaderText}" />
</DockPanel>

<ListBox
Grid.Row="1"
ItemsSource="{Binding ProblemsPanel.Diagnostics}"
SelectedItem="{Binding ProblemsPanel.SelectedDiagnostic, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate DataType="vm:ProblemRow">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
Width="16"
FontWeight="Bold"
Foreground="{Binding SeverityBrush}"
Text="{Binding SeverityGlyph}" />
<TextBlock VerticalAlignment="Center" Text="{Binding KindLabel}" />
<TextBlock
VerticalAlignment="Center"
Classes="Fluent2Caption"
IsVisible="{Binding IsSemantic}"
Opacity="0.6"
Text="(semantic)" />
<TextBlock
VerticalAlignment="Center"
Text="{Binding Message}"
TextTrimming="CharacterEllipsis" />
<TextBlock
VerticalAlignment="Center"
Classes="Fluent2Caption"
Opacity="0.6"
Text="{Binding Location}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>

<Border
Grid.Row="2"
Margin="0,8,0,0"
Padding="8"
Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
IsVisible="{Binding ProblemsPanel.SelectedDiagnostic, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Spacing="4">
<TextBlock Classes="Fluent2Caption" Text="{Binding ProblemsPanel.SelectedDiagnostic.Message}" TextWrapping="Wrap" />
<TextBlock
Classes="Fluent2Caption"
Opacity="0.7"
Text="{Binding ProblemsPanel.SelectedDiagnostic.Location}" />
<TextBlock
FontFamily="Consolas,monospace"
Text="{Binding ProblemsPanel.SelectedXmlSnippet}"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</Grid>
</Border>

<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
Expand Down Expand Up @@ -246,9 +334,9 @@
Text="{Binding Clip.ClipTypeDisplay}" />
<TextBlock
Classes="Fluent2Caption"
Foreground="DarkOrange"
Foreground="{Binding Clip.FidelityBrush}"
IsVisible="{Binding !Clip.IsLossless}"
Text="!"
Text="{Binding Clip.FidelityGlyph}"
ToolTip.Tip="This clip's XML did not round-trip cleanly through the domain model. See the status bar for details when selected." />
</StackPanel>
</StackPanel>
Expand Down
18 changes: 16 additions & 2 deletions src/SharpFM/ViewModels/ClipViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Avalonia.Controls;
using Avalonia.Media;
using AvaloniaEdit.Document;
using SharpFM.Editors;
using SharpFM.Model;
Expand Down Expand Up @@ -104,14 +105,28 @@ public void Replace(string xml)
NotifyPropertyChanged(nameof(ScriptDocument));
NotifyPropertyChanged(nameof(TableEditor));
NotifyPropertyChanged(nameof(XmlDocument));
NotifyFidelityChanged();
}

private void NotifyFidelityChanged()
{
NotifyPropertyChanged(nameof(ParseReport));
NotifyPropertyChanged(nameof(IsLossless));
NotifyPropertyChanged(nameof(HighestSeverity));
NotifyPropertyChanged(nameof(FidelityGlyph));
NotifyPropertyChanged(nameof(FidelityBrush));
}

public ClipParseReport ParseReport => _clip.Parsed.Report;

public bool IsLossless => ParseReport.IsLossless;

public ParseDiagnosticSeverity? HighestSeverity => ParseReport.HighestSeverity;

public string FidelityGlyph => HighestSeverity?.Glyph() ?? "";

public IBrush? FidelityBrush => HighestSeverity?.Brush();

/// <summary>Captures the current XML as the saved baseline; clears the dirty indicator.</summary>
public void MarkSaved()
{
Expand Down Expand Up @@ -139,8 +154,7 @@ internal void HandleEditorContentChanged()
var model = Editor.GetModel();
Clip = Clip.FromEditor(_clip.Name, _clip.FormatId, xml, model);
NotifyPropertyChanged(nameof(IsDirty));
NotifyPropertyChanged(nameof(ParseReport));
NotifyPropertyChanged(nameof(IsLossless));
NotifyFidelityChanged();
EditorContentChanged?.Invoke(this, EventArgs.Empty);
}

Expand Down
Loading
Loading