Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 92 additions & 17 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,81 @@ import { type AnyNode, hasChildren, type ParentNode } from "domhandler";
* @returns Remaining nodes that aren't contained by other nodes.
*/
export function removeSubsets(nodes: AnyNode[]): AnyNode[] {
let index = nodes.length;
const { length } = nodes;

/*
* Check if each node (or one of its ancestors) is already contained in the
* array.
* A lone node is the most common input by far, and the only member it can
* be contained by is itself, so walk its ancestors directly rather than
* building a set for one entry.
*/
while (--index >= 0) {
if (length < 2) {
if (length === 1) {
const node = nodes[0];
for (
let ancestor = node.parent;
ancestor;
ancestor = ancestor.parent
) {
if (ancestor === node) {
nodes.length = 0;
break;
}
}
}
return nodes;
}

/*
* Membership is only ever tested against the nodes that were passed in, so
* collect them up front. That replaces the `includes` scan run for every
* ancestor of every node, which made this O(n^2 * depth), with an O(1)
* lookup per ancestor. Each slot is read once and kept, since a getter on
* the array would otherwise be invoked twice.
*/
const members = new Set<AnyNode>();
const slots: AnyNode[] = [];
for (let index = 0; index < length; index++) {
const node = nodes[index];
slots.push(node);
members.add(node);
}

/*
* Remove the node if it is not unique.
* We are going through the array from the end, so we only
* have to check nodes that preceed the node under consideration in the array.
*/
if (index > 0 && nodes.lastIndexOf(node, index - 1) >= 0) {
nodes.splice(index, 1);
continue;
/*
* A second set is only needed to drop repeats. If every node was distinct
* there are none, so the common case allocates just the one set.
*/
const seen = members.size === length ? null : new Set<AnyNode>();
let kept = 0;

for (let index = 0; index < length; index++) {
const node = slots[index];

/* Keep the first occurrence of each node, as `lastIndexOf` did. */
if (seen !== null) {
if (seen.has(node)) continue;
seen.add(node);
}

let contained = false;
for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
if (nodes.includes(ancestor)) {
nodes.splice(index, 1);
if (members.has(ancestor)) {
contained = true;
break;
}
}

if (contained) continue;

/*
* Compact the survivors in place, as the splices did. Only write when
* the value actually moves, so that an array needing no changes is
* never written to -- a frozen array survived the original untouched.
*/
if (kept !== index) nodes[kept] = node;
kept++;
}

if (kept !== length) nodes.length = kept;
return nodes;
}
/**
Expand Down Expand Up @@ -135,9 +183,36 @@ export function compareDocumentPosition(
* @returns Collection of unique nodes, sorted in document order.
*/
export function uniqueSort<T extends AnyNode>(nodes: T[]): T[] {
nodes = nodes.filter(
(node, index, array) => !array.includes(node, index + 1),
);
/* Nothing to dedupe or sort, and no reason to allocate a Set. */
if (nodes.length < 2) {
return nodes;
}

/*
* Keep the LAST occurrence of each node, as `array.includes(node, index + 1)`
* did. That matters for nodes in different documents: they compare equal, and
* the sort below is stable, so which duplicate survives decides the output
* order. Walking backwards and reversing preserves that, while making the
* dedupe O(n) instead of O(n^2).
*/
const seen = new Set<T>();
const unique: T[] = [];
for (let index = nodes.length - 1; index >= 0; index--) {
/*
* `filter` skipped holes in a sparse array, so an absent slot must not
* become an `undefined` entry in the result.
*/
if (!(index in nodes)) {
continue;
}
const node = nodes[index];
if (!seen.has(node)) {
seen.add(node);
unique.push(node);
}
}
unique.reverse();
nodes = unique;

nodes.sort((a, b) => {
const relative = compareDocumentPosition(a, b);
Expand Down