diff --git a/.gitignore b/.gitignore index c31ed73..b9e9bc6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ [Tt]est[Rr]esult*/ *.nupkg *.snupkg +BenchmarkDotNet.Artifacts/ diff --git a/AGENTS.md b/AGENTS.md index 5825a69..4187489 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,8 +66,12 @@ init-only record: `Display`, `Strict`, `PreserveFormatting`, `LaTeXFlavor` stand (no code generated at run time, so the library runs under Native AOT, `IsAotCompatible` on net10.0): the XPath data model over LINQ to XML (`XPathFunctions`), the result tree (`XsltOutput`), and the partial classes the generator implements (`Stylesheets.cs`; the generated code goes to `obj/`, and is declared there so that Parlot, which compiles - the project without the other generators, sees it). `Xslt/CompiledStylesheetTests` compares them with - `XslCompiledTransform` on the corpora. + the project without the other generators, sees it). The generated code is static: a location path is loops over the + nodes, in a method whose parameters are what it reads, and builds a list only where one is needed; the parameters of + the named templates are typed by the values the calls give them, which the generator learns by compiling the + stylesheet until nothing new is learnt (`Facts`). `Xslt/CompiledStylesheetTests` compares the stylesheets with + `XslCompiledTransform` on the corpora, and the generator on `Xslt/conformance.xsl`; `StylesheetBenchmarks` measures + them against it. - `Internal/Mtef/`: MTEF of Equation Editor 3.0 and MathType, read only; a C# port of the jure/mathtype gem, converted with `mtef2mml.xsl`. It reads the "Equation Native" stream of the OLE object; opening the OLE object is the caller's. - `Internal/UnicodeMath/`: a port of UnicodeMathML. Its PEG grammar is `UnicodeMathGrammar.parlot.cs`, compiled at diff --git a/benchmarks/SharpEquations.Benchmarks/Samples.cs b/benchmarks/SharpEquations.Benchmarks/Samples.cs index 334c81b..12c6fcf 100644 --- a/benchmarks/SharpEquations.Benchmarks/Samples.cs +++ b/benchmarks/SharpEquations.Benchmarks/Samples.cs @@ -39,6 +39,15 @@ public static string[] UnicodeMathCorpus() return json.RootElement.EnumerateArray().Select(example => example.GetProperty("input").GetString()!).ToArray(); } + public static byte[][] MtefCorpus() => + Directory.GetFiles(DataPath("mtef"), "*.*", SearchOption.AllDirectories).OrderBy(path => path, StringComparer.Ordinal).Select(File.ReadAllBytes).ToArray(); + + public static XElement[] ContentMathMLCorpus() => + Directory.GetFiles(DataPath("contentmathml"), "*.mml").OrderBy(path => path, StringComparer.Ordinal) + .SelectMany(path => XElement.Load(path, LoadOptions.PreserveWhitespace).DescendantsAndSelf().Where(e => e.Name.LocalName == "math")).ToArray(); + + public static string Stylesheet(string name) => DataPath("xsl", name); + public static XElement[] OmmlCorpus() => Directory.GetFiles(DataPath("omml"), "*.omml").OrderBy(path => path, StringComparer.Ordinal).Select(path => XElement.Load(path)).ToArray(); } diff --git a/benchmarks/SharpEquations.Benchmarks/SharpEquations.Benchmarks.csproj b/benchmarks/SharpEquations.Benchmarks/SharpEquations.Benchmarks.csproj index 04931b3..f72051f 100644 --- a/benchmarks/SharpEquations.Benchmarks/SharpEquations.Benchmarks.csproj +++ b/benchmarks/SharpEquations.Benchmarks/SharpEquations.Benchmarks.csproj @@ -19,6 +19,9 @@ + + + diff --git a/benchmarks/SharpEquations.Benchmarks/StylesheetBenchmarks.cs b/benchmarks/SharpEquations.Benchmarks/StylesheetBenchmarks.cs new file mode 100644 index 0000000..8f712b7 --- /dev/null +++ b/benchmarks/SharpEquations.Benchmarks/StylesheetBenchmarks.cs @@ -0,0 +1,159 @@ +namespace SharpEquations.Benchmarks +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Xml; + using System.Xml.Linq; + using System.Xml.Xsl; + using BenchmarkDotNet.Attributes; + using SharpEquations.Internal.Mtef; + using SharpEquations.Internal.Xslt; + + /// + /// The stylesheets alone, over the corpora of the tests: compiled to C# by SharpEquations.XsltGenerator, as the + /// library runs them, and run by XslCompiledTransform, as it ran them before (the output written as text and read + /// again). The times are per corpus. + /// + [MemoryDiagnoser] + public class StylesheetBenchmarks + { + private XElement[] omml = []; + private XElement[] mathML = []; + private XElement[] mtef = []; + private XElement[] contentMathML = []; + private XslCompiledTransform omml2mml = null!; + private XslCompiledTransform mml2omml = null!; + private XslCompiledTransform mtef2mml = null!; + private XslCompiledTransform ctop = null!; + + [GlobalSetup] + public void Setup() + { + var equations = Samples.UnicodeMathCorpus() + .Select(source => Equation.TryParse(source, EquationFormat.UnicodeMath, out var equation) ? equation : null) + .OfType() + .Concat(Samples.OmmlCorpus().Select(source => Equation.Parse(source, EquationFormat.Omml))) + .ToArray(); + mathML = equations.Select(equation => equation.ToMathML()).ToArray(); + omml = Samples.OmmlCorpus().SelectMany(source => source.DescendantsAndSelf(MathMLNamespaces.Omml + "oMath")) + .Concat(equations.Select(equation => equation.ToOmml())) + .ToArray(); + mtef = Samples.MtefCorpus().Select(Records).OfType().ToArray(); + omml2mml = Load("omml2mml.xsl"); + omml = omml.Where(oMath => Converts(omml2mml, oMath)).ToArray(); + mml2omml = Load("mml2omml.xsl"); + mtef2mml = Load("mtef2mml.xsl"); + contentMathML = Samples.ContentMathMLCorpus(); + ctop = Load("ctop.xsl"); + } + + // an equation written in the text of another is two document elements for the stylesheet, and + // XslCompiledTransform splits the characters outside the basic plane + private static bool Converts(XslCompiledTransform stylesheet, XElement oMath) + { + try + { + return Omml2mmlStylesheet.Transform(oMath) != null && Run(stylesheet, [oMath]) == 1; + } + catch (Exception e) when (e is XmlException or XsltException or ArgumentException) + { + return false; + } + } + + private static XElement? Records(byte[] bytes) + { + var data = bytes.AsSpan(); + if (MtefParser.HasEquationNativeHeader(data)) + data = data.Slice(data[0] | data[1] << 8); + try + { + var xml = MtefReader.Read(data); + MtefPreprocessor.Prepare(xml); + Mtef2mmlStylesheet.Transform(xml); + return xml; + } + catch (Exception e) when (e is not OutOfMemoryException) + { + return null; + } + } + + private static XslCompiledTransform Load(string name) + { + using var reader = XmlReader.Create(Samples.Stylesheet(name), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + var transform = new XslCompiledTransform(); + transform.Load(reader, XsltSettings.Default, null); + return transform; + } + + private static int Count(XElement? element) => element == null ? 0 : 1; + + private static int Run(XslCompiledTransform stylesheet, XElement[] inputs) + { + var count = 0; + foreach (var input in inputs) + { + var output = new StringWriter(); + using (var reader = new XDocument(input).CreateReader()) + stylesheet.Transform(reader, null, output); + var text = output.ToString(); + if (text.Trim().Length == 0) + continue; + using var result = XmlReader.Create(new StringReader(text), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + count += Count(XElement.Load(result, LoadOptions.PreserveWhitespace)); + } + return count; + } + + [Benchmark] + public int Omml2MathML() + { + var count = 0; + foreach (var input in omml) + count += Count(Omml2mmlStylesheet.Transform(input)); + return count; + } + + [Benchmark] + public int Omml2MathMLXslCompiledTransform() => Run(omml2mml, omml); + + [Benchmark] + public int MathML2Omml() + { + var count = 0; + foreach (var input in mathML) + count += Count(Mml2ommlStylesheet.Transform(input)); + return count; + } + + [Benchmark] + public int MathML2OmmlXslCompiledTransform() => Run(mml2omml, mathML); + + [Benchmark] + public int Mtef2MathML() + { + var count = 0; + foreach (var input in mtef) + count += Count(Mtef2mmlStylesheet.Transform(input)); + return count; + } + + [Benchmark] + public int Mtef2MathMLXslCompiledTransform() => Run(mtef2mml, mtef); + + [Benchmark] + public int ContentMathML2MathML() + { + var count = 0; + foreach (var input in contentMathML) + count += Count(CtopStylesheet.Transform(input)); + return count; + } + + [Benchmark] + public int ContentMathML2MathMLXslCompiledTransform() => Run(ctop, contentMathML); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0dab68c..f6604d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -80,29 +80,49 @@ caught, so `EquationStylesheets` rejects input nested more than 512 levels deep ## The stylesheets compiled to C# (`src/SharpEquations.XsltGenerator/`, `Internal/Xslt/`) -The three XSLT 1.0 stylesheets of `Resources/` (`omml2mml.xsl`, `mml2omml.xsl`, `mtef2mml.xsl`) are compiled to C# at -build time by `SharpEquations.XsltGenerator`, a source generator that reads them as additional files. Nothing is +The XSLT 1.0 stylesheets of `Resources/` (`omml2mml.xsl`, `mml2omml.xsl`, `mtef2mml.xsl`, `ctop.xsl`) are compiled to +C# at build time by `SharpEquations.XsltGenerator`, a source generator that reads them as additional files. Nothing is generated at run time, unlike `XslCompiledTransform`, which emits IL: so the library runs under Native AOT, and the stylesheets are not embedded in it. - Each stylesheet becomes a class named after its file (`Omml2mmlStylesheet`), declared partial in `Internal/Xslt/Stylesheets.cs`, with a method for each template, a lazy property for each global variable and, for each mode, the dispatch of `xsl:apply-templates`: by the name of the element first, then the rules in the order of - XSLT (highest priority, then the last in the stylesheet), then the built-in templates. -- Each XPath expression becomes a C# expression over `XPathFunctions` (the data model of XPath over LINQ to XML, its - functions and conversions). Its type is known when it can be (a string, a number, a boolean, a node-set, a result - tree fragment), and is `object` otherwise (a parameter). + XSLT (highest priority, then the last in the stylesheet), then the built-in templates. The many rules of an element + whose pattern asks for a child with a text (`char[mt_code_value='0x2211' and ...]` of `mtef2mml.xsl`) are found by a + switch on the text of that child. +- A location path becomes loops over the nodes (first child and next sibling, the node test inlined, the names + compared as static `XName` fields), written where the path is used (`xsl:for-each`, `xsl:apply-templates`) or in a + method of its own, whose parameters are what the expression reads: the context node, the variables. What uses the + path tells the loop: a test stops at the first node, a string is the value of the first node, a comparison stops at + the node that compares so, and a list of the nodes is built only for a variable of more than one node, or to sort + the nodes when the loops do not reach them in document order. `position()` and `last()` are counted only for the + predicates, the contents and the templates that read them. +- The type of an expression is known when it can be: a string, a number, a boolean, a node-set (the node, or null, + when it has at most one; a list otherwise), a result tree fragment (a string, when its content only writes text). + The parameters of a named template have the type of the values the calls give them, and are `object` only when the + calls differ. For this, and to know which templates read the context node, its position or the number of nodes, + which only write text and which do not depend on the input, the generator compiles the stylesheet again with what + the compilation before learnt (`Facts`), until nothing new is learnt: every fact starts as the one that gives the + best code and only moves to the one that is always right. +- A global variable that is a string and does not depend on the input is computed once, not for each equation; the + map of a `translate()` is built once. - `XsltOutput` builds the result as LINQ to XML, as `XslCompiledTransform` gave it when its output was written as text and parsed again: text written with `disable-output-escaping` is markup, with the text next to it (`mtef2mml.xsl` - writes character references in pieces), and empty text leaves `` rather than ``. + writes character references in pieces: they are read as they are, and a parser reads any other markup), and empty + text leaves `` rather than ``. - Strings are counted, cut and translated by code points, as XPath defines them (`XslCompiledTransform` counted UTF-16 units). - What the stylesheets do not use (keys, `xsl:sort`, `xsl:number`, imports, extension functions, the `following` and `preceding` axes) is an error of the build (XSLT001). `Xslt/CompiledStylesheetTests` checks that the compiled stylesheets give what `XslCompiledTransform` gives for the -corpora: the OMML of Word documents and of the UnicodeMathML examples, their MathML, and the MTEF of the test data. It -leaves out the inputs with characters outside the basic plane, where the two count differently. +corpora: the OMML of Word documents and of the UnicodeMathML examples, their MathML, the MTEF of the test data and the +Content MathML of the tests of ctop. It leaves out the inputs with characters outside the basic plane, where the two +count differently. `Xslt/conformance.xsl`, compiled into the tests, does the same for what the generator compiles +beyond what the stylesheets of the library use of it on the corpora (the axes, the predicates that count, the +parameters of mixed types). `StylesheetBenchmarks` measures the stylesheets alone, compiled and run by +`XslCompiledTransform`. ## The normalized MathML (`Internal/MathML/MathMLParser.cs`) diff --git a/src/SharpEquations.XsltGenerator/CodeWriter.cs b/src/SharpEquations.XsltGenerator/CodeWriter.cs index 537b48d..de3bdfe 100644 --- a/src/SharpEquations.XsltGenerator/CodeWriter.cs +++ b/src/SharpEquations.XsltGenerator/CodeWriter.cs @@ -38,6 +38,24 @@ public CodeWriter Append(CodeWriter other) return this; } + /// Text that is already indented. + public CodeWriter Raw(string code) + { + text.Append(code); + return this; + } + + /// The lines of another writer, written without indentation, at the indentation of this one. + public CodeWriter Indented(CodeWriter other) + { + foreach (var line in other.text.ToString().Split('\n')) + { + if (line.Length > 0) + Line(line); + } + return this; + } + public override string ToString() => text.ToString(); } } diff --git a/src/SharpEquations.XsltGenerator/StylesheetCompiler.cs b/src/SharpEquations.XsltGenerator/StylesheetCompiler.cs index 605164e..4faf35e 100644 --- a/src/SharpEquations.XsltGenerator/StylesheetCompiler.cs +++ b/src/SharpEquations.XsltGenerator/StylesheetCompiler.cs @@ -7,10 +7,78 @@ namespace SharpEquations.XsltGenerator using System.Xml.Linq; /// - /// An XSLT 1.0 stylesheet compiled to a C# class: a method for each template, the dispatch of - /// xsl:apply-templates for each mode (by the name of the element first), a lazy property for each - /// global variable. What the stylesheets of SharpEquations do not use (keys, sorting, numbering, imports, - /// extension functions) is reported as an error. + /// What a compilation of the stylesheet takes as known of its templates, parameters and global variables, and + /// learns of them: the stylesheet is compiled again, with what the compilation before learnt, until nothing new + /// is learnt. Every fact starts as the one that gives the best code and only moves to the one that is always + /// right, so the last compilation holds. + /// + internal sealed class Facts + { + /// The types of the values given to the parameters of the named templates. + public Dictionary ParameterTypes { get; } = new Dictionary(); + + /// The parameters of the named templates that a call leaves to their default. + public HashSet Omitted { get; } = new HashSet(); + + /// The named templates that read the context node, its position, its size. + public HashSet NodeUsed { get; } = new HashSet(); + + public HashSet PositionUsed { get; } = new HashSet(); + + public HashSet SizeUsed { get; } = new HashSet(); + + /// The modes with a template that reads the position of the node, the size. + public HashSet ModePosition { get; } = new HashSet(); + + public HashSet ModeSize { get; } = new HashSet(); + + /// The named templates that write more than text. + public HashSet NotText { get; } = new HashSet(); + + /// The named templates that read the input, or a global variable that depends on it. + public HashSet Impure { get; } = new HashSet(); + + /// The global variables that depend on the input, or are not strings. + public HashSet Dynamic { get; } = new HashSet(); + + private IEnumerable> Sets => new[] { Omitted, NodeUsed, PositionUsed, SizeUsed, ModePosition, ModeSize, NotText, Impure, Dynamic }; + + public static string Parameter(string template, string parameter) => template + "\n" + parameter; + + public static XType Join(XType left, XType right) + { + if (left == XType.None) + return right; + if (right == XType.None || left == right) + return left; + // a node is a node-set too + return (left == XType.Node && right == XType.NodeSet) || (left == XType.NodeSet && right == XType.Node) ? XType.NodeSet : XType.Any; + } + + public void Give(string parameter, XType type) => + ParameterTypes[parameter] = Join(ParameterTypes.TryGetValue(parameter, out var known) ? known : XType.None, type); + + /// Adds what another compilation learnt; whether it was anything new. + public bool Add(Facts other) + { + var before = Count; + foreach (var parameter in other.ParameterTypes) + Give(parameter.Key, parameter.Value); + foreach (var pair in Sets.Zip(other.Sets, (mine, theirs) => (mine, theirs))) + pair.mine.UnionWith(pair.theirs); + return Count != before; + } + + // every fact moves one way, so the count tells whether one moved + private int Count => Sets.Sum(s => s.Count) + ParameterTypes.Values.Sum(t => t == XType.None ? 0 : t == XType.Any ? 3 : t == XType.NodeSet ? 2 : 1); + } + + /// + /// An XSLT 1.0 stylesheet compiled to a C# class: a method for each template, with typed parameters for the named + /// ones; the dispatch of xsl:apply-templates for each mode (by the name of the element first); a lazy + /// property for each global variable, computed once for all when it is a string that does not depend on the + /// input; a method for each expression that walks the tree. What the stylesheets of SharpEquations do not use + /// (keys, sorting, numbering, imports, extension functions) is reported as an error. /// internal sealed class StylesheetCompiler { @@ -19,33 +87,41 @@ internal sealed class StylesheetCompiler private readonly XElement stylesheet; private readonly string className; private readonly string fileName; + private readonly Facts facts; private readonly XPathCompiler xpath; + private readonly Names locals = new Names(); private readonly CodeWriter nameFields = new CodeWriter(2); - private readonly CodeWriter testFields = new CodeWriter(2); private readonly CodeWriter staticFields = new CodeWriter(2); private readonly CodeWriter instanceFields = new CodeWriter(2); private readonly CodeWriter members = new CodeWriter(2); private readonly Dictionary names = new Dictionary(); - private readonly Dictionary tests = new Dictionary(); + private readonly Dictionary helpers = new Dictionary(); + private readonly Dictionary statics = new Dictionary(); private readonly Dictionary namedTemplates = new Dictionary(); private readonly Dictionary globals = new Dictionary(); private readonly Dictionary modes = new Dictionary { [""] = 0 }; private readonly List rules = new List(); - private int counter; private sealed class NamedTemplate { - public NamedTemplate(string method, List parameters) + public NamedTemplate(string name, string method, List parameters, bool matches) { + Name = name; Method = method; Parameters = parameters; + Matches = matches; } + public string Name { get; } + public string Method { get; } public List Parameters { get; } + + /// Whether the template has a pattern too, and takes its parameters from xsl:apply-templates. + public bool Matches { get; } } private sealed class GlobalVariable @@ -115,17 +191,24 @@ public Rule(int index, XElement template, string method, int mode, double priori public List Steps { get; } public Step? Last => Steps.Count == 0 ? null : Steps[Steps.Count - 1].Step; + + /// Whether the rule matches every node it is tried on, those its last step tells by their kind or name. + public bool Always { get; set; } } - public StylesheetCompiler(XElement stylesheet, string className, string fileName) + public StylesheetCompiler(XElement stylesheet, string className, string fileName, Facts facts) { this.stylesheet = stylesheet; this.className = className; this.fileName = fileName; + this.facts = facts; xpath = new XPathCompiler(this); } - public string NewName(string prefix) => prefix + (++counter); + /// What this compilation learnt. + public Facts Learnt { get; } = new Facts(); + + public string NewName(string prefix) => locals.New(prefix); public static XsltException Error(XObject node, string message) { @@ -147,49 +230,41 @@ public string Name(XName name) return field; } - public string NodeTest(NodeTestSyntax test, XElement scope) + /// A static field of the class with this value, the same for the same value. + public string StaticField(string type, string init) { - string key; - string init; - switch (test.Kind) + var key = type + " " + init; + if (!statics.TryGetValue(key, out var field)) { - case TestKind.Name: - { - var name = XName.Get(test.Local!, Namespace(test.Prefix, scope)); - key = "name " + name; - init = "NodeTest.Named(" + Name(name) + ")"; - break; - } - case TestKind.Namespace: - { - var ns = Namespace(test.Prefix, scope); - key = "ns " + ns; - init = "NodeTest.InNamespace(" + XPathCompiler.Literal(ns) + ")"; - break; - } - case TestKind.AnyName: - return "NodeTest.AnyName"; - case TestKind.Node: - return "NodeTest.Node"; - case TestKind.Text: - return "NodeTest.Text"; - case TestKind.Comment: - return "NodeTest.Comment"; - default: - key = "pi " + test.Local; - init = "NodeTest.ProcessingInstruction(" + (test.Local == null ? "null" : XPathCompiler.Literal(test.Local)) + ")"; - break; + field = "Static" + statics.Count; + statics.Add(key, field); + staticFields.Line("private static readonly " + type + " " + field + " = " + init + ";"); } - if (!tests.TryGetValue(key, out var field)) + return field; + } + + /// A static field for a value kept from one use to the next. + public string CacheField(string type) + { + var field = NewName("Cache"); + staticFields.Line("private static " + type + " " + field + ";"); + return field; + } + + /// A method of the class with this body, the same for the same body; its name. + public string HelperMethod(string returnType, string parameters, string body) + { + var key = returnType + " (" + parameters + ")\n" + body; + if (!helpers.TryGetValue(key, out var method)) { - field = "Test" + tests.Count; - tests.Add(key, field); - testFields.Line("private static readonly NodeTest " + field + " = " + init + ";"); + method = "Path" + helpers.Count; + helpers.Add(key, method); + members.Line().Open("private " + returnType + " " + method + "(" + parameters + ")").Raw(body).Close(); } - return field; + return method; } - private static string Namespace(string? prefix, XElement scope) + public static string Namespace(string? prefix, XElement scope) { if (prefix == null) return ""; @@ -234,7 +309,7 @@ public string Compile() { var name = Attr(templates[i], "name", false); if (name != null) - namedTemplates[name] = new NamedTemplate("Named" + i, TemplateParameters(templates[i]).Select(p => Attr(p, "name", true)!).ToList()); + namedTemplates[name] = new NamedTemplate(name, "Named" + i, TemplateParameters(templates[i]).Select(p => Attr(p, "name", true)!).ToList(), Attr(templates[i], "match", false) != null); } for (var i = 0; i < templates.Count; i++) @@ -283,7 +358,7 @@ private string Assemble() .Line() .Line("// the stylesheet " + fileName + ", compiled to C#") .Open("internal sealed partial class " + className); - file.Append(nameFields).Append(testFields).Append(staticFields); + file.Append(nameFields).Append(staticFields); file.Line() .Line("private readonly XsltOutput output = new XsltOutput();") .Line("private readonly XObject root;") @@ -295,7 +370,7 @@ private string Assemble() .Open("public static partial XElement? Transform(XElement input)") .Line("var document = new XDocument(input);") .Line("var stylesheet = new " + className + "(document);") - .Line("stylesheet.Apply0(Single(document), null);") + .Line("stylesheet.Dispatch0(document, 1, 1, null);") .Line("return stylesheet.output.Result();") .Close(); file.Append(members); @@ -316,34 +391,58 @@ private void CompileTemplate(XElement template, int index) if (name != null) { var named = namedTemplates[name]; - var signature = string.Concat(named.Parameters.Select((p, i) => ", object? a" + i)); + var types = named.Parameters.Select(p => ParameterType(named, p)).ToList(); + if (named.Matches) + { + foreach (var parameter in named.Parameters) + Learnt.Give(Facts.Parameter(name, parameter), XType.Any); + } + var signature = string.Concat(named.Parameters.Select((p, i) => ", " + XPathCompiler.TypeName(types[i]) + "? a" + i)); + var focus = new Focus(); + var purity = new Purity(); var w = new CodeWriter(2); w.Line().Open("private void " + named.Method + "(XObject n0, int p0, int s0" + signature + ")"); - CompileBody(template, parameters, i => "a" + i, w); + CompileBody(template, parameters, i => "a" + i, i => types[i], named, focus, purity, w); w.Close(); members.Append(w); + if (focus.NodeUsed || focus.CurrentUsed) + Learnt.NodeUsed.Add(name); + if (focus.PositionUsed) + Learnt.PositionUsed.Add(name); + if (focus.SizeUsed) + Learnt.SizeUsed.Add(name); + if (purity.Impure) + Learnt.Impure.Add(name); + if (!IsTextOnly(template.Nodes())) + Learnt.NotText.Add(name); } if (match == null) return; + var mode = ModeIndex((string?)template.Attribute("mode")); var method = "Template" + index; var body = new CodeWriter(2); body.Line().Open("private void " + method + "(XObject n0, int p0, int s0, TemplateArguments? args)"); + var templateFocus = new Focus(); if (name != null) { var named = namedTemplates[name]; - body.Line(named.Method + "(n0, p0, s0" + string.Concat(named.Parameters.Select(p => ", args?.Get(" + XPathCompiler.Literal(p) + ")")) + ");"); + var context = TemplateContext(template, templateFocus, new Purity(), new Scope(null)); + body.Line(Call(named, context, named.Parameters.Select(p => "args?.Get(" + XPathCompiler.Literal(p) + ")"))); } else { var names = parameters.Select(p => Attr(p, "name", true)!).ToList(); - CompileBody(template, parameters, i => "args?.Get(" + XPathCompiler.Literal(names[i]) + ")", body); + CompileBody(template, parameters, i => "args?.Get(" + XPathCompiler.Literal(names[i]) + ")", i => XType.Any, null, templateFocus, new Purity(), body); } body.Close(); members.Append(body); + if (templateFocus.PositionUsed) + Learnt.ModePosition.Add(ModeKey(mode)); + if (templateFocus.SizeUsed) + Learnt.ModeSize.Add(ModeKey(mode)); - var mode = ModeIndex((string?)template.Attribute("mode")); var priority = (string?)template.Attribute("priority"); foreach (var alternative in Alternatives(XPathParser.Parse(match), template)) { @@ -372,6 +471,60 @@ private void CompileTemplate(XElement template, int index) } } + private static string ModeKey(int mode) => mode.ToString(System.Globalization.CultureInfo.InvariantCulture); + + private Context TemplateContext(XElement element, Focus focus, Purity purity, Scope scope) => + new Context("n0", NodeKind.Object, "p0", "s0", focus, "n0", NodeKind.Object, focus, scope, element, locals, purity); + + /// The type of a parameter of a named template: that of every value it is given, if they have the same. + private XType ParameterType(NamedTemplate template, string parameter) + { + if (template.Matches) + return XType.Any; + return facts.ParameterTypes.TryGetValue(Facts.Parameter(template.Name, parameter), out var type) ? type : XType.None; + } + + /// + /// The call of a named template, which is given the context node, its position and its size only if it reads them. + /// + private string Call(NamedTemplate template, Context context, IEnumerable arguments) + { + if (facts.Impure.Contains(template.Name)) + context.Purity.Impure = true; + var node = facts.NodeUsed.Contains(template.Name) ? context.Node : "null"; + var position = facts.PositionUsed.Contains(template.Name) ? context.Position : "0"; + var size = facts.SizeUsed.Contains(template.Name) ? context.Size : "0"; + return template.Method + "(" + node + ", " + position + ", " + size + string.Concat(arguments.Select(a => ", " + a)) + ");"; + } + + /// The value as a parameter of the type takes it. + private static string Convert(Compiled value, XType type) + { + switch (type) + { + case XType.Any: + case XType.None: + return XPathCompiler.Box(value); + case var _ when type == value.Type: + return value.Code; + case XType.NodeSet when value.Type == XType.Node: + return "ListOf(" + value.Code + ")"; + case XType.Node: + return "null"; + // no value of another type reaches a parameter whose type is known + case XType.Number: + return "0D"; + case XType.Boolean: + return "false"; + case XType.NodeSet: + return "Empty"; + case XType.Fragment: + return "null!"; + default: + return "\"\""; + } + } + private static IEnumerable Alternatives(Expr pattern, XElement template) { switch (pattern) @@ -403,38 +556,192 @@ private static double DefaultPriority(PathExpr alternative, List st } } - private void CompileBody(XElement template, List parameters, System.Func argument, CodeWriter w) + private void CompileBody(XElement template, List parameters, System.Func argument, System.Func type, NamedTemplate? named, + Focus focus, Purity purity, CodeWriter w) { var scope = new Scope(null); - var context = new Context("n0", "p0", "s0", "n0", scope, template); + var context = TemplateContext(template, focus, purity, scope); for (var i = 0; i < parameters.Count; i++) { var parameter = parameters[i]; + var name = Attr(parameter, "name", true)!; var id = NewName("v"); var paramContext = context.WithElement(parameter); var select = Attr(parameter, "select", false); + var content = select == null && HasContent(parameter); + var valueType = type(i) == XType.Number || type(i) == XType.Boolean; + + // the default is one of the values of the parameter if a call leaves the parameter to it + void Default(XType defaultType) + { + if (named != null && facts.Omitted.Contains(Facts.Parameter(named.Name, name))) + Learnt.Give(Facts.Parameter(named.Name, name), defaultType); + } + if (select != null) { - w.Line("var " + id + " = " + argument(i) + " ?? " + XPathCompiler.Box(xpath.Compile(select, paramContext)) + ";"); + var value = xpath.Compile(select, paramContext).Bound(); + Default(value.Type); + // a node that is null is an empty node-set, not a parameter left to its default + w.Line(type(i) == XType.Node + ? "var " + id + " = ReferenceEquals(" + argument(i) + ", Omitted) ? " + Convert(value, type(i)) + " : " + argument(i) + ";" + : "var " + id + " = " + argument(i) + " ?? " + Convert(value, type(i)) + ";"); } - else if (HasContent(parameter)) + else if (type(i) == XType.Node) + { + Default(content ? XType.Fragment : XType.String); + w.Line("var " + id + " = ReferenceEquals(" + argument(i) + ", Omitted) ? null : " + argument(i) + ";"); + } + else if (content && !valueType) { w.Line("var " + id + " = " + argument(i) + ";"); w.Open("if (" + id + " == null)"); - w.Line("output.BeginCapture();"); - CompileSequence(parameter.Nodes(), paramContext, w); - w.Line(id + " = output.EndFragment();"); + var value = Capture(parameter, paramContext, w); + Default(value.Type); + w.Line(id + " = " + Convert(value, type(i)) + ";"); w.Close(); } else { - w.Line("var " + id + " = " + argument(i) + " ?? \"\";"); + Default(content ? XType.Fragment : XType.String); + w.Line("var " + id + " = " + argument(i) + " ?? " + Convert(new Compiled("\"\"", XType.String), type(i)) + ";"); } - scope.Add(Attr(parameter, "name", true)!, new Variable(id, XType.Any)); + scope.Add(name, new Variable(id, type(i), type(i) == XType.Node ? NodeOrder.Single : NodeOrder.Ordered)); } CompileSequence(template.Nodes().Where(n => !(n is XElement e && parameters.Contains(e))), context, w); } + /// + /// Whether the instructions write only text, so that what they write can be kept as a string: no element, no + /// text written as markup, no template applied, and only named templates that write text called. + /// + private bool IsTextOnly(IEnumerable nodes) + { + foreach (var element in nodes.OfType()) + { + if (element.Name.Namespace != Xsl) + return false; + switch (element.Name.LocalName) + { + case "text": + case "value-of": + if (DisablesEscaping(element)) + return false; + break; + case "variable": + case "param": + case "message": + case "fallback": + break; + case "if": + case "choose": + case "when": + case "otherwise": + case "for-each": + if (!IsTextOnly(element.Nodes())) + return false; + break; + case "call-template": + if (facts.NotText.Contains((string?)element.Attribute("name") ?? "")) + return false; + break; + default: + return false; + } + } + return true; + } + + /// + /// What the instructions write as a C# expression of a string, if they are only text, values and choices + /// between them: null if they need statements. + /// + private string? TextExpression(IEnumerable nodes, Context context) + { + var parts = new List(); + foreach (var node in nodes) + { + switch (node) + { + case XText text when text.Value.Trim().Length == 0: + break; + case XText text: + parts.Add(XPathCompiler.Literal(text.Value)); + break; + case XElement element when element.Name == Xsl + "text" && !DisablesEscaping(element): + if (element.Value.Length > 0) + parts.Add(XPathCompiler.Literal(element.Value)); + break; + case XElement element when element.Name == Xsl + "value-of" && !DisablesEscaping(element) && element.Attribute("select") != null: + parts.Add(XPathCompiler.Str(xpath.Compile(Attr(element, "select", true)!, context.WithElement(element)))); + break; + case XElement element when element.Name == Xsl + "if" && element.Attribute("test") != null: + { + var then = TextExpression(element.Nodes(), context.WithElement(element)); + if (then == null) + return null; + parts.Add("(" + XPathCompiler.Bool(xpath.Compile(Attr(element, "test", true)!, context.WithElement(element))) + " ? " + then + " : \"\")"); + break; + } + case XElement element when element.Name == Xsl + "choose": + { + var choice = new StringBuilder(); + var otherwise = "\"\""; + foreach (var branch in element.Elements()) + { + var value = TextExpression(branch.Nodes(), context.WithElement(branch)); + if (value == null) + return null; + if (branch.Name == Xsl + "when" && branch.Attribute("test") != null) + choice.Append(XPathCompiler.Bool(xpath.Compile(Attr(branch, "test", true)!, context.WithElement(branch)))).Append(" ? ").Append(value).Append(" : "); + else if (branch.Name == Xsl + "otherwise" && branch == element.Elements().Last()) + otherwise = value; + else + return null; + } + parts.Add("(" + choice + otherwise + ")"); + break; + } + case XElement _: + return null; + } + } + switch (parts.Count) + { + case 0: + return "\"\""; + case 1: + return parts[0]; + default: + return "string.Concat(" + string.Join(", ", parts) + ")"; + } + } + + /// + /// The content of a variable or parameter as its value: a string when it only writes text, a result tree + /// fragment otherwise. + /// + private Compiled Capture(XElement element, Context context, CodeWriter w) + { + if (!IsTextOnly(element.Nodes())) + { + var fragment = NewName("f"); + w.Line("output.BeginCapture();"); + CompileSequence(element.Nodes(), context, w); + w.Line("var " + fragment + " = output.EndFragment();"); + return new Compiled(fragment, XType.Fragment); + } + + var expression = TextExpression(element.Nodes(), context); + if (expression != null) + return new Compiled(expression, XType.Text); + var text = NewName("t"); + w.Line("output.BeginText();"); + CompileSequence(element.Nodes(), context, w); + w.Line("var " + text + " = output.EndText();"); + return new Compiled(text, XType.Text); + } + private static bool HasContent(XElement element) => element.Nodes().Any(n => n is XElement || (n is XText t && t.Value.Trim().Length > 0)); @@ -443,7 +750,6 @@ private static bool HasContent(XElement element) => private void CompileMatch(Rule rule) { var w = new CodeWriter(2); - var context = new Context("node", "1", "1", "node", new Scope(null), rule.Template); if (rule.IsRoot) { w.Line().Line("private static bool Match" + rule.Index + "(XObject node) => node is XDocument;"); @@ -454,19 +760,13 @@ private void CompileMatch(Rule rule) for (var i = 0; i < rule.Steps.Count; i++) { var step = rule.Steps[i].Step; - var attributeAxis = step.Axis == "attribute"; - var test = NodeTest(step.Test, rule.Template) + ".Matches(node, " + (attributeAxis ? "true" : "false") + ")"; - if (step.Predicates.Count > 0) - { - if (step.Predicates.Any(p => xpath.IsPositional(p, context))) - test = "MatchesStep(node, Axis." + (attributeAxis ? "Attribute" : "Child") + ", " + NodeTest(step.Test, rule.Template) + ", " - + string.Join(", ", step.Predicates.Select(p => xpath.Predicate(p, context))) + ")"; - else - test += string.Concat(step.Predicates.Select(p => " && " + XPathCompiler.Bool(xpath.Compile(p, context)))); - } + var focus = new Focus(); + var context = new Context("node", NodeKind.Object, null, null, focus, "node", NodeKind.Object, focus, new Scope(null), rule.Template, new Names(), new Purity()); + var test = MatchStep(step, context); w.Line().Open("private bool Match" + rule.Index + "_" + i + "(XObject node)"); - w.Line("if (!(" + test + "))").Line(" return false;"); + if (test != null) + w.Line("if (!(" + test + "))").Line(" return false;"); if (i == 0) { w.Line(rule.Absolute ? "return Parent(node) is XDocument;" : "return true;"); @@ -486,6 +786,67 @@ private void CompileMatch(Rule rule) } w.Line().Line("private bool Match" + rule.Index + "(XObject node) => Match" + rule.Index + "_" + (rule.Steps.Count - 1) + "(node);"); members.Append(w); + + var last = rule.Last!; + if (rule.Steps.Count == 1 && !rule.Absolute && last.Predicates.Count == 0) + { + switch (last.Test.Kind) + { + case TestKind.AnyName: + case TestKind.Node: + rule.Always = true; + break; + case TestKind.Namespace: + break; + case TestKind.ProcessingInstruction: + rule.Always = last.Test.Local == null; + break; + default: + rule.Always = last.Axis != "attribute"; + break; + } + } + } + + /// + /// Whether the node, node in the generated code, is one the step of a pattern selects from its parent: the + /// C# condition, or null if every node is. + /// + private string? MatchStep(Step step, Context context) + { + var attributeAxis = step.Axis == "attribute"; + if (step.Predicates.Any(p => xpath.IsPositional(p, context))) + { + // the predicates count the node among those the step selects from its parent + var body = new CodeWriter(3); + body.Line("var parent = Parent(node);").Line("if (parent == null)").Line(" return false;"); + xpath.EmitStep(new Cursor("parent", NodeKind.Object), step, context, body, + cursor => body.Line("if (ReferenceEquals(" + cursor.Var + ", node))").Line(" return true;")); + body.Line("return false;"); + return HelperMethod("bool", "XObject node", body.ToString()) + "(node)"; + } + + XPathCompiler.Tested tested; + string? condition; + if (attributeAxis) + { + var attribute = context.Names.New("a_"); + tested = xpath.Test(step.Test, true, new Cursor(attribute, NodeKind.Attribute), false, context); + condition = "node is XAttribute " + attribute + (tested.Condition == null ? "" : " && " + tested.Condition); + } + else + { + tested = xpath.Test(step.Test, false, new Cursor("node", NodeKind.Object), false, context); + condition = tested.Condition; + } + if (tested.Never) + return "false"; + foreach (var predicate in step.Predicates) + { + var value = XPathCompiler.Bool(xpath.Compile(predicate, context.WithFocus(tested.Cursor.Var, tested.Cursor.Kind, null, null, new Focus()))); + condition = condition == null ? value : condition + " && " + value; + } + return condition; } private static Bucket[] Buckets(Rule rule) @@ -518,12 +879,30 @@ private void CompileDispatch(int mode) // the rule that wins is the one of highest priority and, among those, the last in the stylesheet var ordered = rules.Where(r => r.Mode == mode).OrderByDescending(r => r.Priority).ThenByDescending(r => r.Index).ToList(); List In(Bucket bucket) => ordered.Where(r => Buckets(r).Contains(bucket)).ToList(); + var position = facts.ModePosition.Contains(ModeKey(mode)); + var size = facts.ModeSize.Contains(ModeKey(mode)); var w = new CodeWriter(2); w.Line().Open("private void Apply" + mode + "(List nodes, TemplateArguments? args)"); w.Line("for (var i = 0; i < nodes.Count; i++)").Line(" Dispatch" + mode + "(nodes[i], i + 1, nodes.Count, args);"); w.Close(); + // the children of a node, without a list of them unless a template reads how many they are + w.Line().Open("private void ApplyChildren" + mode + "(XObject node, TemplateArguments? args)"); + if (size) + { + w.Line("Apply" + mode + "(ChildNodes(node), args);"); + } + else + { + if (position) + w.Line("var position = 0;"); + w.Open("for (var child = FirstChild(node); child != null; child = child.NextNode)"); + w.Line("if (IsNode(child))").Line(" Dispatch" + mode + "(child, " + (position ? "++position" : "0") + ", 0, args);"); + w.Close(); + } + w.Close(); + w.Line().Open("private void Dispatch" + mode + "(XObject node, int position, int size, TemplateArguments? args)"); w.Open("switch (node)"); @@ -546,23 +925,23 @@ private void CompileDispatch(int mode) w.Line("case " + i + ":").Open(); var candidates = ordered.Where(r => generic.Contains(r) || (named.Contains(r) && XName.Get(r.Last!.Test.Local!, Namespace(r.Last.Test.Prefix, r.Template)) == name)); - EmitCandidates(candidates, w); + EmitCandidates(candidates, w, true); w.Line("break;").Close(); } w.Line("default:").Open(); - EmitCandidates(generic, w); + EmitCandidates(generic, w, true); w.Line("break;").Close(); w.Close(); } else { - EmitCandidates(generic, w); + EmitCandidates(generic, w, true); } - w.Line("Apply" + mode + "(ChildNodes(node), null);").Line("return;").Close(); + w.Line("ApplyChildren" + mode + "(node, null);").Line("return;").Close(); w.Line("case XDocument _:").Open(); EmitCandidates(In(Bucket.Root), w); - w.Line("Apply" + mode + "(ChildNodes(node), null);").Line("return;").Close(); + w.Line("ApplyChildren" + mode + "(node, null);").Line("return;").Close(); w.Line("case XAttribute _:").Open(); EmitCandidates(In(Bucket.Attribute), w); @@ -584,22 +963,97 @@ private void CompileDispatch(int mode) members.Append(w); } - private static void EmitCandidates(IEnumerable candidates, CodeWriter w) + /// + /// The child and the text a rule asks of an element, if its pattern starts its first predicate with + /// child = 'text': the rule can only match an element that has such a child. + /// + private static (XName Child, string Text)? KeyOf(Rule rule) + { + var last = rule.Last; + if (last == null || last.Axis != "child" || last.Predicates.Count == 0) + return null; + var condition = last.Predicates[0]; + while (condition is BinaryExpr and && and.Op == "and") + condition = and.Left; + if (!(condition is BinaryExpr equal) || equal.Op != "=") + return null; + var path = equal.Left as PathExpr ?? equal.Right as PathExpr; + var literal = equal.Left as LiteralExpr ?? equal.Right as LiteralExpr; + if (path == null || literal == null || path.Start != null || path.Absolute || path.Steps.Count != 1) + return null; + var step = path.Steps[0]; + if (step.Axis != "child" || step.Test.Kind != TestKind.Name || step.Predicates.Count != 0) + return null; + return (XName.Get(step.Test.Local!, Namespace(step.Test.Prefix, rule.Template)), literal.Value); + } + + /// + /// The rules tried in order. Among those of an element, the many that ask for a child with a text (the + /// characters of MTEF, each with its code) are found by the text of that child, when the element has one. + /// + private void EmitCandidates(IEnumerable candidates, CodeWriter w, bool ofElement = false) { - foreach (var rule in candidates) + var rules = candidates.ToList(); + var always = rules.FindIndex(r => r.Always); + if (always >= 0) + rules = rules.Take(always + 1).ToList(); + var keyed = ofElement + ? rules.Where(r => KeyOf(r) != null).GroupBy(r => KeyOf(r)!.Value.Child).OrderByDescending(g => g.Count()).FirstOrDefault() + : null; + var child = keyed != null && keyed.Count() >= 8 ? keyed.Key : null; + bool Keyed(Rule rule) => child != null && KeyOf(rule)?.Child == child; + + void Try(Rule rule) { + if (rule.Always) + { + w.Line(rule.Method + "(node, position, size, args);").Line("return;"); + return; + } w.Open("if (Match" + rule.Index + "(node))"); w.Line(rule.Method + "(node, position, size, args);").Line("return;"); w.Close(); } + + var key = NewName("key"); + var count = NewName("keys"); + if (child != null) + w.Line("var " + key + " = KeyValue(element, " + Name(child) + ", out var " + count + ");"); + for (var i = 0; i < rules.Count;) + { + if (!Keyed(rules[i])) + { + Try(rules[i++]); + continue; + } + var run = rules.Skip(i).TakeWhile(Keyed).ToList(); + i += run.Count; + w.Open("if (" + count + " == 1)"); + w.Open("switch (" + key + ")"); + foreach (var group in run.GroupBy(r => KeyOf(r)!.Value.Text)) + { + w.Line("case " + XPathCompiler.Literal(group.Key) + ":").Open(); + foreach (var rule in group) + Try(rule); + w.Line("break;").Close(); + } + w.Close().Close(); + // with more than one such child, any of them may have the text + w.Open("else if (" + count + " > 1)"); + foreach (var rule in run) + Try(rule); + w.Close(); + } } // global variables and parameters - public Variable? Global(string name) + public Variable? Global(string name, Purity purity) { if (!globals.TryGetValue(name, out var global)) return null; + if (facts.Dynamic.Contains(name)) + purity.Impure = true; if (global.Variable != null) return global.Variable; if (global.Compiling) @@ -608,49 +1062,62 @@ private static void EmitCandidates(IEnumerable candidates, CodeWriter w) var id = "G" + NewName("_"); var element = global.Element; - var context = new Context("n0", "p0", "s0", "n0", new Scope(null), element); - var w = new CodeWriter(2); + var focus = new Focus(); + var own = new Purity(); + var context = TemplateContext(element, focus, own, new Scope(null)); + var body = new CodeWriter(3); var select = Attr(element, "select", false); - XType type; - w.Line(); + Compiled value; + var order = NodeOrder.Ordered; if (select != null) { - var value = xpath.Compile(select, context); - type = value.Type; - w.Open("private " + XPathCompiler.TypeName(type) + " Compute" + id + "()"); - w.Line("XObject n0 = root;").Line("const int p0 = 1, s0 = 1;"); - w.Line("return " + value.Code + ";"); + var expr = XPathParser.Parse(select); + value = xpath.Compile(expr, context); + if (value.Type == XType.NodeSet) + order = xpath.OrderOf(expr, context); } else if (HasContent(element)) { - type = XType.Fragment; - w.Open("private ResultFragment Compute" + id + "()"); - w.Line("XObject n0 = root;").Line("const int p0 = 1, s0 = 1;"); - w.Line("output.BeginCapture();"); - CompileSequence(element.Nodes(), context, w); - w.Line("return output.EndFragment();"); + value = Capture(element, context, body); } else { - type = XType.String; - w.Open("private string Compute" + id + "()"); - w.Line("return \"\";"); + value = new Compiled("\"\"", XType.String); } + var type = value.Type; + var typeName = XPathCompiler.TypeName(type); + body.Line("return " + value.Code + ";"); + + var w = new CodeWriter(2); + w.Line().Open("private " + typeName + " Compute" + id + "()"); + w.Line("XObject n0 = root;").Line("const int p0 = 1, s0 = 1;"); + w.Raw(body.ToString()); w.Close(); - var typeName = XPathCompiler.TypeName(type); - instanceFields.Line("private bool " + id + "Done;").Line("private " + typeName + " " + id + "Value = default!;"); - w.Line().Open("private " + typeName + " " + id) - .Open("get") - .Open("if (!" + id + "Done)") - .Line(id + "Value = Compute" + id + "();") - .Line(id + "Done = true;") - .Close() - .Line("return " + id + "Value;") - .Close() - .Close(); + // a string that does not depend on the input is computed once for all + var text = type == XType.String || type == XType.Text; + if (own.Impure || focus.NodeUsed || focus.CurrentUsed || !text) + Learnt.Dynamic.Add(name); + if (text && !facts.Dynamic.Contains(name)) + { + staticFields.Line("private static string? " + id + "Value;"); + w.Line().Line("private string " + id + " => " + id + "Value ??= Compute" + id + "();"); + } + else + { + instanceFields.Line("private bool " + id + "Done;").Line("private " + typeName + " " + id + "Value = default!;"); + w.Line().Open("private " + typeName + " " + id) + .Open("get") + .Open("if (!" + id + "Done)") + .Line(id + "Value = Compute" + id + "();") + .Line(id + "Done = true;") + .Close() + .Line("return " + id + "Value;") + .Close() + .Close(); + } members.Append(w); - global.Variable = new Variable(id, type); + global.Variable = new Variable(id, type, order); return global.Variable; } @@ -714,16 +1181,38 @@ private void CompileInstruction(XElement element, Context context, CodeWriter w) case "apply-templates": { var select = Attr(element, "select", false); - var nodes = select == null ? "ChildNodes(" + context.Node + ")" : XPathCompiler.Nodes(xpath.Compile(select, context)); + var mode = ModeIndex((string?)element.Attribute("mode")); + context.Purity.Impure = true; var args = "null"; var parameters = WithParameters(element, context, w); if (parameters.Count > 0) { args = NewName("args"); w.Line("var " + args + " = new TemplateArguments(new[] { " + string.Join(", ", parameters.Keys.Select(XPathCompiler.Literal)) - + " }, new object[] { " + string.Join(", ", parameters.Values) + " });"); + + " }, new object[] { " + string.Join(", ", parameters.Values.Select(XPathCompiler.Box)) + " });"); + } + if (select == null) + { + w.Line("ApplyChildren" + mode + "(" + context.Node + ", " + args + ");"); + break; + } + + // the templates are given the position of each node, and a list of the nodes for their number, + // only if one of the mode reads them + var expr = XPathParser.Parse(select); + if (facts.ModeSize.Contains(ModeKey(mode)) || !XPathCompiler.IsStreamed(xpath.OrderOf(expr, context))) + { + w.Line("Apply" + mode + "(" + xpath.NodesCode(expr, context) + ", " + args + ");"); + break; + } + var position = "0"; + if (facts.ModePosition.Contains(ModeKey(mode))) + { + var p = NewName("p"); + w.Line("var " + p + " = 0;"); + position = "++" + p; } - w.Line("Apply" + ModeIndex((string?)element.Attribute("mode")) + "(" + nodes + ", " + args + ");"); + xpath.EmitNodes(expr, context, w, cursor => w.Line("Dispatch" + mode + "(" + cursor.Var + ", " + position + ", 0, " + args + ");")); break; } case "call-template": @@ -732,8 +1221,22 @@ private void CompileInstruction(XElement element, Context context, CodeWriter w) if (!namedTemplates.TryGetValue(name, out var template)) throw Error(element, "There is no template named " + name + "."); var parameters = WithParameters(element, context, w); - var args = string.Concat(template.Parameters.Select(p => ", " + (parameters.TryGetValue(p, out var value) ? value : "null"))); - w.Line(template.Method + "(" + context.Node + ", " + context.Position + ", " + context.Size + args + ");"); + var args = new List(); + foreach (var parameter in template.Parameters) + { + var key = Facts.Parameter(name, parameter); + if (parameters.TryGetValue(parameter, out var value)) + { + Learnt.Give(key, value.Type); + args.Add(Convert(value, ParameterType(template, parameter))); + } + else + { + Learnt.Omitted.Add(key); + args.Add(ParameterType(template, parameter) == XType.Node ? "Omitted" : "null"); + } + } + w.Line(Call(template, context, args)); break; } case "value-of": @@ -781,15 +1284,39 @@ private void CompileInstruction(XElement element, Context context, CodeWriter w) { if (element.Elements(Xsl + "sort").Any()) throw Error(element, "xsl:sort is not supported."); - var set = NewName("set"); - var i = NewName("i"); + var expr = XPathParser.Parse(Attr(element, "select", true)!); + var kind = xpath.KindOf(expr, context); var n = NewName("n"); var p = NewName("p"); var s = NewName("s"); - w.Line("var " + set + " = " + XPathCompiler.Nodes(xpath.Compile(Attr(element, "select", true)!, context)) + ";"); + + // the content first: whether it reads the position and the number of the nodes tells the loop it needs + var focus = new Focus(); + var body = new CodeWriter(); + CompileSequence(element.Nodes(), new Context(n, kind, p, s, focus, n, kind, focus, context.Scope, element, context.Names, context.Purity), body); + if (!focus.SizeUsed && XPathCompiler.IsStreamed(xpath.OrderOf(expr, context))) + { + if (focus.PositionUsed) + w.Line("var " + p + " = 0;"); + xpath.EmitNodes(expr, context, w, cursor => + { + w.Line("var " + n + " = " + cursor.Var + ";"); + if (focus.PositionUsed) + w.Line(p + "++;"); + w.Indented(body); + }); + break; + } + var set = NewName("set"); + var i = NewName("i"); + w.Line("var " + set + " = " + xpath.NodesCode(expr, context) + ";"); w.Open("for (var " + i + " = 0; " + i + " < " + set + ".Count; " + i + "++)"); - w.Line("var " + n + " = " + set + "[" + i + "];").Line("var " + p + " = " + i + " + 1;").Line("var " + s + " = " + set + ".Count;"); - CompileSequence(element.Nodes(), new Context(n, p, s, n, context.Scope, element), w); + w.Line("var " + n + " = " + (kind == NodeKind.Object ? "" : "(" + XPathCompiler.TypeName(kind) + ")") + set + "[" + i + "];"); + if (focus.PositionUsed) + w.Line("var " + p + " = " + i + " + 1;"); + if (focus.SizeUsed) + w.Line("var " + s + " = " + set + ".Count;"); + w.Indented(body); w.Close(); break; } @@ -798,26 +1325,25 @@ private void CompileInstruction(XElement element, Context context, CodeWriter w) var name = Attr(element, "name", true)!; var id = NewName("v"); var select = Attr(element, "select", false); - XType type; + Compiled value; + var order = NodeOrder.Ordered; if (select != null) { - var value = xpath.Compile(select, context); - type = value.Type; - w.Line("var " + id + " = " + value.Code + ";"); + var expr = XPathParser.Parse(select); + value = xpath.Compile(expr, context).Bound(); + if (value.Type == XType.NodeSet) + order = xpath.OrderOf(expr, context); } else if (HasContent(element)) { - type = XType.Fragment; - w.Line("output.BeginCapture();"); - CompileSequence(element.Nodes(), context, w); - w.Line("var " + id + " = output.EndFragment();"); + value = Capture(element, context, w); } else { - type = XType.String; - w.Line("var " + id + " = \"\";"); + value = new Compiled("\"\"", XType.String); } - context.Scope.Add(name, new Variable(id, type)); + w.Line("var " + id + " = " + value.Code + ";"); + context.Scope.Add(name, new Variable(id, value.Type, order)); break; } case "attribute": @@ -837,9 +1363,24 @@ private void CompileInstruction(XElement element, Context context, CodeWriter w) } case "copy-of": { - var value = xpath.Compile(Attr(element, "select", true)!, context); - var code = value.Type == XType.Number || value.Type == XType.Boolean ? XPathCompiler.Str(value) : value.Code; - w.Line("output.CopyOf(" + code + ");"); + var expr = XPathParser.Parse(Attr(element, "select", true)!); + var value = xpath.Compile(expr, context); + switch (value.Type) + { + case XType.NodeSet when XPathCompiler.IsStreamed(xpath.OrderOf(expr, context)): + xpath.EmitNodes(expr, context, w, cursor => w.Line("output.Copy(" + cursor.Var + ");")); + break; + case XType.Text: + w.Line("output.CopyOfText(" + value.Code + ");"); + break; + case XType.Number: + case XType.Boolean: + w.Line("output.CopyOf(" + XPathCompiler.Str(value) + ");"); + break; + default: + w.Line("output.CopyOf(" + value.Code + ");"); + break; + } break; } case "copy": @@ -876,10 +1417,10 @@ private void CompileInstruction(XElement element, Context context, CodeWriter w) private static bool DisablesEscaping(XElement element) => (string?)element.Attribute("disable-output-escaping") == "yes"; - /// The parameters of xsl:apply-templates or xsl:call-template, by name, as C# expressions of type object. - private Dictionary WithParameters(XElement element, Context context, CodeWriter w) + /// The parameters of xsl:apply-templates or xsl:call-template, by name. + private Dictionary WithParameters(XElement element, Context context, CodeWriter w) { - var parameters = new Dictionary(); + var parameters = new Dictionary(); foreach (var child in element.Elements()) { if (child.Name == Xsl + "sort") @@ -891,19 +1432,16 @@ private Dictionary WithParameters(XElement element, Context cont var paramContext = context.WithElement(child); if (select != null) { - parameters[name] = XPathCompiler.Box(xpath.Compile(select, paramContext)); + // compiled here, before the statements of the parameters after it + parameters[name] = xpath.Compile(select, paramContext).Bound(); } else if (HasContent(child)) { - var id = NewName("w"); - w.Line("output.BeginCapture();"); - CompileSequence(child.Nodes(), paramContext, w); - w.Line("var " + id + " = output.EndFragment();"); - parameters[name] = id; + parameters[name] = Capture(child, paramContext, w); } else { - parameters[name] = "(object)\"\""; + parameters[name] = new Compiled("\"\"", XType.String); } } return parameters; @@ -924,6 +1462,8 @@ private string Content(XElement element, Context context, CodeWriter w) case 1 when nodes[0] is XElement e && e.Name == Xsl + "value-of": return XPathCompiler.Str(xpath.Compile(Attr(e, "select", true)!, context.WithElement(e))); } + if (IsTextOnly(element.Nodes())) + return Capture(element, context, w).Code; var id = NewName("text"); w.Line("output.BeginCapture();"); CompileSequence(element.Nodes(), context, w); diff --git a/src/SharpEquations.XsltGenerator/XPathCompiler.cs b/src/SharpEquations.XsltGenerator/XPathCompiler.cs index 847134b..731e0ba 100644 --- a/src/SharpEquations.XsltGenerator/XPathCompiler.cs +++ b/src/SharpEquations.XsltGenerator/XPathCompiler.cs @@ -6,44 +6,143 @@ namespace SharpEquations.XsltGenerator using System.Text; using System.Xml.Linq; - /// The static type of an XPath value in the generated code. + /// + /// The static type of an XPath value in the generated code. Text is a result tree fragment that holds only + /// text, kept as a string; None is the type of a parameter no call has given a value yet, while the types + /// of the parameters are inferred. + /// internal enum XType { + None, String, Number, Boolean, + + /// A node-set of at most one node, kept as the node or null. + Node, NodeSet, + Text, Fragment, Any, } + /// What is known of the order of the nodes a path yields as the generated loops reach them. + internal enum NodeOrder + { + /// At most one node. + Single, + + /// In document order, without duplicates, none an ancestor of another. + Disjoint, + + /// In document order, without duplicates. + Ordered, + + /// In reverse document order, without duplicates. + Reversed, + + Unordered, + } + + /// The C# type of the variable that holds a node. + internal enum NodeKind + { + Object, + Node, + Element, + Attribute, + Text, + } + + /// A node-set the generated code holds as a list, in a path that starts from it. + internal sealed class ListExpr : Expr + { + public ListExpr(string code) => Code = code; + + public string Code { get; } + } + + /// A node the generated code holds in a variable. + internal readonly struct Cursor + { + public Cursor(string variable, NodeKind kind) + { + Var = variable; + Kind = kind; + } + + public string Var { get; } + + public NodeKind Kind { get; } + } + + /// + /// An expression compiled to C#. A node-set is compiled when it is used, to the code its use needs: a test for a + /// node, the string value of the first one, a count, or the list of the nodes (). + /// internal sealed class Compiled { + private readonly XPathCompiler? owner; + private string? code; + public Compiled(string code, XType type) { - Code = code; + this.code = code; Type = type; } - public string Code { get; } + public Compiled(XPathCompiler owner, Expr expr, Context context) + { + this.owner = owner; + Expr = expr; + Context = context; + Type = XType.NodeSet; + } + + public string Code => code ??= owner!.NodesCode(Expr!, Context!); public XType Type { get; } + + public Expr? Expr { get; } + + public Context? Context { get; } + + public XPathCompiler Owner => owner!; + + public bool IsNodeSet => Expr != null; + + /// + /// The value as a variable or a parameter holds it: a node-set compiled here, to the node when it has at most + /// one, to the list of its nodes otherwise. + /// + public Compiled Bound() + { + if (!IsNodeSet) + return this; + return owner!.OrderOf(Expr!, Context!) == NodeOrder.Single + ? new Compiled(owner.FirstNodeCode(Expr!, Context!), XType.Node) + : new Compiled(Code, XType.NodeSet); + } } internal sealed class Variable { - public Variable(string id, XType type) + public Variable(string id, XType type, NodeOrder order = NodeOrder.Ordered) { Id = id; Type = type; + Order = order; } public string Id { get; } public XType Type { get; } + + /// The order of the nodes of a node-set. + public NodeOrder Order { get; } } - internal sealed class Scope + internal class Scope { private readonly Dictionary variables = new Dictionary(); private readonly Scope? parent; @@ -52,46 +151,146 @@ internal sealed class Scope public void Add(string name, Variable variable) => variables[name] = variable; - public Variable? Find(string name) => + public virtual Variable? Find(string name) => variables.TryGetValue(name, out var variable) ? variable : parent?.Find(name); } + /// The scope of a method generated for an expression: the variables it reads are its parameters. + internal sealed class CapturingScope : Scope + { + private readonly Scope outer; + + public CapturingScope(Scope outer) + : base(null) => this.outer = outer; + + public List Captured { get; } = new List(); + + public override Variable? Find(string name) + { + var variable = outer.Find(name); + if (variable != null && !Captured.Contains(variable)) + Captured.Add(variable); + return variable; + } + } + + /// What the code compiled in a context read of it. + internal sealed class Focus + { + public bool NodeUsed { get; set; } + + public bool PositionUsed { get; set; } + + public bool SizeUsed { get; set; } + + public bool CurrentUsed { get; set; } + } + + /// Whether what is compiled depends on more than its parameters: the input, or a variable that does. + internal sealed class Purity + { + public bool Impure { get; set; } + } + + internal sealed class Names + { + private int counter; + + public string New(string prefix) => prefix + (++counter).ToString(CultureInfo.InvariantCulture); + } + /// /// Where an expression is evaluated: the C# names of the context node, position and size, and of the current /// node of XSLT; the variables in scope; the element whose namespace declarations resolve its prefixes. /// internal sealed class Context { - public Context(string node, string position, string size, string current, Scope scope, XElement element) + private readonly string node; + private readonly string? position; + private readonly string? size; + private readonly string current; + private readonly Focus focus; + private readonly Focus currentFocus; + + public Context(string node, NodeKind nodeKind, string? position, string? size, Focus focus, string current, NodeKind currentKind, Focus currentFocus, + Scope scope, XElement element, Names names, Purity purity) { - Node = node; - Position = position; - Size = size; - Current = current; + this.node = node; + NodeKind = nodeKind; + this.position = position; + this.size = size; + this.focus = focus; + this.current = current; + CurrentKind = currentKind; + this.currentFocus = currentFocus; Scope = scope; Element = element; + Names = names; + Purity = purity; + } + + public string Node + { + get + { + focus.NodeUsed = true; + return node; + } } - public string Node { get; } + public NodeKind NodeKind { get; } - public string Position { get; } + public string Position + { + get + { + focus.PositionUsed = true; + return position ?? throw new XsltException("The position of the context is not known here."); + } + } + + public string Size + { + get + { + focus.SizeUsed = true; + return size ?? throw new XsltException("The size of the context is not known here."); + } + } - public string Size { get; } + public string Current + { + get + { + currentFocus.CurrentUsed = true; + return current; + } + } - public string Current { get; } + public NodeKind CurrentKind { get; } public Scope Scope { get; } public XElement Element { get; } - public Context WithFocus(string node, string position, string size) => new Context(node, position, size, Current, Scope, Element); + public Names Names { get; } - public Context WithScope(Scope scope) => new Context(Node, Position, Size, Current, scope, Element); + public Purity Purity { get; } - public Context WithElement(XElement element) => new Context(Node, Position, Size, Current, Scope, element); + public Context WithFocus(string node, NodeKind kind, string? position, string? size, Focus focus) => + new Context(node, kind, position, size, focus, current, CurrentKind, currentFocus, Scope, Element, Names, Purity); + + public Context WithScope(Scope scope) => + new Context(node, NodeKind, position, size, focus, current, CurrentKind, currentFocus, scope, Element, Names, Purity); + + public Context WithElement(XElement element) => + new Context(node, NodeKind, position, size, focus, current, CurrentKind, currentFocus, Scope, element, Names, Purity); } - /// XPath 1.0 expressions compiled to C# expressions over XPathFunctions. + /// + /// XPath 1.0 expressions compiled to C#: the values as expressions over XPathFunctions, the location paths + /// as loops over the nodes, in the method that uses them or in a method of their own. + /// internal sealed class XPathCompiler { private readonly StylesheetCompiler stylesheet; @@ -110,27 +309,89 @@ public Compiled Compile(Expr expr, Context context) return new Compiled(Number(number.Value), XType.Number); case VariableExpr variable: { - var found = context.Scope.Find(variable.Name) ?? stylesheet.Global(variable.Name) - ?? throw new XsltException("The variable $" + variable.Name + " is not declared."); - return new Compiled(found.Id, found.Type); + var found = Find(variable, context); + return found.Type == XType.NodeSet || found.Type == XType.Node ? new Compiled(this, expr, context) : new Compiled(found.Id, found.Type); } case NegateExpr negate: return new Compiled("(-" + Num(Compile(negate.Operand, context)) + ")", XType.Number); + case BinaryExpr union when union.Op == "|": + return new Compiled(this, expr, context); case BinaryExpr binary: return CompileBinary(binary, context); + case FunctionExpr function when function.Name == "current": + if (function.Arguments.Count != 0) + throw new XsltException("Wrong number of arguments to current()."); + return new Compiled(this, expr, context); case FunctionExpr function: return CompileFunction(function, context); - case FilterExpr filter: + case FilterExpr _: + case PathExpr _: + return new Compiled(this, expr, context); + default: + throw new XsltException("Unknown expression."); + } + } + + private Variable Find(VariableExpr variable, Context context) => + context.Scope.Find(variable.Name) ?? stylesheet.Global(variable.Name, context.Purity) + ?? throw new XsltException("The variable $" + variable.Name + " is not declared."); + + /// The type of the value of an expression, without compiling it. + public XType TypeOf(Expr expr, Context context) + { + switch (expr) + { + case LiteralExpr _: + return XType.String; + case NumberExpr _: + case NegateExpr _: + return XType.Number; + case VariableExpr variable: { - var code = Nodes(Compile(filter.Primary, context)); - foreach (var predicate in filter.Predicates) - code = "Filter(" + code + ", " + Predicate(predicate, context) + ")"; - return new Compiled(code, XType.NodeSet); + var type = Find(variable, context).Type; + return type == XType.Node ? XType.NodeSet : type; } - case PathExpr path: - return CompilePath(path, context); + case BinaryExpr binary: + switch (binary.Op) + { + case "|": + return XType.NodeSet; + case "+": + case "-": + case "*": + case "div": + case "mod": + return XType.Number; + default: + return XType.Boolean; + } + case FunctionExpr function: + switch (function.Name) + { + case "last": + case "position": + case "count": + case "sum": + case "string-length": + case "number": + case "floor": + case "ceiling": + case "round": + return XType.Number; + case "starts-with": + case "contains": + case "boolean": + case "not": + case "true": + case "false": + return XType.Boolean; + case "current": + return XType.NodeSet; + default: + return XType.String; + } default: - throw new XsltException("Unknown expression."); + return XType.NodeSet; } } @@ -152,19 +413,21 @@ private Compiled CompileBinary(BinaryExpr binary, Context context) return new Compiled("(" + Num(left) + " / " + Num(right) + ")", XType.Number); case "mod": return new Compiled("(" + Num(left) + " % " + Num(right) + ")", XType.Number); - case "|": - return new Compiled("Union(" + Nodes(left) + ", " + Nodes(right) + ")", XType.NodeSet); default: return CompileComparison(binary.Op, left, right); } } - private static bool IsNodes(XType type) => type == XType.NodeSet || type == XType.Fragment || type == XType.Any; + private static bool IsUnknown(XType type) => type == XType.Any || type == XType.None; - private static Compiled CompileComparison(string op, Compiled left, Compiled right) + private Compiled CompileComparison(string op, Compiled left, Compiled right) { - if (IsNodes(left.Type) || IsNodes(right.Type)) - return new Compiled("Compare(" + Box(left) + ", " + Box(right) + ", Comparison." + ComparisonName(op) + ")", XType.Boolean); + if (left.IsNodeSet && !right.IsNodeSet && !IsUnknown(right.Type)) + return new Compiled(CompareNodes(left, op, right, false), XType.Boolean); + if (right.IsNodeSet && !left.IsNodeSet && !IsUnknown(left.Type)) + return new Compiled(CompareNodes(right, op, left, true), XType.Boolean); + if (left.IsNodeSet || right.IsNodeSet || IsUnknown(left.Type) || IsUnknown(right.Type)) + return new Compiled("Compare(" + Operand(left) + ", " + Operand(right) + ", Comparison." + ComparisonName(op) + ")", XType.Boolean); var csharp = op == "=" ? "==" : op; string code; if (op != "=" && op != "!=") @@ -178,6 +441,40 @@ private static Compiled CompileComparison(string op, Compiled left, Compiled rig return new Compiled(code, XType.Boolean); } + /// + /// A node-set compared with a value that is not one: whether a node compares so, by its string value (with a + /// boolean, the node-set as a boolean). + /// + private string CompareNodes(Compiled nodes, string op, Compiled other, bool swapped) + { + var csharp = op == "=" ? "==" : op; + var equality = op == "=" || op == "!="; + if (other.Type == XType.Boolean) + { + var exists = Bool(nodes); + if (equality) + return "(" + exists + " " + csharp + " " + other.Code + ")"; + var number = "(" + exists + " ? 1D : 0D)"; + var otherNumber = "(" + other.Code + " ? 1D : 0D)"; + return "(" + (swapped ? otherNumber + " " + csharp + " " + number : number + " " + csharp + " " + otherNumber) + ")"; + } + + var numeric = other.Type == XType.Number || !equality; + var argument = numeric ? Num(other) : Str(other); + return Helper("bool", nodes.Context!, (context, w) => + { + EmitNodes(nodes.Expr!, context, w, cursor => + { + var value = numeric ? "Num(" + StringValue(cursor) + ")" : StringValue(cursor); + w.Line("if (" + (swapped ? "o " + csharp + " " + value : value + " " + csharp + " o") + ")").Line(" return true;"); + }); + w.Line("return false;"); + }, numeric ? "double" : "string", argument); + } + + // a string or a number is compared with a value of unknown type as it is + private static string Operand(Compiled value) => value.Type == XType.String || value.Type == XType.Number ? value.Code : Box(value); + private static string ComparisonName(string op) { switch (op) @@ -206,7 +503,7 @@ void Arity(int min, int max) throw new XsltException("Wrong number of arguments to " + function.Name + "()."); } - string StringArgument(int i) => args.Count > i ? Str(args[i]) : "StringValue(" + context.Node + ")"; + string StringArgument(int i) => args.Count > i ? Str(args[i]) : StringValue(new Cursor(context.Node, context.NodeKind)); switch (function.Name) { @@ -218,7 +515,7 @@ void Arity(int min, int max) return new Compiled("((double)" + context.Position + ")", XType.Number); case "count": Arity(1, 1); - return new Compiled("Count(" + Nodes(args[0]) + ")", XType.Number); + return new Compiled(args[0].IsNodeSet ? "((double)" + CountCode(args[0].Expr!, args[0].Context!) + ")" : "Count(" + Nodes(args[0]) + ")", XType.Number); case "sum": Arity(1, 1); return new Compiled("Sum(" + Nodes(args[0]) + ")", XType.Number); @@ -228,7 +525,14 @@ void Arity(int min, int max) { Arity(0, 1); var method = function.Name == "local-name" ? "LocalName" : function.Name == "name" ? "Name" : "NamespaceUri"; - return new Compiled(method + "(" + (args.Count == 0 ? context.Node : Nodes(args[0])) + ")", XType.String); + string node; + if (args.Count == 0) + node = context.Node; + else if (args[0].IsNodeSet) + node = FirstNodeCode(args[0].Expr!, args[0].Context!); + else + node = Nodes(args[0]); + return new Compiled(method + "(" + node + ")", XType.String); } case "string": Arity(0, 1); @@ -260,7 +564,9 @@ void Arity(int min, int max) return new Compiled("NormalizeSpace(" + StringArgument(0) + ")", XType.String); case "translate": Arity(3, 3); - return new Compiled("Translate(" + Str(args[0]) + ", " + Str(args[1]) + ", " + Str(args[2]) + ")", XType.String); + if (function.Arguments[1] is LiteralExpr from && function.Arguments[2] is LiteralExpr to) + return new Compiled(stylesheet.StaticField("TranslateMap", "new TranslateMap(" + Literal(from.Value) + ", " + Literal(to.Value) + ")") + ".Apply(" + Str(args[0]) + ")", XType.String); + return new Compiled("Translate(" + Str(args[0]) + ", " + Str(args[1]) + ", " + Str(args[2]) + ", ref " + stylesheet.CacheField("TranslateMap?") + ")", XType.String); case "boolean": Arity(1, 1); return new Compiled(Bool(args[0]), XType.Boolean); @@ -275,7 +581,7 @@ void Arity(int min, int max) return new Compiled("false", XType.Boolean); case "number": Arity(0, 1); - return new Compiled(args.Count == 0 ? "Num(StringValue(" + context.Node + "))" : Num(args[0]), XType.Number); + return new Compiled(args.Count == 0 ? "Num(" + StringArgument(0) + ")" : Num(args[0]), XType.Number); case "floor": Arity(1, 1); return new Compiled("System.Math.Floor(" + Num(args[0]) + ")", XType.Number); @@ -285,105 +591,801 @@ void Arity(int min, int max) case "round": Arity(1, 1); return new Compiled("Round(" + Num(args[0]) + ")", XType.Number); - case "current": - Arity(0, 0); - return new Compiled("Single(" + context.Current + ")", XType.NodeSet); default: throw new XsltException("The function " + function.Name + "() is not supported."); } } - private Compiled CompilePath(PathExpr path, Context context) + // node-sets: what the use of one needs + + /// The list of the nodes, in document order. + public string NodesCode(Expr expr, Context context) { - string code; - if (path.Start != null) - code = Nodes(Compile(path.Start, context)); - else if (path.Absolute) - code = "Root(" + context.Node + ")"; - else - code = "Single(" + context.Node + ")"; + switch (expr) + { + case VariableExpr variable: + { + var found = Find(variable, context); + return found.Type == XType.NodeSet ? found.Id : Nodes(new Compiled(found.Id, found.Type)); + } + case ListExpr list: + return list.Code; + case BinaryExpr union when union.Op == "|": + return "Union(" + NodesCode(union.Left, context) + ", " + NodesCode(union.Right, context) + ")"; + case FilterExpr _: + case PathExpr _: + case FunctionExpr _ when IsCurrent(expr): + { + var order = OrderOf(expr, context); + return Helper("List", context, (inner, w) => + { + // the nodes reached from one node are in order when those from more are not + if (order == NodeOrder.Unordered && expr is PathExpr path && path.Start != null + && IsStreamed(Steps(path, inner).Aggregate(NodeOrder.Single, (o, step) => After(o, step, inner)))) + { + w.Line("var start = " + NodesCode(path.Start, inner) + ";"); + w.Line("var list = new List();"); + EmitNodes(new PathExpr(new ListExpr("start"), false, path.Steps), inner, w, cursor => w.Line("list.Add(" + cursor.Var + ");")); + w.Line("if (start.Count > 1)").Line(" SortUnique(list);"); + w.Line("return list;"); + return; + } + if (order == NodeOrder.Single) + { + EmitNodes(expr, inner, w, cursor => w.Line("return Single(" + cursor.Var + ");")); + w.Line("return Empty;"); + return; + } + w.Line("var list = new List();"); + EmitNodes(expr, inner, w, cursor => w.Line("list.Add(" + cursor.Var + ");")); + switch (order) + { + case NodeOrder.Reversed: + w.Line("list.Reverse();"); + break; + case NodeOrder.Unordered: + w.Line("SortUnique(list);"); + break; + } + w.Line("return list;"); + }); + } + default: + return Nodes(Compile(expr, context)); + } + } + + private static bool IsCurrent(Expr expr) => expr is FunctionExpr function && function.Name == "current"; + + public static bool IsStreamed(NodeOrder order) => order == NodeOrder.Single || order == NodeOrder.Disjoint || order == NodeOrder.Ordered; + + /// Whether the node-set has a node. + private string ExistsCode(Expr expr, Context context) + { + switch (expr) + { + case VariableExpr variable when Find(variable, context).Type == XType.Node: + return "(" + Find(variable, context).Id + " != null)"; + case VariableExpr _: + return "(" + NodesCode(expr, context) + ".Count > 0)"; + case BinaryExpr union when union.Op == "|": + return "(" + ExistsCode(union.Left, context) + " || " + ExistsCode(union.Right, context) + ")"; + default: + return Helper("bool", context, (inner, w) => + { + EmitNodes(expr, inner, w, cursor => w.Line("return true;")); + w.Line("return false;"); + }); + } + } + + /// The first node in document order, or null. + public string FirstNodeCode(Expr expr, Context context) + { + var order = OrderOf(expr, context); + if (IsNode(expr, context, out var node)) + return node; + if (expr is PathExpr self && self.Start == null && !self.Absolute && Steps(self, context).Count == 0) + return context.Node; + if (expr is VariableExpr || expr is BinaryExpr || order == NodeOrder.Unordered) + return "First(" + NodesCode(expr, context) + ")"; + return Helper("XObject?", context, (inner, w) => + { + if (order == NodeOrder.Reversed) + { + w.Line("XObject? first = null;"); + EmitNodes(expr, inner, w, cursor => w.Line("first = " + cursor.Var + ";")); + w.Line("return first;"); + return; + } + EmitNodes(expr, inner, w, cursor => w.Line("return " + cursor.Var + ";")); + w.Line("return null;"); + }); + } + + /// The string value of the first node in document order, or the empty string. + private string FirstStringCode(Expr expr, Context context) + { + var order = OrderOf(expr, context); + if (IsNode(expr, context, out var node)) + return "StringValue(" + node + ")"; + if (expr is VariableExpr || expr is BinaryExpr || order == NodeOrder.Unordered) + return "Str(" + NodesCode(expr, context) + ")"; + return Helper("string", context, (inner, w) => + { + if (order == NodeOrder.Reversed) + { + w.Line("XObject? first = null;"); + EmitNodes(expr, inner, w, cursor => w.Line("first = " + cursor.Var + ";")); + w.Line("return first == null ? \"\" : StringValue(first);"); + return; + } + EmitNodes(expr, inner, w, cursor => w.Line("return " + StringValue(cursor) + ";")); + w.Line("return \"\";"); + }); + } + + // a variable that holds a node-set as its node + private bool IsNode(Expr expr, Context context, out string node) + { + node = ""; + if (!(expr is VariableExpr variable)) + return false; + var found = Find(variable, context); + node = found.Id; + return found.Type == XType.Node; + } + + private string CountCode(Expr expr, Context context) + { + if (IsNode(expr, context, out var node)) + return "(" + node + " == null ? 0 : 1)"; + if (expr is VariableExpr || expr is BinaryExpr || OrderOf(expr, context) == NodeOrder.Unordered) + return NodesCode(expr, context) + ".Count"; + return Helper("int", context, (inner, w) => + { + w.Line("var count = 0;"); + EmitNodes(expr, inner, w, cursor => w.Line("count++;")); + w.Line("return count;"); + }); + } + + /// + /// A method for an expression, whose parameters are what the expression reads of its context (the node, the + /// position, the size, the current node) and the variables it reads; the code that calls it. + /// + private string Helper(string returnType, Context context, System.Action emit, string? extraType = null, string? extraArgument = null) + { + var scope = new CapturingScope(context.Scope); + var focus = new Focus(); + var inner = new Context("n", context.NodeKind, "p", "s", focus, "c", context.CurrentKind, focus, scope, context.Element, new Names(), context.Purity); + var body = new CodeWriter(3); + emit(inner, body); + + var parameters = new List(); + var arguments = new List(); + if (focus.NodeUsed) + { + parameters.Add(TypeName(context.NodeKind) + " n"); + arguments.Add(context.Node); + } + if (focus.PositionUsed) + { + parameters.Add("int p"); + arguments.Add(context.Position); + } + if (focus.SizeUsed) + { + parameters.Add("int s"); + arguments.Add(context.Size); + } + if (focus.CurrentUsed) + { + parameters.Add(TypeName(context.CurrentKind) + " c"); + arguments.Add(context.Current); + } + foreach (var variable in scope.Captured) + { + parameters.Add(TypeName(variable.Type) + " " + variable.Id); + arguments.Add(variable.Id); + } + if (extraType != null) + { + parameters.Add(extraType + " o"); + arguments.Add(extraArgument!); + } + return stylesheet.HelperMethod(returnType, string.Join(", ", parameters), body.ToString()) + "(" + string.Join(", ", arguments) + ")"; + } + + // node-sets: the loops + + /// The order in which reaches the nodes of the expression. + public NodeOrder OrderOf(Expr expr, Context context) + { + switch (expr) + { + case VariableExpr variable: + { + var found = Find(variable, context); + if (found.Type == XType.Node) + return NodeOrder.Single; + return found.Type == XType.NodeSet ? found.Order : NodeOrder.Ordered; + } + case FunctionExpr _ when IsCurrent(expr): + return NodeOrder.Single; + case FilterExpr filter: + return OrderOf(filter.Primary, context) == NodeOrder.Single || filter.Predicates.Any(p => SelectsOne(p, context)) ? NodeOrder.Single : NodeOrder.Ordered; + case PathExpr path: + { + var order = path.Start == null ? NodeOrder.Single : OrderOf(path.Start, context); + foreach (var step in Steps(path, context)) + order = After(order, step, context); + return order; + } + default: + return NodeOrder.Ordered; + } + } - var steps = path.Steps; - for (var i = 0; i < steps.Count; i++) + private NodeOrder After(NodeOrder order, Step step, Context context) + { + if (step.Axis == "self") + return order; + var one = step.Predicates.Any(p => SelectsOne(p, context)); + switch (order) + { + case NodeOrder.Single: + switch (step.Axis) + { + case "parent": + return NodeOrder.Single; + case "attribute" when step.Test.Kind == TestKind.Name: + return NodeOrder.Single; + case "child": + case "attribute": + case "following-sibling": + return one ? NodeOrder.Single : NodeOrder.Disjoint; + case "descendant": + case "descendant-or-self": + return one ? NodeOrder.Single : NodeOrder.Ordered; + case "preceding-sibling" when !IsReverse(step, context): + return NodeOrder.Disjoint; + default: + return one ? NodeOrder.Single : NodeOrder.Reversed; + } + case NodeOrder.Disjoint: + switch (step.Axis) + { + case "child": + case "attribute": + return NodeOrder.Disjoint; + case "descendant": + case "descendant-or-self": + return NodeOrder.Ordered; + default: + return NodeOrder.Unordered; + } + case NodeOrder.Ordered when step.Axis == "attribute": + return NodeOrder.Ordered; + default: + return NodeOrder.Unordered; + } + } + + // a predicate that keeps at most one node of those of a context node: a position + private bool SelectsOne(Expr predicate, Context context) => IsLast(predicate) || TypeOf(predicate, context) == XType.Number; + + private static bool IsLast(Expr predicate) => predicate is FunctionExpr function && function.Name == "last" && function.Arguments.Count == 0; + + // the nodes before a node are reached from the nearest, as their positions count, only when a predicate counts them + private bool IsReverse(Step step, Context context) + { + switch (step.Axis) { - var step = steps[i]; + case "ancestor": + case "ancestor-or-self": + return true; + case "preceding-sibling": + return step.Predicates.Any(p => IsPositional(p, context)); + default: + return false; + } + } + + /// The C# type of the variables that hold the nodes of the expression. + public NodeKind KindOf(Expr expr, Context context) + { + switch (expr) + { + case FunctionExpr _ when IsCurrent(expr): + return context.CurrentKind; + case PathExpr path: + { + NodeKind kind; + if (path.Start != null) + kind = KindOf(path.Start, context); + else + kind = path.Absolute ? NodeKind.Object : context.NodeKind; + foreach (var step in Steps(path, context)) + kind = KindAfter(step, kind, context); + return kind; + } + default: + return NodeKind.Object; + } + } + + private NodeKind KindAfter(Step step, NodeKind kind, Context context) => + Test(step.Test, step.Axis == "attribute", new Cursor("node", AxisKind(step.Axis, kind)), false, context).Cursor.Kind; + + private static NodeKind AxisKind(string axis, NodeKind kind) + { + switch (axis) + { + case "child": + case "following-sibling": + case "preceding-sibling": + return NodeKind.Node; + case "attribute": + return NodeKind.Attribute; + case "self": + return kind; + default: + return NodeKind.Object; + } + } + + /// + /// The steps of a path as they are compiled: without the steps that do nothing, and with //name as + /// descendant::name when its predicates do not count positions among the children. + /// + private List Steps(PathExpr path, Context context) + { + var steps = new List(); + for (var i = 0; i < path.Steps.Count; i++) + { + var step = path.Steps[i]; if (step.Axis == "self" && step.Test.Kind == TestKind.Node && step.Predicates.Count == 0) continue; - - // //name: descendant::name, when the predicates do not count positions among the children - if (step.IsDescendantOrSelfNode && i + 1 < steps.Count && steps[i + 1].Axis == "child" - && steps[i + 1].Predicates.All(p => !IsPositional(p, context))) + if (step.IsDescendantOrSelfNode && i + 1 < path.Steps.Count && path.Steps[i + 1].Axis == "child" + && path.Steps[i + 1].Predicates.All(p => !IsPositional(p, context))) { - var next = steps[i + 1]; - code = StepCode(code, "Descendant", next.Test, next.Predicates, false, context); + var next = path.Steps[i + 1]; + steps.Add(new Step("descendant", next.Test, next.Predicates)); i++; continue; } - code = StepCode(code, AxisName(step.Axis), step.Test, step.Predicates, step.Axis == "attribute", context); + steps.Add(step); + } + return steps; + } + + /// + /// Writes the loops over the nodes of the expression, and what writes where a node is + /// reached. The nodes are reached in the order tells, held as tells. + /// + public void EmitNodes(Expr expr, Context context, CodeWriter w, System.Action inner) + { + var names = context.Names; + switch (expr) + { + case PathExpr path: + { + var steps = Steps(path, context); + void Chain(int i, Cursor cursor) + { + if (i == steps.Count) + inner(cursor); + else + EmitStep(cursor, steps[i], context, w, next => Chain(i + 1, next)); + } + + if (path.Start != null) + { + EmitNodes(path.Start, context, w, cursor => Chain(0, cursor)); + } + else if (path.Absolute) + { + var root = names.New("x_"); + w.Line("var " + root + " = RootNode(" + context.Node + ");"); + Chain(0, new Cursor(root, NodeKind.Object)); + } + else + { + Chain(0, new Cursor(context.Node, context.NodeKind)); + } + break; + } + case FilterExpr filter: + EmitFiltered(NodeKind.Object, true, source => EmitList(NodesCode(filter.Primary, context), context, w, source), filter.Predicates, context, w, inner); + break; + case FunctionExpr _ when IsCurrent(expr): + inner(new Cursor(context.Current, context.CurrentKind)); + break; + case VariableExpr _ when IsNode(expr, context, out var node): + w.Open("if (" + node + " != null)"); + inner(new Cursor(node, NodeKind.Object)); + w.Close(); + break; + default: + EmitList(NodesCode(expr, context), context, w, inner); + break; + } + } + + private static void EmitList(string list, Context context, CodeWriter w, System.Action inner) + { + var l = context.Names.New("l_"); + var i = context.Names.New("i_"); + var x = context.Names.New("x_"); + w.Line("var " + l + " = " + list + ";"); + w.Open("for (var " + i + " = 0; " + i + " < " + l + ".Count; " + i + "++)"); + w.Line("var " + x + " = " + l + "[" + i + "];"); + inner(new Cursor(x, NodeKind.Object)); + w.Close(); + } + + public void EmitStep(Cursor cursor, Step step, Context context, CodeWriter w, System.Action inner) + { + var reverse = IsReverse(step, context); + EmitFiltered(KindAfter(step, cursor.Kind, context), IsLoop(step), source => EmitAxis(cursor, step, reverse, context, w, source), step.Predicates, context, w, inner); + } + + private static bool IsDeclaration(NodeTestSyntax test) => test.Prefix == "xmlns" || (test.Prefix == null && test.Local == "xmlns"); + + // whether the nodes of the step are reached in a loop, which a break ends + private static bool IsLoop(Step step) + { + switch (step.Axis) + { + case "self": + case "parent": + return false; + case "attribute": + return step.Test.Kind != TestKind.Name || IsDeclaration(step.Test); + default: + return true; + } + } + + private sealed class Predicate + { + public Predicate(string position, string size) + { + Position = position; + Size = size; } - return new Compiled(code, XType.NodeSet); + + public string Position { get; } + + public string Size { get; } + + public string Code { get; set; } = ""; + + public bool Positional { get; set; } + + public bool IsLast { get; set; } + + public bool NeedsSize { get; set; } + + public bool CanBreak { get; set; } } - private string StepCode(string input, string axis, NodeTestSyntax test, List predicates, bool attributeAxis, Context context) + /// + /// The nodes of a source that pass the predicates. A predicate that counts positions counts them among the + /// nodes that passed the ones before it; one that reads the size has them counted first. + /// + public void EmitFiltered(NodeKind kind, bool isLoop, System.Action> source, List predicates, Context context, CodeWriter w, System.Action inner) { - var testField = stylesheet.NodeTest(test, context.Element); if (predicates.Count == 0) - return "Step(" + input + ", Axis." + axis + ", " + testField + ")"; - return "Step(" + input + ", Axis." + axis + ", " + testField + ", " + string.Join(", ", predicates.Select(p => Predicate(p, context))) + ")"; + { + source(inner); + return; + } + + var names = context.Names; + var node = names.New("m_"); + var compiled = new List(); + foreach (var expr in predicates) + { + var predicate = new Predicate(names.New("pos_"), names.New("size_")) { Positional = IsPositional(expr, context), IsLast = IsLast(expr) }; + if (!predicate.IsLast) + { + var focus = new Focus(); + var value = Compile(expr, context.WithFocus(node, kind, predicate.Position, predicate.Size, focus)); + switch (value.Type) + { + case XType.Number: + predicate.Code = predicate.Position + " == " + value.Code; + predicate.CanBreak = !focus.NodeUsed && !focus.PositionUsed; + break; + case XType.Any: + case XType.None: + predicate.Code = "Predicate(" + value.Code + ", " + predicate.Position + ")"; + break; + default: + predicate.Code = Bool(value); + break; + } + predicate.NeedsSize = focus.SizeUsed; + } + compiled.Add(predicate); + } + + // whether the nodes that pass the first predicates are reached in the loop of the source + bool InLoop(int count) => isLoop && !compiled.Take(count).Any(p => p.Positional && p.IsLast); + + void Passing(int count, System.Action body) + { + if (count == 0) + { + source(cursor => + { + w.Line("var " + node + " = " + cursor.Var + ";"); + body(); + }); + return; + } + + var predicate = compiled[count - 1]; + if (!predicate.Positional) + { + Passing(count - 1, () => + { + w.Open("if (" + predicate.Code + ")"); + body(); + w.Close(); + }); + return; + } + + if (predicate.IsLast) + { + var last = names.New("last_"); + w.Line(TypeName(kind) + "? " + last + " = null;"); + Passing(count - 1, () => w.Line(last + " = " + node + ";")); + w.Open("if (" + last + " != null)"); + w.Line("var " + node + " = " + last + ";"); + body(); + w.Close(); + return; + } + + // the two passes declare the same variables, each in a block of its own + if (predicate.NeedsSize) + { + w.Line("var " + predicate.Size + " = 0;"); + w.Open(); + Passing(count - 1, () => w.Line(predicate.Size + "++;")); + w.Close(); + w.Open(); + } + w.Line("var " + predicate.Position + " = 0;"); + Passing(count - 1, () => + { + w.Line(predicate.Position + "++;"); + w.Open("if (" + predicate.Code + ")"); + body(); + if (predicate.CanBreak && InLoop(count - 1)) + w.Line("break;"); + w.Close(); + }); + if (predicate.NeedsSize) + w.Close(); + } + + Passing(compiled.Count, () => inner(new Cursor(node, kind))); } - public static string AxisName(string axis) + /// The nodes of an axis from a node that pass the node test of the step. + private void EmitAxis(Cursor cursor, Step step, bool reverse, Context context, CodeWriter w, System.Action inner) { - switch (axis) + var names = context.Names; + var c = cursor.Var; + var leaf = cursor.Kind == NodeKind.Attribute || cursor.Kind == NodeKind.Text; + + void Pass(Cursor candidate, bool attributeAxis, bool amongChildren, string? condition = null) + { + var tested = Test(step.Test, attributeAxis, candidate, amongChildren, context); + if (tested.Never) + return; + var all = condition == null ? tested.Condition : tested.Condition == null ? condition : condition + " && " + tested.Condition; + if (all == null) + { + inner(tested.Cursor); + return; + } + w.Open("if (" + all + ")"); + inner(tested.Cursor); + w.Close(); + } + + void Loop(string type, string first, string next, bool amongChildren, NodeKind kind, string? until = null) + { + var x = names.New("x_"); + w.Open("for (" + type + " " + x + " = " + first + "; " + x + " != null" + (until == null ? "" : " && !ReferenceEquals(" + x + ", " + until + ")") + "; " + x + " = " + string.Format(CultureInfo.InvariantCulture, next, x) + ")"); + Pass(new Cursor(x, kind), false, amongChildren); + w.Close(); + } + + var typed = cursor.Kind == NodeKind.Element || cursor.Kind == NodeKind.Node || cursor.Kind == NodeKind.Text; + switch (step.Axis) { case "child": - return "Child"; + if (!leaf) + Loop("var", cursor.Kind == NodeKind.Element ? c + ".FirstNode" : "FirstChild(" + c + ")", "{0}.NextNode", true, NodeKind.Node); + break; case "descendant": - return "Descendant"; + if (!leaf) + Loop("XObject?", "FirstChild(" + c + ")", "NextInTree({0}, " + c + ")", true, NodeKind.Object); + break; case "descendant-or-self": - return "DescendantOrSelf"; - case "parent": - return "Parent"; - case "ancestor": - return "Ancestor"; - case "ancestor-or-self": - return "AncestorOrSelf"; + Loop("XObject?", c, "NextInTree({0}, " + c + ")", true, NodeKind.Object); + break; case "following-sibling": - return "FollowingSibling"; + if (cursor.Kind != NodeKind.Attribute) + Loop("var", typed ? c + ".NextNode" : "NextSibling(" + c + ")", "{0}.NextNode", true, NodeKind.Node); + break; + case "preceding-sibling" when cursor.Kind == NodeKind.Attribute: + break; + case "preceding-sibling" when reverse: + Loop("var", typed ? c + ".PreviousNode" : "PreviousSibling(" + c + ")", "{0}.PreviousNode", true, NodeKind.Node); + break; case "preceding-sibling": - return "PrecedingSibling"; + Loop("var", "FirstSibling(" + c + ")", "{0}.NextNode", true, NodeKind.Node, c); + break; + case "ancestor": + Loop("var", "Parent(" + c + ")", "Parent({0})", false, NodeKind.Object); + break; + case "ancestor-or-self": + Loop("XObject?", c, "Parent({0})", false, NodeKind.Object); + break; + case "parent": + { + var parent = names.New("x_"); + w.Line("var " + parent + " = Parent(" + c + ");"); + Pass(new Cursor(parent, NodeKind.Object), false, false, parent + " != null"); + break; + } case "self": - return "Self"; + Pass(cursor, false, false); + break; case "attribute": - return "Attribute"; + { + if (leaf) + break; + var element = c; + if (cursor.Kind != NodeKind.Element) + { + element = names.New("e_"); + w.Open("if (" + c + " is XElement " + element + ")"); + } + var attribute = names.New("a_"); + if (IsLoop(step)) + { + w.Open("for (var " + attribute + " = " + element + ".FirstAttribute; " + attribute + " != null; " + attribute + " = " + attribute + ".NextAttribute)"); + Pass(new Cursor(attribute, NodeKind.Attribute), true, false, "!" + attribute + ".IsNamespaceDeclaration"); + w.Close(); + } + else + { + w.Line("var " + attribute + " = " + element + ".Attribute(" + stylesheet.Name(XName.Get(step.Test.Local!, StylesheetCompiler.Namespace(step.Test.Prefix, context.Element))) + ");"); + w.Open("if (" + attribute + " != null)"); + inner(new Cursor(attribute, NodeKind.Attribute)); + w.Close(); + } + if (cursor.Kind != NodeKind.Element) + w.Close(); + break; + } default: - throw new XsltException("The axis " + axis + " is not supported."); + throw new XsltException("The axis " + step.Axis + " is not supported."); } } - /// A lambda of XPathPredicate: the predicate evaluated with the node, its position and the size. - public string Predicate(Expr predicate, Context context) + internal readonly struct Tested { - var n = stylesheet.NewName("n"); - var p = stylesheet.NewName("p"); - var s = stylesheet.NewName("s"); - var value = Compile(predicate, context.WithFocus(n, p, s)); - string body; - switch (value.Type) + public Tested(string? condition, Cursor cursor, bool never = false) { - case XType.Number: - body = p + " == " + value.Code; - break; - case XType.Any: - body = "Predicate(" + value.Code + ", " + p + ")"; - break; + Condition = condition; + Cursor = cursor; + Never = never; + } + + /// The C# condition of the test, or null when every node passes. + public string? Condition { get; } + + /// The node, as the type the test tells it has. + public Cursor Cursor { get; } + + /// Whether no node passes. + public bool Never { get; } + } + + /// + /// A node test on a node: on the attribute axis, whose nodes are attributes, or on another axis, where a name + /// is the name of an element. Among the children of a node, those of a document that are no nodes of XPath + /// (the document type, the white space) do not pass. + /// + public Tested Test(NodeTestSyntax test, bool attributeAxis, Cursor cursor, bool amongChildren, Context context) + { + var c = cursor.Var; + if (attributeAxis) + { + switch (test.Kind) + { + case TestKind.Name: + return new Tested(c + ".Name == " + stylesheet.Name(XName.Get(test.Local!, StylesheetCompiler.Namespace(test.Prefix, context.Element))), cursor); + case TestKind.Namespace: + return new Tested(c + ".Name.Namespace == " + NamespaceField(test, context), cursor); + case TestKind.AnyName: + case TestKind.Node: + return new Tested(null, cursor); + default: + return new Tested(null, cursor, true); + } + } + + var leaf = cursor.Kind == NodeKind.Attribute || cursor.Kind == NodeKind.Text; + switch (test.Kind) + { + case TestKind.Name: + case TestKind.Namespace: + case TestKind.AnyName: + { + if (leaf) + return new Tested(null, new Cursor(c, NodeKind.Element), true); + var element = c; + string? condition = null; + if (cursor.Kind != NodeKind.Element) + { + element = context.Names.New("e_"); + condition = c + " is XElement " + element; + } + string? name = null; + switch (test.Kind) + { + case TestKind.Name: + name = element + ".Name == " + stylesheet.Name(XName.Get(test.Local!, StylesheetCompiler.Namespace(test.Prefix, context.Element))); + break; + case TestKind.Namespace: + name = element + ".Name.Namespace == " + NamespaceField(test, context); + break; + } + if (name != null) + condition = condition == null ? name : condition + " && " + name; + return new Tested(condition, new Cursor(element, NodeKind.Element)); + } + case TestKind.Node: + return new Tested(amongChildren ? "IsNode(" + c + ")" : null, cursor); + case TestKind.Text: + { + switch (cursor.Kind) + { + case NodeKind.Text: + return new Tested(null, cursor); + case NodeKind.Element: + case NodeKind.Attribute: + return new Tested(null, new Cursor(c, NodeKind.Text), true); + } + var text = context.Names.New("t_"); + return new Tested(c + " is XText " + text + (amongChildren ? " && IsNode(" + text + ")" : ""), new Cursor(text, NodeKind.Text)); + } + case TestKind.Comment: + return new Tested(c + " is XComment", cursor, cursor.Kind == NodeKind.Element || leaf); default: - body = Bool(value); - break; + { + if (test.Local == null) + return new Tested(c + " is XProcessingInstruction", cursor, cursor.Kind == NodeKind.Element || leaf); + var instruction = context.Names.New("i_"); + return new Tested(c + " is XProcessingInstruction " + instruction + " && " + instruction + ".Target == " + Literal(test.Local), cursor, cursor.Kind == NodeKind.Element || leaf); + } } - return "(" + n + ", " + p + ", " + s + ") => " + body; } + private string NamespaceField(NodeTestSyntax test, Context context) => + stylesheet.StaticField("XNamespace", "XNamespace.Get(" + Literal(StylesheetCompiler.Namespace(test.Prefix, context.Element)) + ")"); + /// /// Whether the value of a predicate depends on the position of the node among the others: a number (or a /// value that may be one), or an expression that reads position() or last(). @@ -392,8 +1394,8 @@ public bool IsPositional(Expr predicate, Context context) { if (UsesFocus(predicate)) return true; - var type = Compile(predicate, context.WithFocus("n", "p", "s")).Type; - return type == XType.Number || type == XType.Any; + var type = TypeOf(predicate, context); + return type == XType.Number || IsUnknown(type); } // whether the expression reads the position or size of its context (not that of a nested predicate) @@ -420,13 +1422,56 @@ private static bool UsesFocus(Expr expr) // conversions - public static string Str(Compiled value) => value.Type == XType.String ? value.Code : "Str(" + value.Code + ")"; + public static string StringValue(Cursor cursor) => + cursor.Kind == NodeKind.Object || cursor.Kind == NodeKind.Node ? "StringValue(" + cursor.Var + ")" : cursor.Var + ".Value"; - public static string Num(Compiled value) => value.Type == XType.Number ? value.Code : "Num(" + value.Code + ")"; + public static string Str(Compiled value) + { + if (value.IsNodeSet) + return value.Owner.FirstStringCode(value.Expr!, value.Context!); + return value.Type == XType.String || value.Type == XType.Text ? value.Code : "Str(" + value.Code + ")"; + } - public static string Bool(Compiled value) => value.Type == XType.Boolean ? value.Code : "Bool(" + value.Code + ")"; + public static string Num(Compiled value) + { + if (value.IsNodeSet) + return "Num(" + Str(value) + ")"; + return value.Type == XType.Number ? value.Code : "Num(" + value.Code + ")"; + } - public static string Box(Compiled value) => "(object)" + value.Code; + public static string Bool(Compiled value) + { + if (value.IsNodeSet) + return value.Owner.ExistsCode(value.Expr!, value.Context!); + switch (value.Type) + { + case XType.Boolean: + return value.Code; + // a result tree fragment is a node-set of its root node + case XType.Text: + case XType.Fragment: + return "true"; + default: + return "Bool(" + value.Code + ")"; + } + } + + /// The value as an object: one of unknown type, as the value of a parameter of unknown type. + public static string Box(Compiled value) + { + switch (value.Type) + { + case XType.Text: + return "new ResultFragment(" + value.Code + ")"; + case XType.Node: + return "ListOf(" + value.Code + ")"; + case XType.Any: + case XType.None: + return value.Code; + default: + return "(object)" + value.Code; + } + } public static string Nodes(Compiled value) { @@ -434,8 +1479,12 @@ public static string Nodes(Compiled value) { case XType.NodeSet: return value.Code; + case XType.Node: + return "ListOf(" + value.Code + ")"; case XType.Any: + case XType.None: return "Nodes(" + value.Code + ")"; + case XType.Text: case XType.Fragment: throw new XsltException("A result tree fragment cannot be used as a node-set in XSLT 1.0."); default: @@ -448,11 +1497,14 @@ public static string TypeName(XType type) switch (type) { case XType.String: + case XType.Text: return "string"; case XType.Number: return "double"; case XType.Boolean: return "bool"; + case XType.Node: + return "XObject"; case XType.NodeSet: return "List"; case XType.Fragment: @@ -462,6 +1514,23 @@ public static string TypeName(XType type) } } + public static string TypeName(NodeKind kind) + { + switch (kind) + { + case NodeKind.Node: + return "XNode"; + case NodeKind.Element: + return "XElement"; + case NodeKind.Attribute: + return "XAttribute"; + case NodeKind.Text: + return "XText"; + default: + return "XObject"; + } + } + public static string Literal(string value) { var result = new StringBuilder("\""); diff --git a/src/SharpEquations.XsltGenerator/XsltGenerator.cs b/src/SharpEquations.XsltGenerator/XsltGenerator.cs index 1363b85..c0353c0 100644 --- a/src/SharpEquations.XsltGenerator/XsltGenerator.cs +++ b/src/SharpEquations.XsltGenerator/XsltGenerator.cs @@ -51,7 +51,16 @@ private static void Generate(SourceProductionContext output, string path, string using (var reader = XmlReader.Create(new StringReader(text), settings)) document = XDocument.Load(reader, LoadOptions.SetLineInfo | LoadOptions.PreserveWhitespace); var className = ClassName(path); - var source = new StylesheetCompiler(document.Root!, className, fileName).Compile(); + // compiled again with what each compilation learns of the templates, until nothing is new + var facts = new Facts(); + string source; + while (true) + { + var compiler = new StylesheetCompiler(document.Root!, className, fileName, facts); + source = compiler.Compile(); + if (!facts.Add(compiler.Learnt)) + break; + } output.AddSource(className + ".g.cs", SourceText.From(source, Encoding.UTF8)); } catch (XsltException e) diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs index 42bcb67..c4b0d88 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathSerializer.cs @@ -507,8 +507,9 @@ private string Binary(XElement node, string op) if (retd.Length == 0) retd = "〖〗"; - // a scripted base (𝑎_𝑏 under ^𝑐) is grouped, as 𝑎_𝑏^𝑐 is one subsup - if ((op == "^" || op == "_" || IsPrime(Text(Last(node)))) && IsScripted(First(node)) && functionName == null) + // a scripted base (𝑎_𝑏 under ^𝑐) is grouped, as 𝑎_𝑏^𝑐 is one subsup, and so is a matrix in brackets (⒨(…)), + // which a script after it would not apply to + if ((op == "^" || op == "_" || IsPrime(Text(Last(node)))) && (IsScripted(First(node)) || IsBracketedMatrix(First(node))) && functionName == null) ret = "〖" + ret.TrimEnd(' ') + "〗"; if (op == "^" && Name(Last(node)) == "msub" || op == "_" && Name(Last(node)) == "msup") return ret + op + "(" + retd + ")"; @@ -522,6 +523,14 @@ private string Binary(XElement node, string op) // a script that applies a function (log_(𝑏⁡𝑎)): the space before the function name would end the script if ((op == "^" || op == "_") && Name(Last(node)) == "mrow" && Role(Last(node)) == ":function") retd = "(" + retd.Trim(' ') + ")"; + // a denominator that applies a function (𝑎/sin⁡𝛼): a space after the fraction bar would end the fraction + if (Name(node) == "mfrac" && Name(Last(node)) == "mrow" && Role(Last(node)) == ":function") + retd = retd.TrimStart(' '); + // a denominator that ends with a script, before an operand that follows the fraction (1/(4𝜋𝜀₀) 𝑞₁𝑞₂/𝑟²): the + // space after it would end the script, not the denominator (an operator after it ends both) + if (Name(node) == "mfrac" && op == "/" && EndsWithSpacedScript(Last(node)) && node.ElementsAfterSelf().FirstOrDefault() is { } next && Name(next) != "mo" && + !(Ch(retd, 0) == "(" && RemoveOuterParens(retd) != retd)) + retd = "(" + retd.Trim(' ') + ")"; if (IsMathMLObject(node, false) && ChildCount(node) > 0) { var attr = Role(Last(node)); @@ -757,10 +766,20 @@ private static bool EndsWithScript(XElement node) return Name(node) is "msub" or "msup" or "msubsup" or "mover" or "munder" or "munderover"; } + private static bool IsBracketedMatrix(XElement node) => Name(node) == "mrow" && MatrixIntents.Any(m => m.Intent == Attr(node, "intent")); + + // a script that a space after it ends, but not what is before it: all but the superscript digits (𝑥²) + private static bool EndsWithSpacedScript(XElement node) + { + while (Name(node) == "mrow" && ChildCount(node) > 0 && !HasAttr(node, "intent")) + node = Last(node); + return IsScripted(node) && !(Name(node) == "msup" && IsDigitArg(node)); + } + private string ScriptBase(XElement node) { var ret = Dump(node); - if (IsScripted(node) || Name(node) == "mrow" && ChildCount(node) > 1 && !HasAttr(node, "intent")) + if (IsScripted(node) || Name(node) == "mrow" && ChildCount(node) > 1 && !HasAttr(node, "intent") || IsBracketedMatrix(node)) ret = "〖" + ret.TrimEnd(' ') + "〗"; return ret; } diff --git a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTransform.cs b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTransform.cs index 89391c8..4045c88 100644 --- a/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTransform.cs +++ b/src/SharpEquations/Internal/UnicodeMath/UnicodeMathTransform.cs @@ -634,6 +634,13 @@ private static JsObject TransformSpaces(object value) foreach (var space in spaceString) spaces += "&#x" + ((int)space).ToString("x", CultureInfo.InvariantCulture) + ";"; } + // a space of a width in eighteenths of an em (the ½ em of ", "), as the character of that width; none for + // the width 0 of an empty cell + else if (value is JsObject width && Get(width, "space") is { } eighteenths && System.Convert.ToInt32(eighteenths, CultureInfo.InvariantCulture) > 0) + { + foreach (var space in UnicodeMathSerializer.UniSpaces[System.Convert.ToInt32(eighteenths, CultureInfo.InvariantCulture)]) + spaces += "&#x" + ((int)space).ToString("x", CultureInfo.InvariantCulture) + ";"; + } return O("mo", NoAttr(spaces)); } diff --git a/src/SharpEquations/Internal/Xslt/NodeTest.cs b/src/SharpEquations/Internal/Xslt/NodeTest.cs deleted file mode 100644 index 0b96e75..0000000 --- a/src/SharpEquations/Internal/Xslt/NodeTest.cs +++ /dev/null @@ -1,75 +0,0 @@ -#nullable enable - -namespace SharpEquations.Internal.Xslt -{ - using System.Xml.Linq; - - /// The node test of an XPath step: a name, a namespace wildcard, * or a node type. - internal sealed class NodeTest - { - private enum Kind - { - Name, - Namespace, - AnyName, - Node, - Text, - Comment, - ProcessingInstruction, - } - - private readonly Kind kind; - private readonly XName? name; - private readonly string? text; - - private NodeTest(Kind kind, XName? name = null, string? text = null) - { - this.kind = kind; - this.name = name; - this.text = text; - } - - public static NodeTest Named(XName name) => new NodeTest(Kind.Name, name); - - public static NodeTest InNamespace(string namespaceName) => new NodeTest(Kind.Namespace, text: namespaceName); - - public static readonly NodeTest AnyName = new NodeTest(Kind.AnyName); - - public static readonly NodeTest Node = new NodeTest(Kind.Node); - - public static readonly NodeTest Text = new NodeTest(Kind.Text); - - public static readonly NodeTest Comment = new NodeTest(Kind.Comment); - - public static NodeTest ProcessingInstruction(string? target) => new NodeTest(Kind.ProcessingInstruction, text: target); - - /// - /// Whether the node passes the test, on an axis whose principal node type is the attribute (the attribute - /// axis) or the element (every other axis). - /// - public bool Matches(XObject node, bool attributeAxis) - { - switch (kind) - { - case Kind.Name: - return attributeAxis - ? node is XAttribute attribute && attribute.Name == name - : node is XElement element && element.Name == name; - case Kind.Namespace: - return attributeAxis - ? node is XAttribute attribute2 && attribute2.Name.NamespaceName == text - : node is XElement element2 && element2.Name.NamespaceName == text; - case Kind.AnyName: - return attributeAxis ? node is XAttribute : node is XElement; - case Kind.Node: - return true; - case Kind.Text: - return node is XText; - case Kind.Comment: - return node is XComment; - default: - return node is XProcessingInstruction instruction && (text == null || instruction.Target == text); - } - } - } -} diff --git a/src/SharpEquations/Internal/Xslt/ResultFragment.cs b/src/SharpEquations/Internal/Xslt/ResultFragment.cs index 703040d..17d5479 100644 --- a/src/SharpEquations/Internal/Xslt/ResultFragment.cs +++ b/src/SharpEquations/Internal/Xslt/ResultFragment.cs @@ -7,12 +7,21 @@ namespace SharpEquations.Internal.Xslt /// A result tree fragment: the value of a variable or parameter given by its content. internal sealed class ResultFragment { - public ResultFragment(XElement root) => Root = root; + private readonly string? text; + private XElement? root; + + public ResultFragment(XElement root) => this.root = root; + + /// A fragment that is only text. + public ResultFragment(string text) => this.text = text; + + /// The text of a fragment that is only text, or null. + public string? Text => text; /// The nodes of the fragment, as the children of an element that is not part of it. - public XElement Root { get; } + public XElement Root => root ??= new XElement(XsltOutput.Container, text!.Length > 0 ? new XText(text) : null); - public string Value => Root.Value; + public string Value => text ?? root!.Value; } /// The parameters passed by xsl:apply-templates. @@ -37,7 +46,4 @@ public TemplateArguments(string[] names, object[] values) return null; } } - - /// An XPath predicate, or any expression evaluated with a context node, position and size. - internal delegate bool XPathPredicate(XObject node, int position, int size); } diff --git a/src/SharpEquations/Internal/Xslt/TranslateMap.cs b/src/SharpEquations/Internal/Xslt/TranslateMap.cs new file mode 100644 index 0000000..bdb858d --- /dev/null +++ b/src/SharpEquations/Internal/Xslt/TranslateMap.cs @@ -0,0 +1,95 @@ +#nullable enable + +namespace SharpEquations.Internal.Xslt +{ + using System.Collections.Generic; + using System.Text; + + /// The map of an XPath translate(), by code points: the characters replaced and those removed. + internal sealed class TranslateMap + { + private const int Keep = -1; + private const int Remove = -2; + + private readonly string from; + private readonly string to; + private readonly int[] ascii = new int[128]; + private readonly Dictionary? others; + + public TranslateMap(string from, string to) + { + this.from = from; + this.to = to; + for (var i = 0; i < ascii.Length; i++) + ascii[i] = Keep; + var target = new List(); + foreach (var rune in to.EnumerateRunes()) + target.Add(rune.Value); + var index = 0; + foreach (var rune in from.EnumerateRunes()) + { + var replacement = index < target.Count ? target[index] : Remove; + index++; + // the first occurrence of a character in the map is the one that counts + if (rune.Value < ascii.Length) + { + if (ascii[rune.Value] == Keep) + ascii[rune.Value] = replacement; + } + else + { + others ??= new Dictionary(); + if (!others.TryGetValue(rune.Value, out _)) + others[rune.Value] = replacement; + } + } + } + + public bool Maps(string from, string to) => this.from == from && this.to == to; + + private int Find(int codePoint) + { + if (codePoint < ascii.Length) + return ascii[codePoint]; + return others != null && others.TryGetValue(codePoint, out var replacement) ? replacement : Keep; + } + + /// The text translated: the text itself when nothing in it is replaced or removed. + public string Apply(string s) + { + StringBuilder? result = null; + var index = 0; + foreach (var rune in s.EnumerateRunes()) + { + var replacement = Find(rune.Value); + // a lone surrogate, which reads as U+FFFD, is written as U+FFFD + if (replacement == Keep && rune.Value == 0xFFFD && s[index] != '�') + replacement = 0xFFFD; + if (replacement != Keep && result == null) + result = new StringBuilder(s, 0, index, s.Length); + if (result != null) + { + switch (replacement) + { + case Keep when rune.IsBmp: + result.Append((char)rune.Value); + break; + case Keep: + result.Append(rune.ToString()); + break; + case Remove: + break; + case <= 0xFFFF: + result.Append((char)replacement); + break; + default: + result.Append(char.ConvertFromUtf32(replacement)); + break; + } + } + index += rune.IsBmp ? 1 : 2; + } + return result == null ? s : result.ToString(); + } + } +} diff --git a/src/SharpEquations/Internal/Xslt/XPathFunctions.cs b/src/SharpEquations/Internal/Xslt/XPathFunctions.cs index e3576a0..5257b99 100644 --- a/src/SharpEquations/Internal/Xslt/XPathFunctions.cs +++ b/src/SharpEquations/Internal/Xslt/XPathFunctions.cs @@ -10,20 +10,6 @@ namespace SharpEquations.Internal.Xslt using System.Xml.Linq; using SharpEquations.Internal.Text; - internal enum Axis - { - Child, - Descendant, - DescendantOrSelf, - Parent, - Ancestor, - AncestorOrSelf, - FollowingSibling, - PrecedingSibling, - Self, - Attribute, - } - internal enum Comparison { Equal, @@ -110,30 +96,42 @@ public static string Str(object value) public static double Num(string value) { - var s = value.Trim(' ', '\t', '\r', '\n'); - var i = 0; - if (i < s.Length && s[i] == '-') + var start = 0; + var end = value.Length; + while (start < end && IsSpace(value[start])) + start++; + while (end > start && IsSpace(value[end - 1])) + end--; + var i = start; + if (i < end && value[i] == '-') i++; var digits = 0; - while (i < s.Length && s[i] >= '0' && s[i] <= '9') + var whole = 0L; + while (i < end && value[i] >= '0' && value[i] <= '9') { + whole = whole * 10 + (value[i] - '0'); i++; digits++; } - if (i < s.Length && s[i] == '.') + // a short whole number, as most are, is exact as it is + if (i == end && digits > 0 && digits <= 15) + return value[start] == '-' ? -(double)whole : whole; + if (i < end && value[i] == '.') { i++; - while (i < s.Length && s[i] >= '0' && s[i] <= '9') + while (i < end && value[i] >= '0' && value[i] <= '9') { i++; digits++; } } - if (digits == 0 || i != s.Length) + if (digits == 0 || i != end) return double.NaN; - return double.Parse(s, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); + return double.Parse(value.Substring(start, end - start), NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); } + private static bool IsSpace(char c) => c == ' ' || c == '\t' || c == '\r' || c == '\n'; + public static double Num(double value) => value; public static double Num(bool value) => value ? 1 : 0; @@ -218,9 +216,22 @@ public static string StringValue(XObject node) public static bool Compare(object left, object right, Comparison comparison) { if (IsNodes(left)) - return IsNodes(right) ? CompareNodes(left, right, comparison) : CompareNodes(left, right, comparison, false); + { + if (!IsNodes(right)) + return CompareNodes(left, right, comparison); + for (int i = 0, leftCount = ValueCount(left); i < leftCount; i++) + { + var value = ValueAt(left, i); + for (int j = 0, rightCount = ValueCount(right); j < rightCount; j++) + { + if (CompareStrings(value, ValueAt(right, j), comparison)) + return true; + } + } + return false; + } if (IsNodes(right)) - return CompareNodes(right, left, comparison, true); + return CompareNodes(right, left, Swap(comparison)); if (comparison == Comparison.Equal || comparison == Comparison.NotEqual) { bool equal; @@ -235,6 +246,43 @@ public static bool Compare(object left, object right, Comparison comparison) return Compare(Num(left), Num(right), comparison); } + public static bool Compare(object left, string right, Comparison comparison) + { + if (!IsNodes(left)) + return Compare(left, (object)right, comparison); + for (int i = 0, count = ValueCount(left); i < count; i++) + { + if (CompareStrings(ValueAt(left, i), right, comparison)) + return true; + } + return false; + } + + public static bool Compare(string left, object right, Comparison comparison) => Compare(right, left, Swap(comparison)); + + public static bool Compare(object left, double right, Comparison comparison) + { + switch (left) + { + case double number: + return Compare(number, right, comparison); + case string text when comparison != Comparison.Equal && comparison != Comparison.NotEqual: + return Compare(Num(text), right, comparison); + case string text: + return (Num(text) == right) == (comparison == Comparison.Equal); + case bool _: + return Compare(left, (object)right, comparison); + } + for (int i = 0, count = ValueCount(left); i < count; i++) + { + if (Compare(Num(ValueAt(left, i)), right, comparison)) + return true; + } + return false; + } + + public static bool Compare(double left, object right, Comparison comparison) => Compare(right, left, Swap(comparison)); + public static bool Compare(double left, double right, Comparison comparison) { switch (comparison) @@ -256,27 +304,14 @@ public static bool Compare(double left, double right, Comparison comparison) private static bool IsNodes(object value) => value is List || value is ResultFragment; - private static IEnumerable StringValues(object value) => - value is ResultFragment fragment ? new[] { fragment.Value } : ((List)value).Select(StringValue); + // the string values of a node-set: those of its nodes, or that of a result tree fragment + private static int ValueCount(object nodes) => nodes is List list ? list.Count : 1; - private static bool CompareNodes(object left, object right, Comparison comparison) - { - var rightValues = StringValues(right).ToList(); - foreach (var l in StringValues(left)) - { - foreach (var r in rightValues) - { - if (CompareStrings(l, r, comparison)) - return true; - } - } - return false; - } + private static string ValueAt(object nodes, int index) => nodes is List list ? StringValue(list[index]) : ((ResultFragment)nodes).Value; - private static bool CompareNodes(object nodes, object other, Comparison comparison, bool swapped) + // a node-set compared with a value that is not one + private static bool CompareNodes(object nodes, object other, Comparison comparison) { - if (swapped) - comparison = Swap(comparison); if (other is bool b) { var value = Bool(nodes); @@ -284,10 +319,23 @@ private static bool CompareNodes(object nodes, object other, Comparison comparis ? (value == b) == (comparison == Comparison.Equal) : Compare(value ? 1 : 0, b ? 1 : 0, comparison); } + var count = ValueCount(nodes); if (other is double d) - return StringValues(nodes).Any(s => Compare(Num(s), d, comparison)); + { + for (var i = 0; i < count; i++) + { + if (Compare(Num(ValueAt(nodes, i)), d, comparison)) + return true; + } + return false; + } var text = Str(other); - return StringValues(nodes).Any(s => CompareStrings(s, text, comparison)); + for (var i = 0; i < count; i++) + { + if (CompareStrings(ValueAt(nodes, i), text, comparison)) + return true; + } + return false; } private static bool CompareStrings(string left, string right, Comparison comparison) @@ -324,14 +372,6 @@ private static Comparison Swap(Comparison comparison) public static List Single(XObject node) => new List(1) { node }; - public static List Root(XObject node) - { - var root = node; - for (var parent = Parent(root); parent != null; parent = Parent(parent)) - root = parent; - return Single(root); - } - public static XObject? Parent(XObject node) { switch (node) @@ -360,129 +400,79 @@ public static List Union(List left, List right) return result; } - public static List Step(List input, Axis axis, NodeTest test) - { - var result = new List(); - var attributeAxis = axis == Axis.Attribute; - foreach (var context in input) - { - foreach (var node in Along(context, axis)) - { - if (test.Matches(node, attributeAxis)) - result.Add(node); - } - } - Order(result, input.Count, axis); - return result; - } + /// What a template is given for a parameter that is a node, when the call leaves it to its default. + public static readonly XObject Omitted = new XComment(""); - public static List Step(List input, Axis axis, NodeTest test, params XPathPredicate[] predicates) - { - var result = new List(); - var attributeAxis = axis == Axis.Attribute; - var candidates = new List(); - foreach (var context in input) - { - candidates.Clear(); - foreach (var node in Along(context, axis)) - { - if (test.Matches(node, attributeAxis)) - candidates.Add(node); - } - result.AddRange(Apply(candidates, predicates)); - } - Order(result, input.Count, axis); - return result; - } + public static List ListOf(XObject? node) => node == null ? Empty : Single(node); - public static List Filter(List nodes, XPathPredicate predicate) + /// The string value of the first child of the element with the name, and whether it has none, one or more. + public static string? KeyValue(XElement element, XName name, out int count) { - var result = new List(); - for (var i = 0; i < nodes.Count; i++) + string? value = null; + count = 0; + for (var node = element.FirstNode; node != null; node = node.NextNode) { - if (predicate(nodes[i], i + 1, nodes.Count)) - result.Add(nodes[i]); + if (!(node is XElement child) || child.Name != name) + continue; + if (++count > 1) + break; + value = child.Value; } - return result; + return value; } - private static List Apply(List candidates, XPathPredicate[] predicates) + public static XObject? First(List nodes) => nodes.Count == 0 ? null : nodes[0]; + + public static XObject RootNode(XObject node) { - var nodes = candidates; - foreach (var predicate in predicates) - nodes = Filter(nodes, predicate); - return ReferenceEquals(nodes, candidates) ? new List(candidates) : nodes; + var root = node; + for (var parent = Parent(root); parent != null; parent = Parent(parent)) + root = parent; + return root; } - private static bool IsReverse(Axis axis) => - axis == Axis.Ancestor || axis == Axis.AncestorOrSelf || axis == Axis.PrecedingSibling; + public static XNode? FirstChild(XObject node) => node is XContainer container ? container.FirstNode : null; - private static void Order(List nodes, int contexts, Axis axis) - { - if (contexts > 1) - SortUnique(nodes); - else if (IsReverse(axis)) - nodes.Reverse(); - } + public static XNode? NextSibling(XObject node) => node is XNode sibling ? sibling.NextNode : null; + + public static XNode? PreviousSibling(XObject node) => node is XNode sibling ? sibling.PreviousNode : null; + + /// The first of the nodes that have the parent of the node, if it is one of them (an attribute is not). + public static XNode? FirstSibling(XObject node) => + node is XNode && !(node is XDocument) && Parent(node) is XContainer parent ? parent.FirstNode : null; - private static IEnumerable Along(XObject node, Axis axis) + /// The node after a node of a tree in document order, within the tree. + public static XObject? NextInTree(XObject node, XObject root) { - switch (axis) + if (node is XContainer container && container.FirstNode is XNode first) + return first; + for (var current = node; !ReferenceEquals(current, root); ) { - case Axis.Child: - return Children(node); - case Axis.Descendant: - return Descendants(node); - case Axis.DescendantOrSelf: - return new[] { node }.Concat(Descendants(node)); - case Axis.Parent: - var parent = Parent(node); - return parent == null ? Enumerable.Empty() : new[] { parent }; - case Axis.Ancestor: - return Ancestors(node); - case Axis.AncestorOrSelf: - return new[] { node }.Concat(Ancestors(node)); - case Axis.FollowingSibling: - return node is XNode following && !(node is XDocument) ? following.NodesAfterSelf().Where(IsInModel) : Enumerable.Empty(); - case Axis.PrecedingSibling: - return node is XNode preceding && !(node is XDocument) ? preceding.NodesBeforeSelf().Reverse().Where(IsInModel) : Enumerable.Empty(); - case Axis.Self: - return new[] { node }; - default: - return node is XElement element ? element.Attributes().Where(a => !a.IsNamespaceDeclaration) : Enumerable.Empty(); + if (current is XNode sibling && sibling.NextNode is XNode next) + return next; + var parent = Parent(current); + if (parent == null) + return null; + current = parent; } + return null; } public static List ChildNodes(XObject node) => new List(Children(node)); - /// - /// Whether a node matches a step of a pattern whose predicates count positions: whether the step, from the - /// parent of the node, selects it. - /// - public static bool MatchesStep(XObject node, Axis axis, NodeTest test, params XPathPredicate[] predicates) - { - var parent = Parent(node); - return parent != null && test.Matches(node, axis == Axis.Attribute) && Step(Single(parent), axis, test, predicates).Contains(node); - } - public static IEnumerable Children(XObject node) => node is XContainer container ? container.Nodes().Where(IsInModel) : Enumerable.Empty(); - private static IEnumerable Descendants(XObject node) => - node is XContainer container ? container.DescendantNodes().Where(IsInModel) : Enumerable.Empty(); - - private static IEnumerable Ancestors(XObject node) - { - for (var parent = Parent(node); parent != null; parent = Parent(parent)) - yield return parent; - } - // the document type and the white space around the document element are not nodes of XPath private static bool IsInModel(XNode node) => !(node is XDocumentType) && !(node is XText && node.Parent == null); - private static void SortUnique(List nodes) + public static bool IsNode(XObject node) => !(node is XNode other) || IsInModel(other); + + public static void SortUnique(List nodes) { + if (nodes.Count < 2) + return; nodes.Sort(DocumentOrder); var write = 0; for (var read = 0; read < nodes.Count; read++) @@ -605,9 +595,39 @@ public static string Substring(string s, double start, double length) { var first = Round(start); var end = first + Round(length); + var position = 0; + var index = 0; + var from = -1; + var to = 0; + foreach (var rune in s.EnumerateRunes()) + { + position++; + var next = index + (rune.IsBmp ? 1 : 2); + if (position >= first && position < end) + { + // a lone surrogate, which reads as U+FFFD, is written as U+FFFD + if (rune.Value == 0xFFFD && s[index] != '\uFFFD') + return SubstringOfRunes(s, first, end); + if (from < 0) + from = index; + to = next; + } + else if (from >= 0) + { + break; + } + index = next; + } + if (from < 0) + return ""; + return from == 0 && to == s.Length ? s : s.Substring(from, to - from); + } + + private static string SubstringOfRunes(string s, double first, double end) + { var result = new StringBuilder(); var position = 0; - foreach (var rune in CodePoints.Of(s)) + foreach (var rune in s.EnumerateRunes()) { position++; if (position >= first && position < end) @@ -616,7 +636,13 @@ public static string Substring(string s, double start, double length) return result.ToString(); } - public static double StringLength(string s) => CodePoints.Of(s).Count(); + public static double StringLength(string s) + { + var length = 0; + foreach (var rune in s.EnumerateRunes()) + length++; + return length; + } public static string NormalizeSpace(string s) { @@ -637,20 +663,14 @@ public static string NormalizeSpace(string s) return result.ToString(); } - public static string Translate(string s, string from, string to) + public static string Translate(string s, string from, string to) => new TranslateMap(from, to).Apply(s); + + /// translate() with the map of the last call from the same place, which mostly has the same arguments. + public static string Translate(string s, string from, string to, ref TranslateMap? map) { - var source = CodePoints.Of(from).ToList(); - var target = CodePoints.Of(to).ToList(); - var result = new StringBuilder(s.Length); - foreach (var rune in CodePoints.Of(s)) - { - var index = source.IndexOf(rune); - if (index < 0) - result.Append(rune.ToString()); - else if (index < target.Count) - result.Append(target[index].ToString()); - } - return result.ToString(); + if (map == null || !map.Maps(from, to)) + map = new TranslateMap(from, to); + return map.Apply(s); } public static double Round(double value) diff --git a/src/SharpEquations/Internal/Xslt/XsltOutput.cs b/src/SharpEquations/Internal/Xslt/XsltOutput.cs index 0d820e9..7f72df8 100644 --- a/src/SharpEquations/Internal/Xslt/XsltOutput.cs +++ b/src/SharpEquations/Internal/Xslt/XsltOutput.cs @@ -19,54 +19,88 @@ namespace SharpEquations.Internal.Xslt /// internal sealed class XsltOutput { - private static readonly XName Container = XName.Get("fragment"); + public static readonly XName Container = XName.Get("fragment"); - private readonly Stack open = new Stack(); - private readonly Stack roots = new Stack(); private readonly XElement result = new XElement(Container); - // the text written since the last node, as it is and as markup (escaped, with the unescaped text as it is) - private readonly StringBuilder text = new StringBuilder(); - private readonly StringBuilder markup = new StringBuilder(); + // the element being written, the root of what is written (the result, or the fragment being captured), and + // those of the captures around it + private XContainer top; + private XElement root; + private Stack? outer; + + // the text written since the last node: as it is (one piece, or more in the builder) and, once some is written + // with disable-output-escaping, as markup (escaped, with the unescaped text as it is) + private string? piece; + private StringBuilder? text; + private StringBuilder? markup; private bool hasText; private bool hasMarkup; + // the text captured as strings, one capture after the other, and where the one in progress starts (-1: none) + private StringBuilder? captured; + private Stack? captures; + private int capture = -1; + public XsltOutput() { - open.Push(result); - roots.Push(result); + top = result; + root = result; } - private XContainer Top => open.Peek(); - public void StartElement(XName name) { Flush(); var element = new XElement(name); - Top.Add(element); - open.Push(element); + top.Add(element); + top = element; } public void EndElement() { Flush(); - open.Pop(); + top = top.Parent!; } public void Attribute(XName name, string value) { Flush(); - if (!(Top is XElement element) || ReferenceEquals(element, roots.Peek())) + if (!(top is XElement element) || ReferenceEquals(element, root)) throw new InvalidOperationException("An attribute can be added only to an element."); - if (element.Nodes().Any(n => !(n is XText t) || t.Value.Length > 0)) - throw new InvalidOperationException("An attribute cannot be added to an element after its children."); + for (var node = element.FirstNode; node != null; node = node.NextNode) + { + if (!(node is XText t) || t.Value.Length > 0) + throw new InvalidOperationException("An attribute cannot be added to an element after its children."); + } element.SetAttributeValue(name, value); } public void Text(string value) { - hasText = true; - text.Append(value); + if (capture >= 0) + { + captured!.Append(value); + return; + } + if (hasMarkup) + Escape(markup!, value); + if (!hasText) + { + hasText = true; + piece = value; + return; + } + if (piece != null) + { + text ??= new StringBuilder(); + text.Append(piece); + piece = null; + } + text!.Append(value); + } + + private static void Escape(StringBuilder markup, string value) + { foreach (var c in value) { switch (c) @@ -87,46 +121,168 @@ public void Text(string value) } } + private string Pending => piece ?? text!.ToString(); + /// Text written with disable-output-escaping. public void RawText(string value) { - hasText = true; - hasMarkup = true; - text.Append(value); - markup.Append(value); + if (capture >= 0) + throw new InvalidOperationException("Text cannot be written as markup here."); + if (!hasMarkup) + { + markup ??= new StringBuilder(); + markup.Clear(); + if (hasText) + Escape(markup, Pending); + hasMarkup = true; + } + markup!.Append(value); + if (!hasText) + { + hasText = true; + piece = value; + return; + } + if (piece != null) + { + text ??= new StringBuilder(); + text.Append(piece); + piece = null; + } + text!.Append(value); } private void Flush() { if (!hasText) return; - var top = Top; - if (hasMarkup) + if (hasMarkup && Decode(markup!) is string decoded) + { + if (decoded.Length > 0) + top.Add(new XText(decoded)); + markup!.Clear(); + } + else if (hasMarkup) { var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }; using (var reader = XmlReader.Create(new StringReader("" + markup + ""), settings)) top.Add(XElement.Load(reader, LoadOptions.PreserveWhitespace).Nodes().ToList()); + markup!.Clear(); } - else if (text.Length > 0 || (top is XElement element && element.IsEmpty && !ReferenceEquals(element, roots.Peek()))) + else { - top.Add(text.ToString()); + var pending = Pending; + if (pending.Length > 0 || (top is XElement element && element.IsEmpty && !ReferenceEquals(element, root))) + top.Add(pending); } - text.Clear(); - markup.Clear(); + piece = null; + text?.Clear(); hasText = false; hasMarkup = false; } + /// + /// The text of markup that is only text and references to characters, as most is (a character reference + /// written as text): what a parser reads it as. Null for any other markup, which a parser reads. + /// + private static string? Decode(StringBuilder markup) + { + var result = new StringBuilder(markup.Length); + for (var i = 0; i < markup.Length; i++) + { + var c = markup[i]; + switch (c) + { + case '&': + { + var end = i + 1; + while (end < markup.Length && end - i <= 10 && markup[end] != ';') + end++; + if (end >= markup.Length || markup[end] != ';') + return null; + var reference = Reference(markup.ToString(i + 1, end - i - 1)); + if (reference == null) + return null; + result.Append(reference); + i = end; + break; + } + // markup, the end of a CDATA section, the ends of lines a parser normalizes, the characters XML + // does not allow and the surrogates, whose pairs a parser checks + case '<': + case '>': + case '\r': + case var _ when c < 0x20 && c != '\t' && c != '\n': + case var _ when c >= 0xD800 && c <= 0xDFFF: + case var _ when c >= 0xFFFE: + return null; + default: + result.Append(c); + break; + } + } + return result.ToString(); + } + + // the character of a reference to a character or to one of the entities of XML, or null if it is not one + private static string? Reference(string name) + { + switch (name) + { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return "\""; + case "apos": + return "'"; + } + if (name.Length < 2 || name[0] != '#') + return null; + var hexadecimal = name[1] == 'x'; + var value = 0; + for (var i = hexadecimal ? 2 : 1; i < name.Length; i++) + { + var c = name[i]; + int digit; + switch (c) + { + case >= '0' and <= '9': + digit = c - '0'; + break; + case >= 'a' and <= 'f' when hexadecimal: + digit = c - 'a' + 10; + break; + case >= 'A' and <= 'F' when hexadecimal: + digit = c - 'A' + 10; + break; + default: + return null; + } + value = value * (hexadecimal ? 16 : 10) + digit; + if (value > 0x10FFFF) + return null; + } + if (name.Length == (hexadecimal ? 2 : 1)) + return null; + // the characters of XML but the ends of lines + var allowed = value == 0x9 || (value >= 0x20 && value <= 0xD7FF) || (value >= 0xE000 && value <= 0xFFFD) || value >= 0x10000; + return allowed ? char.ConvertFromUtf32(value) : null; + } + public void Comment(string value) { Flush(); - Top.Add(new XComment(value)); + top.Add(new XComment(value)); } public void ProcessingInstruction(string target, string data) { Flush(); - Top.Add(new XProcessingInstruction(target, data)); + top.Add(new XProcessingInstruction(target, data)); } public void CopyOf(List nodes) @@ -137,12 +293,24 @@ public void CopyOf(List nodes) public void CopyOf(ResultFragment fragment) { + if (fragment.Text != null) + { + CopyOfText(fragment.Text); + return; + } foreach (var node in fragment.Root.Nodes()) Copy(node); } public void CopyOf(string value) => Text(value); + /// The copy of a result tree fragment that is only text: its text node, if it has one. + public void CopyOfText(string value) + { + if (value.Length > 0) + Text(value); + } + public void CopyOf(object value) { switch (value) @@ -159,7 +327,7 @@ public void CopyOf(object value) } } - private void Copy(XObject node) + public void Copy(XObject node) { switch (node) { @@ -169,7 +337,7 @@ private void Copy(XObject node) break; case XElement element: Flush(); - Top.Add(new XElement(element)); + top.Add(new XElement(element)); break; case XAttribute attribute: Attribute(attribute.Name, attribute.Value); @@ -202,28 +370,53 @@ public bool StartCopy(XObject node) } } + /// Starts keeping the text written as a string: only text is written until . + public void BeginText() + { + captured ??= new StringBuilder(); + captures ??= new Stack(); + captures.Push(capture); + capture = captured.Length; + } + + public string EndText() + { + var value = captured!.ToString(capture, captured.Length - capture); + captured.Length = capture; + capture = captures!.Pop(); + return value; + } + public void BeginCapture() { Flush(); - var root = new XElement(Container); - open.Push(root); - roots.Push(root); + captures ??= new Stack(); + captures.Push(capture); + capture = -1; + outer ??= new Stack(); + outer.Push(top); + outer.Push(root); + root = new XElement(Container); + top = root; } - public ResultFragment EndFragment() + private XElement EndCapture() { Flush(); - open.Pop(); - return new ResultFragment(roots.Pop()); + var fragment = root; + root = (XElement)outer!.Pop(); + top = outer.Pop(); + capture = captures!.Pop(); + return fragment; } + public ResultFragment EndFragment() => new ResultFragment(EndCapture()); + /// The text captured, for an attribute or a comment, where disable-output-escaping does not apply. public string EndString() { hasMarkup = false; - Flush(); - open.Pop(); - return roots.Pop().Value; + return EndCapture().Value; } /// The document element of the result, or null if the result is only white space. diff --git a/src/SharpEquations/SharpEquations.csproj b/src/SharpEquations/SharpEquations.csproj index 7c43b0b..6454104 100644 --- a/src/SharpEquations/SharpEquations.csproj +++ b/src/SharpEquations/SharpEquations.csproj @@ -18,6 +18,7 @@ + diff --git a/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs b/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs index 458b869..c71470a 100644 --- a/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs +++ b/tests/SharpEquations.Tests/Regression/WriterRoundTripTests.cs @@ -35,6 +35,36 @@ public async Task UnicodeMathWritesWhatItReads(string mathML, string unicodeMath await Assert.That(RoundTrip(back, EquationFormat.UnicodeMath)).IsEqualTo(back); } + /// + /// UnicodeMath that reads to an equation the writer wrote as text that read back otherwise: a script of a matrix + /// in brackets (⒨(…)²), a function in a denominator (𝑎/ sin⁡𝛼), a denominator that ends with a script before + /// what follows the fraction (1/4𝜋𝜀₀ 𝑞₁𝑞₂/𝑟²), and the space after a comma that two spaces type (𝑎, 𝑏). + /// + [Test] + [Arguments("(■(a&b@c&d))^2")] + [Arguments("(■(a&b@c&d))^(-1)=1/(ad-bc) (■(d&-b@-c&a))")] + [Arguments("[■(a&b@c&d)]_ij")] + [Arguments("|■(a&b@c&d)|^2")] + [Arguments("a/sin⁡α")] + [Arguments("a/sin⁡α=b/sin⁡β=c/sin⁡γ")] + [Arguments("(sin⁡α)/a")] + [Arguments("1/(4πε_0) (q_1 q_2)/r^2")] + [Arguments("1/(x_1) y")] + [Arguments("1/(ab^2) c/d")] + [Arguments("A=√s, s=2")] + [Arguments("f(x, y)")] + public async Task UnicodeMathReadsBackWhatItWrites(string unicodeMath) + { + var equation = Equation.Parse(unicodeMath, EquationFormat.UnicodeMath); + await Assert.That(RoundTrip(equation, EquationFormat.UnicodeMath)).IsEqualTo(equation).Because(equation.ToString(EquationFormat.UnicodeMath)); + } + + [Test] + public async Task UnicodeMathReadsTwoSpacesAfterACommaAsASpace() + { + await Assert.That(Equation.Parse("a, b", EquationFormat.UnicodeMath)).IsEqualTo(Equation.Parse("a,\u2002b", EquationFormat.UnicodeMath)); + } + [Test] [Arguments("[a|b]")] [Arguments("[a‖b]")] diff --git a/tests/SharpEquations.Tests/SharpEquations.Tests.csproj b/tests/SharpEquations.Tests/SharpEquations.Tests.csproj index 53abb9f..1e4d407 100644 --- a/tests/SharpEquations.Tests/SharpEquations.Tests.csproj +++ b/tests/SharpEquations.Tests/SharpEquations.Tests.csproj @@ -24,4 +24,10 @@ SetTargetFramework="TargetFramework=netstandard2.0" /> + + + + + + diff --git a/tests/SharpEquations.Tests/Xslt/CompiledStylesheetTests.cs b/tests/SharpEquations.Tests/Xslt/CompiledStylesheetTests.cs index 4d6d4be..658fe6f 100644 --- a/tests/SharpEquations.Tests/Xslt/CompiledStylesheetTests.cs +++ b/tests/SharpEquations.Tests/Xslt/CompiledStylesheetTests.cs @@ -1,5 +1,16 @@ #nullable enable +namespace SharpEquations.Internal.Xslt +{ + using System.Xml.Linq; + + /// Xslt/conformance.xsl, compiled by SharpEquations.XsltGenerator. + internal sealed partial class ConformanceStylesheet + { + public static partial XElement? Transform(XElement input); + } +} + namespace SharpEquations.Tests.Xslt { using System; @@ -32,7 +43,9 @@ public class CompiledStylesheetTests { var stylesheet = Stylesheets.GetOrAdd(name, n => { - using var stream = File.OpenRead(Path.Combine(TestData.Path(), "..", "..", "src", "SharpEquations", "Resources", n)); + using var stream = File.OpenRead(n == "conformance.xsl" + ? Path.Combine(TestData.Path(), "..", "SharpEquations.Tests", "Xslt", n) + : Path.Combine(TestData.Path(), "..", "..", "src", "SharpEquations", "Resources", n)); using var reader = XmlReader.Create(stream, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); var transform = new XslCompiledTransform(); transform.Load(reader, XsltSettings.Default, null); @@ -114,7 +127,7 @@ private static string Around(string text, string other) private static async Task AssertNoDifferences((List Differences, int Astral) result, int inputs) { - await Assert.That(inputs).IsGreaterThan(100); + await Assert.That(inputs).IsGreaterThan(30); Console.WriteLine(result.Astral + " of " + inputs + " differ, with characters outside the basic plane"); await Assert.That(result.Differences.Count).IsEqualTo(0).Because(string.Join("\n", result.Differences.Take(10))); } @@ -133,6 +146,44 @@ public async Task MathML2Omml() await AssertNoDifferences(Differences("mml2omml.xsl", Mml2ommlStylesheet.Transform, inputs), inputs.Count); } + private const string ConformanceInput = "" + + "alpha310" + + "beta45text" + + "gamma-1.5" + + "delta78" + + "c01mc01tc01" + + "c02tc02c03mc03" + + "c04c05c05c01tc06" + + "c05c09mc10 c07" + + ""; + + /// + /// What the generator compiles, beyond what the stylesheets of the library use of it on the corpora: the axes, + /// the predicates that count, the node-sets as variables and parameters, the comparisons, the rules found by the + /// text of a child (Xslt/conformance.xsl). + /// + [Test] + public async Task GeneratorConformance() + { + var input = XElement.Parse(ConformanceInput); + var expected = Canonical(Reference("conformance.xsl", new XElement(input))); + var actual = Canonical(ConformanceStylesheet.Transform(new XElement(input))); + await Assert.That(XElement.Parse(expected).Elements().Count()).IsGreaterThan(30); + foreach (var (e, a) in XElement.Parse(expected).Elements().Zip(XElement.Parse(actual).Elements(), (e, a) => (e, a))) + await Assert.That(a.ToString(SaveOptions.DisableFormatting)).IsEqualTo(e.ToString(SaveOptions.DisableFormatting)); + await Assert.That(actual).IsEqualTo(expected); + } + + [Test] + public async Task ContentMathML2MathML() + { + var inputs = Directory.GetFiles(TestData.Path("contentmathml"), "*.mml") + .OrderBy(f => f, StringComparer.Ordinal) + .SelectMany(f => XElement.Load(f, LoadOptions.PreserveWhitespace).DescendantsAndSelf().Where(e => e.Name.LocalName == "math").Select((e, i) => (Path.GetFileName(f) + "#" + i, e))) + .ToList(); + await AssertNoDifferences(Differences("ctop.xsl", CtopStylesheet.Transform, inputs), inputs.Count); + } + [Test] public async Task Mtef2MathML() { diff --git a/tests/SharpEquations.Tests/Xslt/conformance.xsl b/tests/SharpEquations.Tests/Xslt/conformance.xsl new file mode 100644 index 0000000..6ca7068 --- /dev/null +++ b/tests/SharpEquations.Tests/Xslt/conformance.xsl @@ -0,0 +1,215 @@ + + + + + + + + + + + + + + + 12 + + + + + + + + + + + + + + + + + + + + + + is-x + gt-2 + + + + + + + + + + + + + + + + , + + ! + + + + + + + + + + + + + + + + + one + other + + + x + + + + + + + + ,, + , + ||| + ||| + ||| + || + ||| + ||| + |||| + |||||| + ||||||| + ||||||| + |||||| + |||||| + |||| + |||| + ||||||||||| + ||||||||| + ||| + ||| + + + + + /:; + + =; + + + + + = + + + + + + + + + + + + + + + + + + + + + + + inside + + + + + + + + + + + + + + + + + + + + + + + + + &#x2211;<r a="1">x</r> + + + a + + + c + data + + + + + + + + + + + + + [second ] + [last ] + (sub ) + (deep) + (top ) + (attribute ) + (comment ) + (pi ) + + + + + + + [other ] + [m ] + A + a + B + C + D + E + [plain ] + F + G + H + b + I +