252 lines
11 KiB
C#
252 lines
11 KiB
C#
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();
|
|
|
|
// Probe XML root element first, we may use its namespace as a hint
|
|
(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;
|
|
|
|
// Try to ensure the requested element exists in the schema set; if not, try to infer the correct namespace instead of failing hard.
|
|
var qname = new XmlQualifiedName(elementName, elementNamespace ?? string.Empty);
|
|
if (schemas.GlobalElements[qname] is not XmlSchemaElement)
|
|
{
|
|
// Try to find candidates with the same local name across namespaces
|
|
var candidates = schemas.GlobalElements.Names.Cast<XmlQualifiedName>().Where(n => string.Equals(n.Name, elementName, StringComparison.Ordinal)).Distinct().ToList();
|
|
if (candidates.Count == 1)
|
|
{
|
|
elementNamespace = candidates[0].Namespace;
|
|
qname = new XmlQualifiedName(elementName, elementNamespace ?? string.Empty);
|
|
result.AddWarning($"Element '{{{qname.Namespace}}}{qname.Name}' was not found with the provided namespace. Using detected namespace '{candidates[0].Namespace}'.");
|
|
}
|
|
else if (candidates.Count > 1)
|
|
{
|
|
// Prefer a candidate matching the XML root namespace if any
|
|
var preferred = candidates.FirstOrDefault(c => string.Equals(c.Namespace ?? string.Empty, rootNs ?? string.Empty, StringComparison.Ordinal));
|
|
if (preferred != null)
|
|
{
|
|
elementNamespace = preferred.Namespace;
|
|
qname = new XmlQualifiedName(elementName, elementNamespace ?? string.Empty);
|
|
result.AddWarning($"Element namespace adjusted to match XML root namespace: '{{{preferred.Namespace}}}{preferred.Name}'.");
|
|
}
|
|
else
|
|
{
|
|
var list = string.Join(", ", candidates.Select(c => $"'{{{c.Namespace}}}{c.Name}'"));
|
|
result.AddWarning($"Element '{{{qname.Namespace}}}{qname.Name}' was not found in the compiled schema set. Candidates by name: {list}. Proceeding with best-effort validation.");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// No candidates at all; continue and let the validator report more actionable errors.
|
|
result.AddWarning($"Element '{{{qname.Namespace}}}{qname.Name}' was not found in the compiled schema set. Proceeding with best-effort validation.");
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
// Try again ignoring namespace, in case the provided namespace was incorrect or omitted
|
|
var retry = FindFirstElementNode(xmlPath, elementName, null).Node;
|
|
if (retry is not null)
|
|
{
|
|
result.AddWarning($"Could not find element '{{{elementNamespace}}}{elementName}' with the specified namespace; validating first occurrence by local name only.");
|
|
elementNode = retry;
|
|
}
|
|
else
|
|
{
|
|
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);
|