diff --git a/XSDVisualiser.Desktop/Views/LeftTreeView.axaml b/XSDVisualiser.Desktop/Views/LeftTreeView.axaml
index 0bea34e..e5022ee 100644
--- a/XSDVisualiser.Desktop/Views/LeftTreeView.axaml
+++ b/XSDVisualiser.Desktop/Views/LeftTreeView.axaml
@@ -38,6 +38,7 @@
+
diff --git a/XSDVisualiser.Desktop/Views/LeftTreeView.axaml.cs b/XSDVisualiser.Desktop/Views/LeftTreeView.axaml.cs
index 9108e0a..6a6382f 100644
--- a/XSDVisualiser.Desktop/Views/LeftTreeView.axaml.cs
+++ b/XSDVisualiser.Desktop/Views/LeftTreeView.axaml.cs
@@ -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().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
+ {
+ new FilePickerFileType("XML files") { Patterns = new List { "*.xml" }, MimeTypes = new List{"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 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;
diff --git a/XSDVisualiser/Utils/XmlValidation.cs b/XSDVisualiser/Utils/XmlValidation.cs
new file mode 100644
index 0000000..dfa8f28
--- /dev/null
+++ b/XSDVisualiser/Utils/XmlValidation.cs
@@ -0,0 +1,213 @@
+using System.Xml;
+using System.Xml.Schema;
+
+namespace XSDVisualiser.Core;
+
+///
+/// Validates an XML document against a compiled XSD schema set and a specific global element (node).
+///
+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 _issues = new();
+
+ public bool IsValid => _issues.TrueForAll(i => i.Severity != XmlSeverityType.Error);
+
+ public IReadOnlyList Issues => _issues;
+
+ public IEnumerable Errors => _issues.Where(i => i.Severity == XmlSeverityType.Error);
+ public IEnumerable 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);