From 57a09bdd852ca9a1e838eec6d5d4d8b8bcf338b2 Mon Sep 17 00:00:00 2001 From: dv00d00 <1162823+dv00d00@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:51:39 +0100 Subject: [PATCH 1/2] Split compact document responsibilities --- .../Document/CompactDocument.Query.cs | 129 ++++++ .../Document/CompactDocument.Storage.cs | 196 +++++++++ .../Document/CompactDocument.Templates.cs | 60 +++ .../Document/CompactDocument.cs | 374 +----------------- 4 files changed, 386 insertions(+), 373 deletions(-) create mode 100644 src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Query.cs create mode 100644 src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Storage.cs create mode 100644 src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Templates.cs diff --git a/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Query.cs b/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Query.cs new file mode 100644 index 0000000..af466ea --- /dev/null +++ b/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Query.cs @@ -0,0 +1,129 @@ +using System.Runtime.InteropServices; + +namespace AngleSharp.ReadOnlyDom.Compact.Document; + +public sealed partial class CompactDocument +{ + /// + /// Returns the next node at or after with the given name ID, or -1. + /// Frozen columns use a vectorized scan; packed documents use a scalar scan. + /// + internal int IndexOfName(string name, int start = 0, int endExclusive = int.MaxValue) + { + return IndexOfName(name.AsSpan(), start, endExclusive); + } + + /// + /// Resolves once, then returns the next matching node at or after + /// , or -1. + /// + internal int IndexOfName(ReadOnlySpan name, int start = 0, int endExclusive = int.MaxValue) + { + return IndexOfName(ResolveNameId(name), start, endExclusive); + } + + /// + /// Returns the next node at or after with the given pre-resolved name ID, or -1. + /// + internal int IndexOfName(ushort nameId, int start = 0, int endExclusive = int.MaxValue) + { + if (start < 0) + start = 0; + endExclusive = Math.Min(endExclusive, _nodeCount); + if (start >= endExclusive) + return -1; + if (_arena is not null) + { + var column = _arena.NameIdColumn; + var relative = MemoryMarshal + .Cast(column.Slice(start, endExclusive - start)) + .IndexOf((char)nameId); + return relative < 0 ? -1 : start + relative; + } + + for (var handle = start; handle < endExclusive; handle++) + if (_nodes![handle].NameId == nameId) + return handle; + return -1; + } + + internal int CountElements(string name) + { + return CountElements(name.AsSpan()); + } + + /// Resolves once, then counts matching elements. + internal int CountElements(ReadOnlySpan name) + { + return CountElements(ResolveNameId(name)); + } + + /// Counts elements using a previously resolved name ID. + internal int CountElements(ushort nameId) + { + var count = 0; + for (var handle = 0; handle < _nodeCount; handle++) + { + if (TryGetContainingTemplateContentEnd(handle, out var contentEnd)) + { + handle = contentEnd - 1; + continue; + } + + if (KindAt(handle) == CompactNodeKind.Element && NameIdAt(handle) == nameId) + count++; + } + + return count; + } + + internal string GetName(ushort id) + { + return id < GeneratedTagMetadata.KnownNameCount + ? GeneratedTagMetadata.GetKnownName(id) + : _names[id - GeneratedTagMetadata.KnownNameCount]; + } + + /// + /// Resolves a name only when it occurs in this document. This scans nodes and attributes. + /// + internal ushort FindNameId(string name) + { + return FindNameId(name.AsSpan()); + } + + internal ushort FindNameId(ReadOnlySpan name) + { + var id = ResolveNameId(name); + return id != ushort.MaxValue && ContainsNameId(id) ? id : ushort.MaxValue; + } + + /// + /// Resolves a stable name ID without checking whether it occurs in this document. + /// + internal ushort ResolveNameId(string name) + { + return ResolveNameId(name.AsSpan()); + } + + internal ushort ResolveNameId(ReadOnlySpan name) + { + if (GeneratedTagMetadata.TryGetKnownNameId(name, out var knownId)) + return knownId; + for (ushort i = 0; i < _nameCount; i++) + if (name.SequenceEqual(_names[i])) + return checked((ushort)(GeneratedTagMetadata.KnownNameCount + i)); + return ushort.MaxValue; + } + + private bool ContainsNameId(ushort id) + { + for (var handle = 0; handle < _nodeCount; handle++) + if (NameIdAt(handle) == id) + return true; + for (var attribute = 0; attribute < _attributeCount; attribute++) + if (AttributeNameIdAt(attribute) == id) + return true; + return false; + } +} diff --git a/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Storage.cs b/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Storage.cs new file mode 100644 index 0000000..5b0dca8 --- /dev/null +++ b/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Storage.cs @@ -0,0 +1,196 @@ +namespace AngleSharp.ReadOnlyDom.Compact.Document; + +public sealed partial class CompactDocument +{ + internal CompactNode GetNode(int handle) + { + if (_arena is null) + return _nodes![handle]; + return new CompactNode( + _arena.FrozenFirstChild(handle), + _arena.FrozenSubtreeEnd(handle), + _arena.FrozenPayloadIndex(handle), + _arena.FrozenNameId(handle), + _arena.FrozenKind(handle), + _arena.FrozenFlags(handle) + ); + } + + internal CompactNodeKind KindAt(int handle) + { + return _arena is null ? _nodes![handle].Kind : _arena.FrozenKind(handle); + } + + internal ushort NameIdAt(int handle) + { + return _arena is null ? _nodes![handle].NameId : _arena.FrozenNameId(handle); + } + + internal int PayloadIndexAt(int handle) + { + return _arena is null ? _nodes![handle].PayloadIndex : _arena.FrozenPayloadIndex(handle); + } + + internal int SubtreeEndAt(int handle) + { + return _arena is null ? _nodes![handle].SubtreeEndExclusive : _arena.FrozenSubtreeEnd(handle); + } + + internal CompactNodePayload GetPayload(int index) + { + if (_arena is null) + return _payloads![index]; + var value = _arena.FrozenPayloadValue(index); + return new CompactNodePayload( + _arena.FrozenFirstAttribute(index), + value.IsEmpty ? -1 : EncodePayloadValue(index), + value.Length, + _arena.FrozenAttributeCount(index) + ); + } + + internal CompactAttribute GetAttribute(int index) + { + if (_arena is null) + return _attributes![index]; + var value = _arena.FrozenAttributeValue(index); + return new CompactAttribute( + _arena.FrozenAttributeNameId(index), + value.IsEmpty ? -1 : EncodeAttributeValue(index), + value.Length + ); + } + + // Lightweight column accessors used by the hot attribute-lookup loops. They avoid materializing a + // CompactNode/CompactNodePayload/CompactAttribute struct (and, on the frozen path, the encode/decode + // round-trip) when the caller only needs the name ID or the value span. + internal int PayloadFirstAttributeAt(int payloadIndex) + { + return _arena is null ? _payloads![payloadIndex].FirstAttribute : _arena.FrozenFirstAttribute(payloadIndex); + } + + internal int PayloadAttributeCountAt(int payloadIndex) + { + return _arena is null ? _payloads![payloadIndex].AttributeCount : _arena.FrozenAttributeCount(payloadIndex); + } + + internal ushort AttributeNameIdAt(int index) + { + return _arena is null ? _attributes![index].NameId : _arena.FrozenAttributeNameId(index); + } + + internal ReadOnlySpan AttributeValueSpanAt(int index) + { + if (_arena is null) + { + ref readonly var attribute = ref _attributes![index]; + return attribute.ValueLength == 0 ? [] : _text!.AsSpan(attribute.ValueStart, attribute.ValueLength); + } + + return _arena.FrozenAttributeValue(index).Span; + } + + /// The value span for a payload index, without materializing a . + internal ReadOnlySpan PayloadValueSpanAt(int payloadIndex) + { + if (_arena is null) + { + ref readonly var payload = ref _payloads![payloadIndex]; + return payload.ValueLength == 0 ? [] : _text!.AsSpan(payload.ValueStart, payload.ValueLength); + } + + return _arena.FrozenPayloadValue(payloadIndex).Span; + } + + /// Returns the first-attribute index and count for a node handle, or false when it has no payload. + internal bool TryGetAttributeRange(int handle, out int firstAttribute, out int count) + { + var payloadIndex = PayloadIndexAt(handle); + if (payloadIndex < 0) + { + firstAttribute = 0; + count = 0; + return false; + } + + firstAttribute = PayloadFirstAttributeAt(payloadIndex); + count = PayloadAttributeCountAt(payloadIndex); + return true; + } + + internal bool TryGetAttribute(int handle, ushort nameId, out CompactAttribute attribute, ref int inspected) + { + if (nameId != ushort.MaxValue && TryGetAttributeRange(handle, out var first, out var count)) + for (var index = first; index < first + count; index++) + { + inspected++; + if (AttributeNameIdAt(index) == nameId) + { + attribute = GetAttribute(index); + return true; + } + } + + attribute = default; + return false; + } + + internal ReadOnlySpan GetValue(int start, int length) + { + if (length == 0) + return []; + if (_arena is null) + return _text!.AsSpan(start, length); + var memory = IsAttributeValue(start) + ? _arena.FrozenAttributeValue(DecodeValueIndex(start)) + : _arena.FrozenPayloadValue(DecodeValueIndex(start)); + return memory.Span[..length]; + } + + internal int GetParent(int handle) + { + if (!RetainsParentLinks) + throw new InvalidOperationException("Parent links were not retained."); + return _arena is null ? _parents![handle] : _arena.FrozenParent(handle); + } + + internal bool TryGetSourceLocation(int handle, out CompactSourceLocation source) + { + if (_arena is not null) + { + if (RetainsSourceLocations) + return _arena.TryGetFrozenSourceLocation(handle, out source); + source = default; + return false; + } + + if (_sources is not null && _sources[handle].Index >= 0) + { + source = _sources[handle]; + return true; + } + + source = default; + return false; + } + + private static int EncodePayloadValue(int index) + { + return checked(index << 1); + } + + private static int EncodeAttributeValue(int index) + { + return checked((index << 1) | 1); + } + + private static bool IsAttributeValue(int value) + { + return (value & 1) != 0; + } + + private static int DecodeValueIndex(int value) + { + return value >> 1; + } +} diff --git a/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Templates.cs b/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Templates.cs new file mode 100644 index 0000000..29017c6 --- /dev/null +++ b/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.Templates.cs @@ -0,0 +1,60 @@ +namespace AngleSharp.ReadOnlyDom.Compact.Document; + +public sealed partial class CompactDocument +{ + internal bool IsTemplate(int handle) + { + if (!_hasTemplates) + return false; + foreach (var boundary in _templateBoundaries) + if (boundary.Handle == handle) + return true; + return false; + } + + internal bool TryGetTemplateContent(int handle, out int contentStart) + { + if (!_hasTemplates) + { + contentStart = -1; + return false; + } + + foreach (var boundary in _templateBoundaries) + { + if (boundary.Handle != handle) + continue; + contentStart = boundary.ContentStart; + return contentStart >= 0; + } + + contentStart = -1; + return false; + } + + internal bool TryGetContainingTemplateContentEnd(int handle, out int contentEnd) + { + contentEnd = -1; + if (!_hasTemplates) + return false; + foreach (var boundary in _templateBoundaries) + if (handle >= boundary.ContentStart && handle < boundary.ContentEnd) + contentEnd = Math.Max(contentEnd, boundary.ContentEnd); + return contentEnd >= 0; + } + + internal bool IsInSameTreeScope(int first, int second) + { + if (!_hasTemplates) + return true; + foreach (var boundary in _templateBoundaries) + { + var firstInContent = first >= boundary.ContentStart && first < boundary.ContentEnd; + var secondInContent = second >= boundary.ContentStart && second < boundary.ContentEnd; + if (firstInContent != secondInContent) + return false; + } + + return true; + } +} diff --git a/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.cs b/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.cs index a243711..759d37a 100644 --- a/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.cs +++ b/src/AngleSharp.ReadOnlyDom.Compact/Document/CompactDocument.cs @@ -1,12 +1,11 @@ using System.Buffers; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; using AngleSharp.Text; using ArenaStorage = AngleSharp.ReadOnlyDom.Compact.Arena.Arena; namespace AngleSharp.ReadOnlyDom.Compact.Document; -public sealed class CompactDocument : IDisposable +public sealed partial class CompactDocument : IDisposable { private readonly ArenaStorage? _arena; private readonly CompactAttribute[]? _attributes; @@ -181,375 +180,4 @@ public void Dispose() if (_sources is not null) ArrayPool.Shared.Return(_sources); } - - internal CompactNode GetNode(int handle) - { - if (_arena is null) - return _nodes![handle]; - return new CompactNode( - _arena.FrozenFirstChild(handle), - _arena.FrozenSubtreeEnd(handle), - _arena.FrozenPayloadIndex(handle), - _arena.FrozenNameId(handle), - _arena.FrozenKind(handle), - _arena.FrozenFlags(handle) - ); - } - - internal CompactNodeKind KindAt(int handle) - { - return _arena is null ? _nodes![handle].Kind : _arena.FrozenKind(handle); - } - - internal ushort NameIdAt(int handle) - { - return _arena is null ? _nodes![handle].NameId : _arena.FrozenNameId(handle); - } - - internal int PayloadIndexAt(int handle) - { - return _arena is null ? _nodes![handle].PayloadIndex : _arena.FrozenPayloadIndex(handle); - } - - internal int SubtreeEndAt(int handle) - { - return _arena is null ? _nodes![handle].SubtreeEndExclusive : _arena.FrozenSubtreeEnd(handle); - } - - internal CompactNodePayload GetPayload(int index) - { - if (_arena is null) - return _payloads![index]; - var value = _arena.FrozenPayloadValue(index); - return new CompactNodePayload( - _arena.FrozenFirstAttribute(index), - value.IsEmpty ? -1 : EncodePayloadValue(index), - value.Length, - _arena.FrozenAttributeCount(index) - ); - } - - internal CompactAttribute GetAttribute(int index) - { - if (_arena is null) - return _attributes![index]; - var value = _arena.FrozenAttributeValue(index); - return new CompactAttribute( - _arena.FrozenAttributeNameId(index), - value.IsEmpty ? -1 : EncodeAttributeValue(index), - value.Length - ); - } - - // Lightweight column accessors used by the hot attribute-lookup loops. They avoid materializing a - // CompactNode/CompactNodePayload/CompactAttribute struct (and, on the frozen path, the encode/decode - // round-trip) when the caller only needs the name ID or the value span. - internal int PayloadFirstAttributeAt(int payloadIndex) - { - return _arena is null ? _payloads![payloadIndex].FirstAttribute : _arena.FrozenFirstAttribute(payloadIndex); - } - - internal int PayloadAttributeCountAt(int payloadIndex) - { - return _arena is null ? _payloads![payloadIndex].AttributeCount : _arena.FrozenAttributeCount(payloadIndex); - } - - internal ushort AttributeNameIdAt(int index) - { - return _arena is null ? _attributes![index].NameId : _arena.FrozenAttributeNameId(index); - } - - internal ReadOnlySpan AttributeValueSpanAt(int index) - { - if (_arena is null) - { - ref readonly var attribute = ref _attributes![index]; - return attribute.ValueLength == 0 ? [] : _text!.AsSpan(attribute.ValueStart, attribute.ValueLength); - } - - return _arena.FrozenAttributeValue(index).Span; - } - - /// The value span for a payload index, without materializing a . - internal ReadOnlySpan PayloadValueSpanAt(int payloadIndex) - { - if (_arena is null) - { - ref readonly var payload = ref _payloads![payloadIndex]; - return payload.ValueLength == 0 ? [] : _text!.AsSpan(payload.ValueStart, payload.ValueLength); - } - - return _arena.FrozenPayloadValue(payloadIndex).Span; - } - - /// Returns the first-attribute index and count for a node handle, or false when it has no payload. - internal bool TryGetAttributeRange(int handle, out int firstAttribute, out int count) - { - var payloadIndex = PayloadIndexAt(handle); - if (payloadIndex < 0) - { - firstAttribute = 0; - count = 0; - return false; - } - - firstAttribute = PayloadFirstAttributeAt(payloadIndex); - count = PayloadAttributeCountAt(payloadIndex); - return true; - } - - internal bool TryGetAttribute(int handle, ushort nameId, out CompactAttribute attribute, ref int inspected) - { - if (nameId != ushort.MaxValue && TryGetAttributeRange(handle, out var first, out var count)) - for (var index = first; index < first + count; index++) - { - inspected++; - if (AttributeNameIdAt(index) == nameId) - { - attribute = GetAttribute(index); - return true; - } - } - - attribute = default; - return false; - } - - /// - /// Returns the next node at or after with the given name ID, or -1. - /// Frozen columns use a vectorized scan; packed documents use a scalar scan. - /// - internal int IndexOfName(string name, int start = 0, int endExclusive = int.MaxValue) - { - return IndexOfName(name.AsSpan(), start, endExclusive); - } - - /// - /// Resolves once, then returns the next matching node at or after - /// , or -1. - /// - internal int IndexOfName(ReadOnlySpan name, int start = 0, int endExclusive = int.MaxValue) - { - return IndexOfName(ResolveNameId(name), start, endExclusive); - } - - /// - /// Returns the next node at or after with the given pre-resolved name ID, or -1. - /// - internal int IndexOfName(ushort nameId, int start = 0, int endExclusive = int.MaxValue) - { - if (start < 0) - start = 0; - endExclusive = Math.Min(endExclusive, _nodeCount); - if (start >= endExclusive) - return -1; - if (_arena is not null) - { - var column = _arena.NameIdColumn; - var relative = MemoryMarshal - .Cast(column.Slice(start, endExclusive - start)) - .IndexOf((char)nameId); - return relative < 0 ? -1 : start + relative; - } - - for (var handle = start; handle < endExclusive; handle++) - if (_nodes![handle].NameId == nameId) - return handle; - return -1; - } - - internal string GetName(ushort id) - { - return id < GeneratedTagMetadata.KnownNameCount - ? GeneratedTagMetadata.GetKnownName(id) - : _names[id - GeneratedTagMetadata.KnownNameCount]; - } - - internal ReadOnlySpan GetValue(int start, int length) - { - if (length == 0) - return []; - if (_arena is null) - return _text!.AsSpan(start, length); - var memory = IsAttributeValue(start) - ? _arena.FrozenAttributeValue(DecodeValueIndex(start)) - : _arena.FrozenPayloadValue(DecodeValueIndex(start)); - return memory.Span[..length]; - } - - internal int GetParent(int handle) - { - if (!RetainsParentLinks) - throw new InvalidOperationException("Parent links were not retained."); - return _arena is null ? _parents![handle] : _arena.FrozenParent(handle); - } - - internal bool TryGetSourceLocation(int handle, out CompactSourceLocation source) - { - if (_arena is not null) - { - if (RetainsSourceLocations) - return _arena.TryGetFrozenSourceLocation(handle, out source); - source = default; - return false; - } - - if (_sources is not null && _sources[handle].Index >= 0) - { - source = _sources[handle]; - return true; - } - - source = default; - return false; - } - - internal int CountElements(string name) - { - return CountElements(name.AsSpan()); - } - - /// Resolves once, then counts matching elements. - internal int CountElements(ReadOnlySpan name) - { - return CountElements(ResolveNameId(name)); - } - - /// Counts elements using a previously resolved name ID. - internal int CountElements(ushort nameId) - { - var count = 0; - for (var handle = 0; handle < _nodeCount; handle++) - { - if (TryGetContainingTemplateContentEnd(handle, out var contentEnd)) - { - handle = contentEnd - 1; - continue; - } - - if (KindAt(handle) == CompactNodeKind.Element && NameIdAt(handle) == nameId) - count++; - } - - return count; - } - - internal bool IsTemplate(int handle) - { - if (!_hasTemplates) - return false; - foreach (var boundary in _templateBoundaries) - if (boundary.Handle == handle) - return true; - return false; - } - - internal bool TryGetTemplateContent(int handle, out int contentStart) - { - if (!_hasTemplates) - { - contentStart = -1; - return false; - } - - foreach (var boundary in _templateBoundaries) - { - if (boundary.Handle != handle) - continue; - contentStart = boundary.ContentStart; - return contentStart >= 0; - } - - contentStart = -1; - return false; - } - - internal bool TryGetContainingTemplateContentEnd(int handle, out int contentEnd) - { - contentEnd = -1; - if (!_hasTemplates) - return false; - foreach (var boundary in _templateBoundaries) - if (handle >= boundary.ContentStart && handle < boundary.ContentEnd) - contentEnd = Math.Max(contentEnd, boundary.ContentEnd); - return contentEnd >= 0; - } - - internal bool IsInSameTreeScope(int first, int second) - { - if (!_hasTemplates) - return true; - foreach (var boundary in _templateBoundaries) - { - var firstInContent = first >= boundary.ContentStart && first < boundary.ContentEnd; - var secondInContent = second >= boundary.ContentStart && second < boundary.ContentEnd; - if (firstInContent != secondInContent) - return false; - } - - return true; - } - - /// - /// Resolves a name only when it occurs in this document. This scans nodes and attributes. - /// - internal ushort FindNameId(string name) - { - return FindNameId(name.AsSpan()); - } - - internal ushort FindNameId(ReadOnlySpan name) - { - var id = ResolveNameId(name); - return id != ushort.MaxValue && ContainsNameId(id) ? id : ushort.MaxValue; - } - - /// - /// Resolves a stable name ID without checking whether it occurs in this document. - /// - internal ushort ResolveNameId(string name) - { - return ResolveNameId(name.AsSpan()); - } - - internal ushort ResolveNameId(ReadOnlySpan name) - { - if (GeneratedTagMetadata.TryGetKnownNameId(name, out var knownId)) - return knownId; - for (ushort i = 0; i < _nameCount; i++) - if (name.SequenceEqual(_names[i])) - return checked((ushort)(GeneratedTagMetadata.KnownNameCount + i)); - return ushort.MaxValue; - } - - private static int EncodePayloadValue(int index) - { - return checked(index << 1); - } - - private static int EncodeAttributeValue(int index) - { - return checked((index << 1) | 1); - } - - private static bool IsAttributeValue(int value) - { - return (value & 1) != 0; - } - - private static int DecodeValueIndex(int value) - { - return value >> 1; - } - - private bool ContainsNameId(ushort id) - { - for (var handle = 0; handle < _nodeCount; handle++) - if (NameIdAt(handle) == id) - return true; - for (var attribute = 0; attribute < _attributeCount; attribute++) - if (AttributeNameIdAt(attribute) == id) - return true; - return false; - } } From ff02242895b41cbeac730f47c274ea3588fca850 Mon Sep 17 00:00:00 2001 From: dv00d00 <1162823+dv00d00@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:51:44 +0100 Subject: [PATCH 2/2] Separate query execution concerns --- .../Execution/QueryExecution.Captures.cs | 197 ++++++ .../Execution/QueryExecution.TokenSink.cs | 364 +++++++++++ .../Query/Execution/QueryExecution.cs | 579 +----------------- 3 files changed, 570 insertions(+), 570 deletions(-) create mode 100644 src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.Captures.cs create mode 100644 src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.TokenSink.cs diff --git a/src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.Captures.cs b/src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.Captures.cs new file mode 100644 index 0000000..e204905 --- /dev/null +++ b/src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.Captures.cs @@ -0,0 +1,197 @@ +using System.Numerics; +using System.Runtime.CompilerServices; + +namespace AngleSharp.ReadOnlyDom.Streaming.Query.Execution; + +internal partial class QueryExecution + where TResourceLimits : struct, IResourceLimitPolicy +{ + [MethodImpl(MethodImplOptions.NoInlining)] + private void DisposeCompletedCaptures() + { + foreach (var captures in _completedCaptures) + { + if (captures is null) + continue; + foreach (var capture in captures) + capture.Dispose(); + } + if (_reusableCaptures is not null) + { + foreach (var capture in _reusableCaptures) + capture.Dispose(); + _reusableCaptures.Clear(); + } + Array.Clear(_completedCaptures); + } + + private void StartCompletedCaptures(ulong matches) + { + var completed = matches & _plan.CompletedHandlerMask; + while (completed != 0) + { + var index = BitOperations.TrailingZeroCount(completed); + completed &= completed - 1; + var node = _plan.Nodes[index]; + var capture = _reusableCaptures!.Count == 0 ? new CapturedElementBuffer() : _reusableCaptures.Pop(); + capture.Reset(node.CompletedTextMode, node.CapturedAttributeIndexes.Length); + for (var attribute = 0; attribute < node.CapturedAttributeIndexes.Length; attribute++) + { + var attributeIndex = node.CapturedAttributeIndexes[attribute]; + if (_attributeLengths[attributeIndex] >= 0) + { + var value = GetAttributeValue(attributeIndex); + capture.SetAttribute(attribute, value); + if (TResourceLimits.Enabled) + { + _queryCaptureBytes += value.Length; + } + } + } + capture.BeginText(); + var captures = _completedCaptures[index] ??= []; + captures.Add(capture); + if (node.CompletedTextMode != CompletedTextMode.None) + _activeCompletedTextCaptures++; + if (node.CompletedTextMode == CompletedTextMode.Normalized) + _activeNormalizedTextCaptures++; + } + } + + /// + /// Separates words in every open normalized capture. Callers have already established that this + /// tag is a boundary and that at least one normalized capture is open, so this walks only the + /// normalized nodes and never the raw ones. + /// + [MethodImpl(MethodImplOptions.NoInlining)] + private void MarkTextBoundary() + { + var completed = _normalizedTextMask; + while (completed != 0) + { + var index = BitOperations.TrailingZeroCount(completed); + completed &= completed - 1; + var captures = _completedCaptures[index]; + if (captures is null) + continue; + foreach (var capture in captures) + capture.MarkBoundary(); + } + } + + private void AppendCompletedText(ReadOnlySpan utf8) + { + var completed = _plan.CompletedHandlerMask; + while (completed != 0) + { + var index = BitOperations.TrailingZeroCount(completed); + completed &= completed - 1; + var captures = _completedCaptures[index]; + if (captures is null) + continue; + foreach (var capture in captures) + { + var previousLength = capture.BufferedByteCount; + capture.Append(utf8); + if (TResourceLimits.Enabled) + { + _queryCaptureBytes += capture.BufferedByteCount - previousLength; + } + } + } + } + + private void CompleteCapture(int index) + { + var node = _plan.Nodes[index]; + if (node.Completed is null) + return; + var captures = _completedCaptures[index]; + if (captures is null || captures.Count == 0) + throw new InvalidOperationException("The completed-element capture stack is unbalanced."); + var captureIndex = captures.Count - 1; + var capture = captures[captureIndex]; + captures.RemoveAt(captureIndex); + if (node.CompletedTextMode != CompletedTextMode.None) + _activeCompletedTextCaptures--; + if (node.CompletedTextMode == CompletedTextMode.Normalized) + _activeNormalizedTextCaptures--; + if (TResourceLimits.Enabled) + { + _queryCaptureBytes -= capture.BufferedByteCount; + } + try + { + var element = new CompletedElement( + capture, + _plan.AttributeNames, + _plan.AttributeNamesUtf8, + node.CapturedAttributeIndexes + ); + node.Completed.Invoke(ref _state, in element); + } + finally + { + _reusableCaptures!.Push(capture); + } + } + + private long GetCompletedAttributeBytes(ulong matches) + { + var total = 0L; + var completed = matches & _plan.CompletedHandlerMask; + while (completed != 0) + { + var index = BitOperations.TrailingZeroCount(completed); + completed &= completed - 1; + foreach (var attributeIndex in _plan.Nodes[index].CapturedAttributeIndexes) + { + var length = _attributeLengths[attributeIndex]; + if (length > 0) + total = SaturatingAdd(total, length); + } + } + return total; + } + + private long GetCompletedTextUpperBound(int textLength) + { + if (textLength == 0) + return 0; + + var total = 0L; + var completed = _plan.CompletedHandlerMask; + while (completed != 0) + { + var index = BitOperations.TrailingZeroCount(completed); + completed &= completed - 1; + if (_plan.Nodes[index].CompletedTextMode == CompletedTextMode.None) + continue; + var captures = _completedCaptures[index]; + if (captures is null) + continue; + foreach (var capture in captures) + { + total = SaturatingAdd(total, textLength); + if (capture.HasPendingNormalizedSpace) + total = SaturatingAdd(total, 1); + } + } + return total; + } + + private void EnsureQueryCaptureCapacity(long additional) + { + var observed = + _queryCaptureBytes > long.MaxValue - additional ? long.MaxValue : _queryCaptureBytes + additional; + if (observed > _maximumQueryCaptureBytes) + throw new HtmlStreamingLimitExceededException( + HtmlStreamingLimit.QueryCaptureBytes, + _maximumQueryCaptureBytes, + observed + ); + } + + private static long SaturatingAdd(long value, long additional) => + value > long.MaxValue - additional ? long.MaxValue : value + additional; +} diff --git a/src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.TokenSink.cs b/src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.TokenSink.cs new file mode 100644 index 0000000..f5ecdc8 --- /dev/null +++ b/src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.TokenSink.cs @@ -0,0 +1,364 @@ +using System.Buffers; +using System.Numerics; +using AngleSharp.ReadOnlyDom.Streaming.Query.Rewriting; +using AngleSharp.ReadOnlyDom.Streaming.Tokenization; + +namespace AngleSharp.ReadOnlyDom.Streaming.Query.Execution; + +internal partial class QueryExecution + where TResourceLimits : struct, IResourceLimitPolicy +{ + public void ObserveNormalizedUtf8End(long sourceStart, ReadOnlySpan utf8, long publishableOffset) + { + _observedUtf8End = sourceStart + utf8.Length; + _streamingRewriteCollector?.PublishWindow(sourceStart, utf8, publishableOffset); + } + + public void RawText(long sourceStart, ReadOnlySpan utf8, Utf8HtmlTextType textType, bool isLastInTextNode) + { + if (!WantsRawText) + return; + var chunk = new TextChunk(utf8, (HtmlTextType)textType, isLastInTextNode); + var rewriter = new TextChunkRewriter(_rewriteCollector!, sourceStart, sourceStart + utf8.Length); + TextRewriteHandler.Invoke(ref _state, in chunk, ref rewriter); + rewriter.Commit(); + } + + public Utf8HtmlStartTagCapture StartTag(Utf8HtmlName name) + { + ReleasePendingFallbackTagName(); + var identityLength = 0; + if (!name.TryGetCompactKey(out var identity)) + { + identity = name.SemanticHash; + identityLength = name.Verbatim.Length; + _pendingFallbackTagNameUtf8 = ArrayPool.Shared.Rent(identityLength); + name.Verbatim.CopyTo(_pendingFallbackTagNameUtf8); + } + _pendingTagIdentity = identity; + _pendingTagIdentityLength = identityLength; + _pendingTagNameLength = name.Verbatim.Length; + _pendingCandidateBits = 0; + _pendingAttributeBits = 0; + _pendingAttributeFilter = 0; + _pendingAttributeNameLengths = 0; + _pendingAttributeIndex = -1; + var candidates = FindTagCandidates(identity, identityLength); + while (candidates != 0) + { + var index = BitOperations.TrailingZeroCount(candidates); + candidates &= candidates - 1; + var node = _plan.Nodes[index]; + if ((identityLength != 0 && !name.SemanticEquals(node.TagNameUtf8)) || !ParentMatches(node)) + continue; + _pendingCandidateBits |= 1UL << node.Index; + _pendingAttributeBits |= node.RequestedAttributeMask; + _pendingAttributeFilter |= node.RequestedAttributeFilter; + _pendingAttributeNameLengths |= node.RequestedAttributeNameLengths; + } + ResetAttributes(); + return _pendingAttributeBits == 0 ? Utf8HtmlStartTagCapture.None : Utf8HtmlStartTagCapture.Attributes; + } + + /// + /// Bloom of the semantic hashes of every attribute name any candidate node on the current + /// tag requests. WantsAttribute is a pure function of the semantic name for the duration of + /// the tag (it only consults _pendingAttributeBits, fixed at StartTag), so the tokenizer may + /// reject filter-missed names without calling back. + /// + public ulong StartTagAttributeFilter => _pendingAttributeFilter; + + /// + /// Byte lengths of the attribute names any candidate node on the current tag requests, as bits. + /// Same purity contract as , and cheaper for the tokenizer + /// to consult: a length is known before the name has been hashed. + /// + public ulong StartTagAttributeNameLengths => _pendingAttributeNameLengths; + + public bool WantsAttribute(Utf8HtmlName name) + { + _pendingAttributeIndex = -1; + var identity = 0UL; + var hasCompactIdentity = + (_pendingAttributeBits & _plan.CompactAttributeMask) != 0 && name.TryGetCompactKey(out identity); + + var attributes = _pendingAttributeBits; + while (attributes != 0) + { + var index = BitOperations.TrailingZeroCount(attributes); + attributes &= attributes - 1; + var expected = _plan.AttributeIdentities[index]; + if (hasCompactIdentity) + { + if (expected.Length != 0 || expected.Value != identity) + continue; + } + else if ( + expected.Length == 0 + || expected.Length != name.Verbatim.Length + || !name.SemanticEquals(_plan.AttributeNamesUtf8[index]) + ) + { + continue; + } + _pendingAttributeIndex = index; + return true; + } + return false; + } + + public void Attribute(Utf8HtmlName name, ReadOnlySpan value, bool valueMayContainReferences) + { + var index = _pendingAttributeIndex; + _pendingAttributeIndex = -1; + if (index < 0 || _attributeLengths[index] >= 0) + return; + if (TResourceLimits.Enabled) + { + EnsureQueryCaptureCapacity(value.Length); + } + EnsureAttributeCapacity(value.Length); + _attributeStarts[index] = _attributeValueLength; + _attributeLengths[index] = value.Length; + _seenAttributeBits |= 1UL << index; + if (valueMayContainReferences) + _rawAttributeBits |= 1UL << index; + value.CopyTo(_attributeValues.AsSpan(_attributeValueLength)); + _attributeValueLength += value.Length; + if (TResourceLimits.Enabled) + { + _queryCaptureBytes += value.Length; + } + } + + public void StartTagSourceRange(long sourceStart, long sourceEnd) + { + _startTagSourceStart = sourceStart; + _startTagSourceEnd = sourceEnd; + } + + public void StartTagEnd(bool selfClosing) + { + StartTagEndCore(selfClosing, _startTagSourceStart, _startTagSourceEnd); + _startTagSourceStart = -1; + _startTagSourceEnd = -1; + } + + private void StartTagEndCore(bool selfClosing, long sourceStart, long sourceEnd) + { + // Classify only inside an open normalized capture, then carry the result to the close in + // the frame's sign bit. A frame opened before the outermost capture cannot close while that + // capture is active: lexical recovery closes inner frames first. + var isTextBoundary = + _activeNormalizedTextCaptures != 0 + && HtmlTextBoundaryElements.IsBoundary(_pendingTagIdentity, _pendingTagIdentityLength); + if (isTextBoundary) + MarkTextBoundary(); + var matches = 0UL; + var candidates = _pendingCandidateBits; + while (candidates != 0) + { + var index = BitOperations.TrailingZeroCount(candidates); + candidates &= candidates - 1; + var node = _plan.Nodes[index]; + if (!PredicatesMatch(node.Predicates)) + continue; + matches |= 1UL << node.Index; + } + + var closesImmediately = IsVoidTag(_pendingTagIdentity, _pendingTagIdentityLength, _pendingTagNameLength); + if (TResourceLimits.Enabled && !closesImmediately && _frameCount >= _maximumNestingDepth) + throw new HtmlStreamingLimitExceededException( + HtmlStreamingLimit.NestingDepth, + _maximumNestingDepth, + (long)_frameCount + 1 + ); + if (TResourceLimits.Enabled) + { + EnsureQueryCaptureCapacity(GetCompletedAttributeBytes(matches)); + } + + var starts = matches; + while (starts != 0) + { + var index = BitOperations.TrailingZeroCount(starts); + starts &= starts - 1; + var node = _plan.Nodes[index]; + if (node.Start is null) + continue; + var element = CreateElement(node.RequestedAttributeMask); + node.Start.Invoke(ref _state, in element); + } + var rewriteScopeId = -1; + var rewriteHandler = ElementRewriteHandler; + if (rewriteHandler is not null && (matches & _plan.TerminalNodeMask) != 0) + { + if (sourceStart < 0 || sourceEnd <= sourceStart) + throw new InvalidOperationException("The tokenizer did not provide a valid start-tag source range."); + var element = CreateElement(GetRequestedAttributeMask(matches & _plan.TerminalNodeMask)); + var editor = new ElementRewriter( + _rewriteCollector!, + sourceStart, + sourceEnd, + !closesImmediately, + selfClosing + ); + rewriteHandler.Invoke(ref _state, in element, ref editor); + editor.Commit(); + rewriteScopeId = editor.ScopeId; + } + StartCompletedCaptures(matches); + + if (closesImmediately) + { + try + { + _rewriteCollector?.EndElement(rewriteScopeId, sourceEnd, sourceEnd, hasExplicitEndTag: false); + CloseMatches(matches); + } + finally + { + ReleasePendingFallbackTagName(); + } + return; + } + + EnsureFrameCapacity(); + _frames[_frameCount++] = new QueryFrame( + _pendingTagIdentity, + isTextBoundary ? _pendingTagIdentityLength | TextBoundaryFrameFlag : _pendingTagIdentityLength, + _pendingFallbackTagNameUtf8, + matches, + rewriteScopeId + ); + _pendingFallbackTagNameUtf8 = null; + IncrementActive(matches); + } + + private Element CreateElement(ulong allowedAttributeMask) => + new(_plan.AttributeNames, _plan.AttributeNamesUtf8, this, allowedAttributeMask); + + bool IElementAttributeSource.TryGetAttributeValue(int index, out ReadOnlySpan value) + { + if (_attributeLengths[index] < 0) + { + value = default; + return false; + } + value = GetAttributeValue(index); + return true; + } + + private ulong GetRequestedAttributeMask(ulong nodes) + { + var attributes = 0UL; + while (nodes != 0) + { + var index = BitOperations.TrailingZeroCount(nodes); + nodes &= nodes - 1; + attributes |= _plan.Nodes[index].RequestedAttributeMask; + } + return attributes; + } + + public void Text(ReadOnlySpan utf8) + { + if (_plan.TextHandlerMask == 0 && _plan.CompletedHandlerMask == 0) + return; + if (TResourceLimits.Enabled) + { + EnsureQueryCaptureCapacity(GetCompletedTextUpperBound(utf8.Length)); + } + var handlers = _plan.TextHandlerMask; + while (handlers != 0) + { + var nodeIndex = BitOperations.TrailingZeroCount(handlers); + handlers &= handlers - 1; + if (_activeCounts[nodeIndex] == 0) + continue; + _plan.Nodes[nodeIndex].Text!.Invoke(ref _state, utf8); + } + AppendCompletedText(utf8); + } + + bool IUtf8HtmlStreamingCommentSink.BeginComment() => false; + + void IUtf8HtmlStreamingCommentSink.CommentChunk(ReadOnlySpan utf8) { } + + void IUtf8HtmlStreamingCommentSink.EndComment() { } + + public void EndTagSourceRange(long sourceStart, long sourceEnd) + { + _endTagSourceStart = sourceStart; + _endTagSourceEnd = sourceEnd; + } + + public void EndTag(Utf8HtmlName name) + { + var identityLength = 0; + if (!name.TryGetCompactKey(out var identity)) + { + identity = name.SemanticHash; + identityLength = name.Verbatim.Length; + } + for (var index = _frameCount - 1; index >= 0; index--) + { + if ( + _frames[index].TagIdentity != identity + || (_frames[index].TagIdentityLength & TagIdentityLengthMask) != identityLength + ) + continue; + if ( + identityLength != 0 + && !name.SemanticEquals(_frames[index].FallbackTagNameUtf8.AsSpan(0, identityLength)) + ) + continue; + for (var popped = _frameCount - 1; popped >= index; popped--) + { + var frame = _frames[popped]; + _frames[popped] = default; + _frameCount = popped; + var explicitEnd = popped == index; + CloseFrame(frame, _endTagSourceStart, explicitEnd ? _endTagSourceEnd : _endTagSourceStart, explicitEnd); + } + _endTagSourceStart = -1; + _endTagSourceEnd = -1; + return; + } + _endTagSourceStart = -1; + _endTagSourceEnd = -1; + } + + private ulong FindTagCandidates(ulong identity, int identityLength) + { + var entries = _plan.TagDispatch; + var low = 0; + var high = entries.Length - 1; + while (low <= high) + { + var middle = (low + high) >>> 1; + var entry = entries[middle]; + var comparison = entry.Identity.CompareTo(identity); + if (comparison == 0) + comparison = entry.IdentityLength.CompareTo(identityLength); + if (comparison < 0) + low = middle + 1; + else if (comparison > 0) + high = middle - 1; + else + return entry.CandidateBits; + } + return 0; + } + + public void EndOfFile() + { + for (var index = _frameCount - 1; index >= 0; index--) + { + var frame = _frames[index]; + _frames[index] = default; + _frameCount = index; + CloseFrame(frame, _observedUtf8End, _observedUtf8End, hasExplicitEndTag: false); + } + } +} diff --git a/src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.cs b/src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.cs index cd006e0..eea467a 100644 --- a/src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.cs +++ b/src/AngleSharp.ReadOnlyDom.Streaming/Query/Execution/QueryExecution.cs @@ -11,7 +11,7 @@ internal interface IQueryExecution : IUtf8HtmlTokenSink, IDisposable TState State { get; } } -internal class QueryExecution +internal partial class QueryExecution : IUtf8HtmlStartTagSourceRangeSink, IUtf8HtmlRawTextSink, IUtf8HtmlStreamingCommentSink, @@ -45,7 +45,8 @@ internal class QueryExecution private bool _disposed; private readonly int _maximumNestingDepth; private readonly long _maximumQueryCaptureBytes; - private readonly object? _rewriteHandlers; + private readonly RewriteHandler? _elementRewriteHandler; + private readonly TextRewriteHandler? _textRewriteHandler; private readonly IHtmlRewriteCollector? _rewriteCollector; private readonly Utf8StreamingRewriteCollector? _streamingRewriteCollector; private long _startTagSourceStart = -1; @@ -83,18 +84,8 @@ internal QueryExecution( _state = state; _maximumNestingDepth = limits.MaximumNestingDepth; _maximumQueryCaptureBytes = limits.MaximumQueryCaptureBytes; - if (rewriteHandler is not null && textRewriteHandler is not null) - { - _rewriteHandlers = new RewriteHandlerPair(rewriteHandler, textRewriteHandler); - } - else if (rewriteHandler is not null) - { - _rewriteHandlers = rewriteHandler; - } - else if (textRewriteHandler is not null) - { - _rewriteHandlers = textRewriteHandler; - } + _elementRewriteHandler = rewriteHandler; + _textRewriteHandler = textRewriteHandler; _rewriteCollector = rewriteCollector; _normalizedTextMask = plan.NormalizedTextHandlerMask; _streamingRewriteCollector = rewriteCollector as Utf8StreamingRewriteCollector; @@ -116,7 +107,7 @@ internal QueryExecution( ? Utf8HtmlTokenCapture.Text : Utf8HtmlTokenCapture.None; - public bool WantsStartTagSourceRanges => _rewriteHandlers is not null; + public bool WantsStartTagSourceRanges => _elementRewriteHandler is not null || _textRewriteHandler is not null; public bool IsRawTextEnabled => HasTextRewriteHandler; @@ -125,360 +116,6 @@ internal QueryExecution( public bool WantsEndTagSourceRanges => HasTextRewriteHandler || _rewriteCollector?.NeedsEndTagSourceRanges == true; - public void ObserveNormalizedUtf8End(long sourceStart, ReadOnlySpan utf8, long publishableOffset) - { - _observedUtf8End = sourceStart + utf8.Length; - _streamingRewriteCollector?.PublishWindow(sourceStart, utf8, publishableOffset); - } - - public void RawText(long sourceStart, ReadOnlySpan utf8, Utf8HtmlTextType textType, bool isLastInTextNode) - { - if (!WantsRawText) - return; - var chunk = new TextChunk(utf8, (HtmlTextType)textType, isLastInTextNode); - var rewriter = new TextChunkRewriter(_rewriteCollector!, sourceStart, sourceStart + utf8.Length); - TextRewriteHandler.Invoke(ref _state, in chunk, ref rewriter); - rewriter.Commit(); - } - - public Utf8HtmlStartTagCapture StartTag(Utf8HtmlName name) - { - ReleasePendingFallbackTagName(); - var identityLength = 0; - if (!name.TryGetCompactKey(out var identity)) - { - identity = name.SemanticHash; - identityLength = name.Verbatim.Length; - _pendingFallbackTagNameUtf8 = ArrayPool.Shared.Rent(identityLength); - name.Verbatim.CopyTo(_pendingFallbackTagNameUtf8); - } - _pendingTagIdentity = identity; - _pendingTagIdentityLength = identityLength; - _pendingTagNameLength = name.Verbatim.Length; - _pendingCandidateBits = 0; - _pendingAttributeBits = 0; - _pendingAttributeFilter = 0; - _pendingAttributeNameLengths = 0; - _pendingAttributeIndex = -1; - var candidates = FindTagCandidates(identity, identityLength); - while (candidates != 0) - { - var index = BitOperations.TrailingZeroCount(candidates); - candidates &= candidates - 1; - var node = _plan.Nodes[index]; - if ((identityLength != 0 && !name.SemanticEquals(node.TagNameUtf8)) || !ParentMatches(node)) - continue; - _pendingCandidateBits |= 1UL << node.Index; - _pendingAttributeBits |= node.RequestedAttributeMask; - _pendingAttributeFilter |= node.RequestedAttributeFilter; - _pendingAttributeNameLengths |= node.RequestedAttributeNameLengths; - } - ResetAttributes(); - return _pendingAttributeBits == 0 ? Utf8HtmlStartTagCapture.None : Utf8HtmlStartTagCapture.Attributes; - } - - /// - /// Bloom of the semantic hashes of every attribute name any candidate node on the current - /// tag requests. WantsAttribute is a pure function of the semantic name for the duration of - /// the tag (it only consults _pendingAttributeBits, fixed at StartTag), so the tokenizer may - /// reject filter-missed names without calling back. - /// - public ulong StartTagAttributeFilter => _pendingAttributeFilter; - - /// - /// Byte lengths of the attribute names any candidate node on the current tag requests, as bits. - /// Same purity contract as , and cheaper for the tokenizer - /// to consult: a length is known before the name has been hashed. - /// - public ulong StartTagAttributeNameLengths => _pendingAttributeNameLengths; - - public bool WantsAttribute(Utf8HtmlName name) - { - _pendingAttributeIndex = -1; - var identity = 0UL; - var hasCompactIdentity = - (_pendingAttributeBits & _plan.CompactAttributeMask) != 0 && name.TryGetCompactKey(out identity); - - var attributes = _pendingAttributeBits; - while (attributes != 0) - { - var index = BitOperations.TrailingZeroCount(attributes); - attributes &= attributes - 1; - var expected = _plan.AttributeIdentities[index]; - if (hasCompactIdentity) - { - if (expected.Length != 0 || expected.Value != identity) - continue; - } - else if ( - expected.Length == 0 - || expected.Length != name.Verbatim.Length - || !name.SemanticEquals(_plan.AttributeNamesUtf8[index]) - ) - { - continue; - } - _pendingAttributeIndex = index; - return true; - } - return false; - } - - public void Attribute(Utf8HtmlName name, ReadOnlySpan value, bool valueMayContainReferences) - { - var index = _pendingAttributeIndex; - _pendingAttributeIndex = -1; - if (index < 0 || _attributeLengths[index] >= 0) - return; - if (TResourceLimits.Enabled) - { - EnsureQueryCaptureCapacity(value.Length); - } - EnsureAttributeCapacity(value.Length); - _attributeStarts[index] = _attributeValueLength; - _attributeLengths[index] = value.Length; - _seenAttributeBits |= 1UL << index; - if (valueMayContainReferences) - _rawAttributeBits |= 1UL << index; - value.CopyTo(_attributeValues.AsSpan(_attributeValueLength)); - _attributeValueLength += value.Length; - if (TResourceLimits.Enabled) - { - _queryCaptureBytes += value.Length; - } - } - - public void StartTagSourceRange(long sourceStart, long sourceEnd) - { - _startTagSourceStart = sourceStart; - _startTagSourceEnd = sourceEnd; - } - - public void StartTagEnd(bool selfClosing) - { - StartTagEndCore(selfClosing, _startTagSourceStart, _startTagSourceEnd); - _startTagSourceStart = -1; - _startTagSourceEnd = -1; - } - - private void StartTagEndCore(bool selfClosing, long sourceStart, long sourceEnd) - { - // Classify only inside an open normalized capture, then carry the result to the close in - // the frame's sign bit. A frame opened before the outermost capture cannot close while that - // capture is active: lexical recovery closes inner frames first. - var isTextBoundary = - _activeNormalizedTextCaptures != 0 - && HtmlTextBoundaryElements.IsBoundary(_pendingTagIdentity, _pendingTagIdentityLength); - if (isTextBoundary) - MarkTextBoundary(); - var matches = 0UL; - var candidates = _pendingCandidateBits; - while (candidates != 0) - { - var index = BitOperations.TrailingZeroCount(candidates); - candidates &= candidates - 1; - var node = _plan.Nodes[index]; - if (!PredicatesMatch(node.Predicates)) - continue; - matches |= 1UL << node.Index; - } - - var closesImmediately = IsVoidTag(_pendingTagIdentity, _pendingTagIdentityLength, _pendingTagNameLength); - if (TResourceLimits.Enabled && !closesImmediately && _frameCount >= _maximumNestingDepth) - throw new HtmlStreamingLimitExceededException( - HtmlStreamingLimit.NestingDepth, - _maximumNestingDepth, - (long)_frameCount + 1 - ); - if (TResourceLimits.Enabled) - { - EnsureQueryCaptureCapacity(GetCompletedAttributeBytes(matches)); - } - - var starts = matches; - while (starts != 0) - { - var index = BitOperations.TrailingZeroCount(starts); - starts &= starts - 1; - var node = _plan.Nodes[index]; - if (node.Start is null) - continue; - var element = CreateElement(node.RequestedAttributeMask); - node.Start.Invoke(ref _state, in element); - } - var rewriteScopeId = -1; - var rewriteHandler = ElementRewriteHandler; - if (rewriteHandler is not null && (matches & _plan.TerminalNodeMask) != 0) - { - if (sourceStart < 0 || sourceEnd <= sourceStart) - throw new InvalidOperationException("The tokenizer did not provide a valid start-tag source range."); - var element = CreateElement(GetRequestedAttributeMask(matches & _plan.TerminalNodeMask)); - var editor = new ElementRewriter( - _rewriteCollector!, - sourceStart, - sourceEnd, - !closesImmediately, - selfClosing - ); - rewriteHandler.Invoke(ref _state, in element, ref editor); - editor.Commit(); - rewriteScopeId = editor.ScopeId; - } - StartCompletedCaptures(matches); - - if (closesImmediately) - { - try - { - _rewriteCollector?.EndElement(rewriteScopeId, sourceEnd, sourceEnd, hasExplicitEndTag: false); - CloseMatches(matches); - } - finally - { - ReleasePendingFallbackTagName(); - } - return; - } - - EnsureFrameCapacity(); - _frames[_frameCount++] = new QueryFrame( - _pendingTagIdentity, - isTextBoundary ? _pendingTagIdentityLength | TextBoundaryFrameFlag : _pendingTagIdentityLength, - _pendingFallbackTagNameUtf8, - matches, - rewriteScopeId - ); - _pendingFallbackTagNameUtf8 = null; - IncrementActive(matches); - } - - private Element CreateElement(ulong allowedAttributeMask) => - new(_plan.AttributeNames, _plan.AttributeNamesUtf8, this, allowedAttributeMask); - - bool IElementAttributeSource.TryGetAttributeValue(int index, out ReadOnlySpan value) - { - if (_attributeLengths[index] < 0) - { - value = default; - return false; - } - value = GetAttributeValue(index); - return true; - } - - private ulong GetRequestedAttributeMask(ulong nodes) - { - var attributes = 0UL; - while (nodes != 0) - { - var index = BitOperations.TrailingZeroCount(nodes); - nodes &= nodes - 1; - attributes |= _plan.Nodes[index].RequestedAttributeMask; - } - return attributes; - } - - public void Text(ReadOnlySpan utf8) - { - if (_plan.TextHandlerMask == 0 && _plan.CompletedHandlerMask == 0) - return; - if (TResourceLimits.Enabled) - { - EnsureQueryCaptureCapacity(GetCompletedTextUpperBound(utf8.Length)); - } - var handlers = _plan.TextHandlerMask; - while (handlers != 0) - { - var nodeIndex = BitOperations.TrailingZeroCount(handlers); - handlers &= handlers - 1; - if (_activeCounts[nodeIndex] == 0) - continue; - _plan.Nodes[nodeIndex].Text!.Invoke(ref _state, utf8); - } - AppendCompletedText(utf8); - } - - bool IUtf8HtmlStreamingCommentSink.BeginComment() => false; - - void IUtf8HtmlStreamingCommentSink.CommentChunk(ReadOnlySpan utf8) { } - - void IUtf8HtmlStreamingCommentSink.EndComment() { } - - public void EndTagSourceRange(long sourceStart, long sourceEnd) - { - _endTagSourceStart = sourceStart; - _endTagSourceEnd = sourceEnd; - } - - public void EndTag(Utf8HtmlName name) - { - var identityLength = 0; - if (!name.TryGetCompactKey(out var identity)) - { - identity = name.SemanticHash; - identityLength = name.Verbatim.Length; - } - for (var index = _frameCount - 1; index >= 0; index--) - { - if ( - _frames[index].TagIdentity != identity - || (_frames[index].TagIdentityLength & TagIdentityLengthMask) != identityLength - ) - continue; - if ( - identityLength != 0 - && !name.SemanticEquals(_frames[index].FallbackTagNameUtf8.AsSpan(0, identityLength)) - ) - continue; - for (var popped = _frameCount - 1; popped >= index; popped--) - { - var frame = _frames[popped]; - _frames[popped] = default; - _frameCount = popped; - var explicitEnd = popped == index; - CloseFrame(frame, _endTagSourceStart, explicitEnd ? _endTagSourceEnd : _endTagSourceStart, explicitEnd); - } - _endTagSourceStart = -1; - _endTagSourceEnd = -1; - return; - } - _endTagSourceStart = -1; - _endTagSourceEnd = -1; - } - - private ulong FindTagCandidates(ulong identity, int identityLength) - { - var entries = _plan.TagDispatch; - var low = 0; - var high = entries.Length - 1; - while (low <= high) - { - var middle = (low + high) >>> 1; - var entry = entries[middle]; - var comparison = entry.Identity.CompareTo(identity); - if (comparison == 0) - comparison = entry.IdentityLength.CompareTo(identityLength); - if (comparison < 0) - low = middle + 1; - else if (comparison > 0) - high = middle - 1; - else - return entry.CandidateBits; - } - return 0; - } - - public void EndOfFile() - { - for (var index = _frameCount - 1; index >= 0; index--) - { - var frame = _frames[index]; - _frames[index] = default; - _frameCount = index; - CloseFrame(frame, _observedUtf8End, _observedUtf8End, hasExplicitEndTag: false); - } - } - public void Dispose() { if (_disposed) @@ -516,25 +153,6 @@ private void ReleaseLiveFrames() _frameCount = 0; } - [MethodImpl(MethodImplOptions.NoInlining)] - private void DisposeCompletedCaptures() - { - foreach (var captures in _completedCaptures) - { - if (captures is null) - continue; - foreach (var capture in captures) - capture.Dispose(); - } - if (_reusableCaptures is not null) - { - foreach (var capture in _reusableCaptures) - capture.Dispose(); - _reusableCaptures.Clear(); - } - Array.Clear(_completedCaptures); - } - private bool ParentMatches(QueryPlanNode node) { if (node.ParentIndex < 0) @@ -641,117 +259,6 @@ private void CloseMatches(ulong matches) } } - private void StartCompletedCaptures(ulong matches) - { - var completed = matches & _plan.CompletedHandlerMask; - while (completed != 0) - { - var index = BitOperations.TrailingZeroCount(completed); - completed &= completed - 1; - var node = _plan.Nodes[index]; - var capture = _reusableCaptures!.Count == 0 ? new CapturedElementBuffer() : _reusableCaptures.Pop(); - capture.Reset(node.CompletedTextMode, node.CapturedAttributeIndexes.Length); - for (var attribute = 0; attribute < node.CapturedAttributeIndexes.Length; attribute++) - { - var attributeIndex = node.CapturedAttributeIndexes[attribute]; - if (_attributeLengths[attributeIndex] >= 0) - { - var value = GetAttributeValue(attributeIndex); - capture.SetAttribute(attribute, value); - if (TResourceLimits.Enabled) - { - _queryCaptureBytes += value.Length; - } - } - } - capture.BeginText(); - var captures = _completedCaptures[index] ??= []; - captures.Add(capture); - if (node.CompletedTextMode != CompletedTextMode.None) - _activeCompletedTextCaptures++; - if (node.CompletedTextMode == CompletedTextMode.Normalized) - _activeNormalizedTextCaptures++; - } - } - - /// - /// Separates words in every open normalized capture. Callers have already established that this - /// tag is a boundary and that at least one normalized capture is open, so this walks only the - /// normalized nodes and never the raw ones. - /// - [MethodImpl(MethodImplOptions.NoInlining)] - private void MarkTextBoundary() - { - var completed = _normalizedTextMask; - while (completed != 0) - { - var index = BitOperations.TrailingZeroCount(completed); - completed &= completed - 1; - var captures = _completedCaptures[index]; - if (captures is null) - continue; - foreach (var capture in captures) - capture.MarkBoundary(); - } - } - - private void AppendCompletedText(ReadOnlySpan utf8) - { - var completed = _plan.CompletedHandlerMask; - while (completed != 0) - { - var index = BitOperations.TrailingZeroCount(completed); - completed &= completed - 1; - var captures = _completedCaptures[index]; - if (captures is null) - continue; - foreach (var capture in captures) - { - var previousLength = capture.BufferedByteCount; - capture.Append(utf8); - if (TResourceLimits.Enabled) - { - _queryCaptureBytes += capture.BufferedByteCount - previousLength; - } - } - } - } - - private void CompleteCapture(int index) - { - var node = _plan.Nodes[index]; - if (node.Completed is null) - return; - var captures = _completedCaptures[index]; - if (captures is null || captures.Count == 0) - throw new InvalidOperationException("The completed-element capture stack is unbalanced."); - var captureIndex = captures.Count - 1; - var capture = captures[captureIndex]; - captures.RemoveAt(captureIndex); - if (node.CompletedTextMode != CompletedTextMode.None) - _activeCompletedTextCaptures--; - if (node.CompletedTextMode == CompletedTextMode.Normalized) - _activeNormalizedTextCaptures--; - if (TResourceLimits.Enabled) - { - _queryCaptureBytes -= capture.BufferedByteCount; - } - try - { - var element = new CompletedElement( - capture, - _plan.AttributeNames, - _plan.AttributeNamesUtf8, - node.CapturedAttributeIndexes - ); - node.Completed.Invoke(ref _state, in element); - } - finally - { - _reusableCaptures!.Push(capture); - } - } - private void IncrementActive(ulong matches) { while (matches != 0) @@ -820,65 +327,6 @@ private void EnsureFrameCapacity() _frames = replacement; } - private long GetCompletedAttributeBytes(ulong matches) - { - var total = 0L; - var completed = matches & _plan.CompletedHandlerMask; - while (completed != 0) - { - var index = BitOperations.TrailingZeroCount(completed); - completed &= completed - 1; - foreach (var attributeIndex in _plan.Nodes[index].CapturedAttributeIndexes) - { - var length = _attributeLengths[attributeIndex]; - if (length > 0) - total = SaturatingAdd(total, length); - } - } - return total; - } - - private long GetCompletedTextUpperBound(int textLength) - { - if (textLength == 0) - return 0; - - var total = 0L; - var completed = _plan.CompletedHandlerMask; - while (completed != 0) - { - var index = BitOperations.TrailingZeroCount(completed); - completed &= completed - 1; - if (_plan.Nodes[index].CompletedTextMode == CompletedTextMode.None) - continue; - var captures = _completedCaptures[index]; - if (captures is null) - continue; - foreach (var capture in captures) - { - total = SaturatingAdd(total, textLength); - if (capture.HasPendingNormalizedSpace) - total = SaturatingAdd(total, 1); - } - } - return total; - } - - private void EnsureQueryCaptureCapacity(long additional) - { - var observed = - _queryCaptureBytes > long.MaxValue - additional ? long.MaxValue : _queryCaptureBytes + additional; - if (observed > _maximumQueryCaptureBytes) - throw new HtmlStreamingLimitExceededException( - HtmlStreamingLimit.QueryCaptureBytes, - _maximumQueryCaptureBytes, - observed - ); - } - - private static long SaturatingAdd(long value, long additional) => - value > long.MaxValue - additional ? long.MaxValue : value + additional; - private static bool ContainsToken(ReadOnlySpan tokens, ReadOnlySpan wanted) { var index = 0; @@ -930,20 +378,11 @@ private static bool IsVoidTag(ulong identity, int identityLength, int nameLength || (nameLength == 6 && identity == HtmlVoidElements.Source) ); - private bool HasTextRewriteHandler => _rewriteHandlers is TextRewriteHandler or RewriteHandlerPair; - - private RewriteHandler? ElementRewriteHandler => - _rewriteHandlers switch - { - RewriteHandler handler => handler, - RewriteHandlerPair pair => pair.Element, - _ => null, - }; + private bool HasTextRewriteHandler => _textRewriteHandler is not null; - private TextRewriteHandler TextRewriteHandler => - _rewriteHandlers is TextRewriteHandler handler ? handler : ((RewriteHandlerPair)_rewriteHandlers!).Text; + private RewriteHandler? ElementRewriteHandler => _elementRewriteHandler; - private sealed record RewriteHandlerPair(RewriteHandler Element, TextRewriteHandler Text); + private TextRewriteHandler TextRewriteHandler => _textRewriteHandler!; } internal sealed class QueryExecution : QueryExecution