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);