Added a validation to the context menu, that might or might not work

This commit is contained in:
Frederik Jacobsen 2025-10-18 22:56:58 +02:00
parent f8fb0a431f
commit e56b76f3de
3 changed files with 353 additions and 0 deletions

View File

@ -38,6 +38,7 @@
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="Copy as TSV" Click="OnCopyAsTsvClick" />
<MenuItem Header="Validate XML against this element…" Click="OnValidateAgainstXmlClick" />
</ContextMenu>
</Border.ContextMenu>
<StackPanel Orientation="Vertical" Spacing="2">

View File

@ -1,9 +1,14 @@
using System.Globalization;
using System.Text;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using XSDVisualiser.Core;
using XSDVisualiser.Desktop.ViewModels;
namespace XSDVisualiser.Desktop.Views;
@ -30,6 +35,61 @@ public partial class LeftTreeView : UserControl
if (topLevel?.Clipboard != null) await topLevel.Clipboard.SetTextAsync(tsv);
}
private async void OnValidateAgainstXmlClick(object? sender, RoutedEventArgs e)
{
// Resolve node from context
var node = (sender as MenuItem)?.DataContext as SchemaNode;
if (node == null)
node = (sender as Control)?.GetVisualAncestors().OfType<Control>().FirstOrDefault()?.DataContext as SchemaNode;
if (node == null)
return;
var vm = DataContext as MainWindowViewModel;
if (vm == null || string.IsNullOrEmpty(vm.CurrentFilePath))
{
await ShowTextDialogAsync("Validate XML", "No XSD file is currently loaded. Please open an XSD file first.");
return;
}
var top = TopLevel.GetTopLevel(this);
var sp = top?.StorageProvider;
if (sp == null)
return;
var options = new FilePickerOpenOptions
{
Title = "Select XML file to validate",
AllowMultiple = false,
FileTypeFilter = new List<FilePickerFileType>
{
new FilePickerFileType("XML files") { Patterns = new List<string> { "*.xml" }, MimeTypes = new List<string>{"application/xml","text/xml"} },
FilePickerFileTypes.All
}
};
var files = await sp.OpenFilePickerAsync(options);
var file = files?.FirstOrDefault();
if (file == null) return;
var xmlPath = await TryGetLocalPathAsync(file);
if (string.IsNullOrEmpty(xmlPath))
{
await ShowTextDialogAsync("Validate XML", "The selected file is not accessible via a local path. Please choose a local XML file.");
return;
}
if (string.IsNullOrEmpty(node.Name))
{
await ShowTextDialogAsync("Validate XML", "Selected schema node has no name and cannot be validated.");
return;
}
var result = XmlValidator.ValidateAgainstElement(vm.CurrentFilePath!, node.Name!, node.Namespace, xmlPath!);
var summary = BuildValidationSummary(node, xmlPath!, result);
await ShowTextDialogAsync("Validation Result", summary);
}
private static string BuildTsv(SchemaNode root)
{
var sb = new StringBuilder();
@ -81,6 +141,85 @@ public partial class LeftTreeView : UserControl
}
}
private static async Task<string?> TryGetLocalPathAsync(IStorageFile file)
{
// Avalonia provides TryGetLocalPath extension for local files; otherwise, we can't access it via System.IO API.
return file.TryGetLocalPath();
}
private static string BuildValidationSummary(SchemaNode node, string xmlPath, XmlValidationResult result)
{
var sb = new StringBuilder();
sb.AppendLine($"Element: {{{node.Namespace}}}{node.Name}");
sb.AppendLine($"XML: {xmlPath}");
sb.AppendLine($"Valid: {(result.IsValid ? "Yes" : "No")}");
var errors = result.Errors.ToList();
var warnings = result.Warnings.ToList();
sb.AppendLine($"Errors: {errors.Count}, Warnings: {warnings.Count}");
sb.AppendLine();
foreach (var issue in result.Issues)
{
var prefix = issue.Severity == System.Xml.Schema.XmlSeverityType.Error ? "Error" : "Warning";
var loc = (issue.LineNumber > 0 || issue.LinePosition > 0) ? $" (Line {issue.LineNumber}, Pos {issue.LinePosition})" : string.Empty;
sb.AppendLine($"- {prefix}{loc}: {issue.Message}");
}
return sb.ToString();
}
private async Task ShowTextDialogAsync(string title, string text)
{
var dialog = new Window
{
Title = title,
Width = 700,
Height = 500,
CanResize = true,
Content = new Grid
{
RowDefinitions = new RowDefinitions("*,Auto")
}
};
// Compose content
if (dialog.Content is Grid grid)
{
var textBlock = new TextBlock
{
Text = text,
TextWrapping = Avalonia.Media.TextWrapping.Wrap,
Margin = new Thickness(12)
};
var scroller = new ScrollViewer { Content = textBlock };
var closeButton = new Button
{
Content = "Close",
MinWidth = 80,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(8)
};
closeButton.Click += (_, _) => dialog.Close();
var buttonsPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right
};
buttonsPanel.Children.Add(closeButton);
Grid.SetRow(buttonsPanel, 1);
grid.Children.Add(scroller);
grid.Children.Add(buttonsPanel);
}
var top = TopLevel.GetTopLevel(this);
if (top is Window owner)
await dialog.ShowDialog(owner);
else
dialog.Show();
}
private static string FormatConstraints(SchemaNode node)
{
var cons = node.Constraints?.Constraints;

View File

@ -0,0 +1,213 @@
using System.Xml;
using System.Xml.Schema;
namespace XSDVisualiser.Core;
/// <summary>
/// Validates an XML document against a compiled XSD schema set and a specific global element (node).
/// </summary>
public static class XmlValidator
{
public static XmlValidationResult ValidateAgainstElement(string xsdPath, string elementName, string? elementNamespace, string xmlPath)
{
var set = BuildSchemaSet(xsdPath);
return ValidateAgainstElement(set, elementName, elementNamespace, xmlPath);
}
public static XmlValidationResult ValidateAgainstElement(XmlSchemaSet schemas, string elementName, string? elementNamespace, string xmlPath)
{
var result = new XmlValidationResult();
// Ensure the requested element exists in the schema set
var qname = new XmlQualifiedName(elementName, elementNamespace ?? string.Empty);
if (schemas.GlobalElements[qname] is not XmlSchemaElement)
{
result.AddError($"Element '{qname}' was not found in the compiled schema set.");
return result;
}
// Probe XML root element
(string localName, string nsUri)? rootInfo = TryReadRoot(xmlPath);
if (rootInfo is null)
{
result.AddError("Unable to read XML root element.");
return result;
}
var (rootLocal, rootNs) = rootInfo.Value;
var matchesRoot = string.Equals(rootLocal, elementName, StringComparison.Ordinal) && string.Equals(rootNs ?? string.Empty, elementNamespace ?? string.Empty, StringComparison.Ordinal);
var settings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Ignore,
ValidationType = ValidationType.Schema,
Schemas = schemas,
CloseInput = true,
ConformanceLevel = ConformanceLevel.Auto
};
settings.ValidationFlags = XmlSchemaValidationFlags.ReportValidationWarnings | XmlSchemaValidationFlags.ProcessIdentityConstraints;
void Handler(object? sender, ValidationEventArgs e)
{
if (e.Severity == XmlSeverityType.Warning)
result.AddWarning(e.Message, e.Exception?.LineNumber, e.Exception?.LinePosition);
else
result.AddError(e.Message, e.Exception?.LineNumber, e.Exception?.LinePosition);
}
settings.ValidationEventHandler += Handler;
if (matchesRoot)
{
using var reader = XmlReader.Create(xmlPath, settings);
try
{
while (reader.Read())
{
// just advance to trigger validation callbacks
}
}
catch (XmlException xe)
{
result.AddError($"XML parsing error: {xe.Message}", xe.LineNumber, xe.LinePosition);
}
return result;
}
else
{
// Root does not match the selected schema element. Try to locate the first matching subtree and validate only that fragment.
// This enables validating an XML file towards a selected node from the XSD.
var fragmentSettings = new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Ignore,
ValidationType = ValidationType.Schema,
Schemas = schemas,
CloseInput = true,
ConformanceLevel = ConformanceLevel.Fragment
};
fragmentSettings.ValidationFlags = settings.ValidationFlags;
fragmentSettings.ValidationEventHandler += Handler;
try
{
var (elementNode, loadError) = FindFirstElementNode(xmlPath, elementName, elementNamespace);
if (loadError is not null)
{
result.AddError(loadError.Value.Message, loadError.Value.LineNumber, loadError.Value.LinePosition);
return result;
}
if (elementNode is null)
{
result.AddError($"Could not find any element '{{{elementNamespace}}}{elementName}' in the XML document to validate against.");
return result;
}
// Inform as a warning that we validate a subtree instead of the document root
result.AddWarning($"Validating against the first occurrence of '{{{elementNamespace}}}{elementName}' found in the document (root is '{{{rootNs}}}{rootLocal}').");
using var nodeReader = new XmlNodeReader(elementNode);
using var validatingReader = XmlReader.Create(nodeReader, fragmentSettings);
while (validatingReader.Read())
{
// advance to trigger validation callbacks for the subtree
}
}
catch (XmlException xe)
{
result.AddError($"XML parsing error: {xe.Message}", xe.LineNumber, xe.LinePosition);
}
return result;
}
}
private static XmlSchemaSet BuildSchemaSet(string xsdPath)
{
var set = new XmlSchemaSet
{
XmlResolver = new XmlUrlResolver(),
CompilationSettings = new XmlSchemaCompilationSettings { EnableUpaCheck = true }
};
using var reader = XmlReader.Create(xsdPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore });
var schema = XmlSchema.Read(reader, null);
if (schema != null)
set.Add(schema);
set.Compile();
return set;
}
private static (string localName, string nsUri)? TryReadRoot(string xmlPath)
{
using var reader = XmlReader.Create(xmlPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore, IgnoreWhitespace = true, IgnoreComments = true });
try
{
while (reader.Read())
{
if (reader.NodeType == XmlNodeType.Element && reader.Depth == 0)
{
return (reader.LocalName, reader.NamespaceURI);
}
}
}
catch
{
// ignored; higher level will report XmlException separately
}
return null;
}
private static (XmlElement? Node, (string Message, int LineNumber, int LinePosition)? LoadError) FindFirstElementNode(string xmlPath, string elementName, string? elementNamespace)
{
try
{
var xr = XmlReader.Create(xmlPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore });
var doc = new XmlDocument();
doc.PreserveWhitespace = false;
doc.Load(xr);
static XmlElement? Traverse(XmlNode node, string name, string? ns)
{
if (node is XmlElement el)
{
if (string.Equals(el.LocalName, name, StringComparison.Ordinal) && string.Equals(el.NamespaceURI ?? string.Empty, ns ?? string.Empty, StringComparison.Ordinal))
return el;
}
foreach (XmlNode child in node.ChildNodes)
{
var found = Traverse(child, name, ns);
if (found != null) return found;
}
return null;
}
var match = Traverse(doc, elementName, elementNamespace);
return (match, null);
}
catch (XmlException xe)
{
return (null, ($"XML parsing error: {xe.Message}", xe.LineNumber, xe.LinePosition));
}
}
}
public sealed class XmlValidationResult
{
private readonly List<XmlValidationIssue> _issues = new();
public bool IsValid => _issues.TrueForAll(i => i.Severity != XmlSeverityType.Error);
public IReadOnlyList<XmlValidationIssue> Issues => _issues;
public IEnumerable<XmlValidationIssue> Errors => _issues.Where(i => i.Severity == XmlSeverityType.Error);
public IEnumerable<XmlValidationIssue> Warnings => _issues.Where(i => i.Severity == XmlSeverityType.Warning);
internal void AddError(string message, int? line = null, int? position = null) =>
_issues.Add(new XmlValidationIssue(XmlSeverityType.Error, message, line ?? 0, position ?? 0));
internal void AddWarning(string message, int? line = null, int? position = null) =>
_issues.Add(new XmlValidationIssue(XmlSeverityType.Warning, message, line ?? 0, position ?? 0));
}
public sealed record XmlValidationIssue(XmlSeverityType Severity, string Message, int LineNumber, int LinePosition);