Added a validation to the context menu, that might or might not work
This commit is contained in:
parent
f8fb0a431f
commit
e56b76f3de
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
213
XSDVisualiser/Utils/XmlValidation.cs
Normal file
213
XSDVisualiser/Utils/XmlValidation.cs
Normal 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);
|
||||
Loading…
x
Reference in New Issue
Block a user