Merge branch 'allow-node-as-scope' (PR #110)
diff --git a/packages/dom/src/css.ts b/packages/dom/src/css.ts
index 1026004..2594374 100644
--- a/packages/dom/src/css.ts
+++ b/packages/dom/src/css.ts
@@ -21,6 +21,7 @@
 import optimalSelect from 'optimal-select';
 import type { CssSelector, Matcher } from '@apache-annotator/selector';
 import { ownerDocument } from './owner-document';
+import { toRange } from './to-range';
 
 /**
  * Find the elements corresponding to the given {@link
@@ -45,21 +46,17 @@
  * > “If […] the user agent discovers multiple matching text sequences, then the
  * > selection SHOULD be treated as matching all of the matches.”
  *
- * Each matching element is returned as a {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range} surrounding that element. This in order to make its output reusable
- * as the scope for any subsequents selectors that {@link
- * Selector.refinedBy | refine} this CssSelector.
- *
- * @param selector - The {@link CssSelector} to be anchored
- * @returns A {@link Matcher} function that applies `selector` to a given {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range}
+ * @param selector - The {@link CssSelector} to be anchored.
+ * @returns A {@link Matcher} function that applies `selector` to a given
+ * `scope`.
  *
  * @public
  */
 export function createCssSelectorMatcher(
   selector: CssSelector,
-): Matcher<Range, Range> {
+): Matcher<Node | Range, Element> {
   return async function* matchAll(scope) {
+    scope = toRange(scope);
     const document = ownerDocument(scope);
     for (const element of document.querySelectorAll(selector.value)) {
       const range = document.createRange();
@@ -69,7 +66,7 @@
         scope.isPointInRange(range.startContainer, range.startOffset) &&
         scope.isPointInRange(range.endContainer, range.endOffset)
       ) {
-        yield range;
+        yield element;
       }
     }
   };
diff --git a/packages/dom/src/owner-document.ts b/packages/dom/src/owner-document.ts
index 1c0621e..fe7818a 100644
--- a/packages/dom/src/owner-document.ts
+++ b/packages/dom/src/owner-document.ts
@@ -18,8 +18,19 @@
  * under the License.
  */
 
-export function ownerDocument(range: Range): Document {
-  const { startContainer } = range;
+/**
+ * Get the ownerDocument for either a range or a node.
+ *
+ * @param nodeOrRange the node or range for which to get the owner document.
+ */
+export function ownerDocument(nodeOrRange: Node | Range): Document {
+  const node = isRange(nodeOrRange)
+    ? nodeOrRange.startContainer
+    : nodeOrRange;
   // node.ownerDocument is null iff node is itself a Document.
-  return startContainer.ownerDocument ?? (startContainer as Document);
+  return node.ownerDocument ?? (node as Document);
+}
+
+function isRange(nodeOrRange: Node | Range): nodeOrRange is Range {
+  return ('startContainer' in nodeOrRange);
 }
diff --git a/packages/dom/src/range/match.ts b/packages/dom/src/range/match.ts
index 061eb75..6ffece3 100644
--- a/packages/dom/src/range/match.ts
+++ b/packages/dom/src/range/match.ts
@@ -24,11 +24,11 @@
   Selector,
 } from '@apache-annotator/selector';
 import { ownerDocument } from '../owner-document';
+import { toRange } from '../to-range';
 import { cartesian } from './cartesian';
 
 /**
- * Find the range(s) corresponding to the given {@link
- * RangeSelector}.
+ * Find the range(s) corresponding to the given {@link RangeSelector}.
  *
  * As a RangeSelector itself nests two further selectors, one needs to pass a
  * `createMatcher` function that will be used to process those nested selectors.
@@ -36,13 +36,12 @@
  * The function is curried, taking first the `createMatcher` function, then the
  * selector, and then the scope.
  *
- * As there may be multiple matches for a given selector, the matcher will
- * return an (async) generator that produces each match in the order they are
- * found in the text. If both its nested selectors produce multiple matches, the
- * RangeSelector matches each possible pair among those in which the order of
- * start and end are respected. *(Note this behaviour is a rather free
- * interpretation — the Web Annotation Data Model spec is silent about multiple
- * matches for RangeSelectors)*
+ * As there may be multiple matches for the start & end selectors, the resulting
+ * matcher will return an (async) iterable, that produces a match for each
+ * possible pair of matches of the nested selectors (except those where its end
+ * would precede its start). *(Note that this behaviour is a rather free
+ * interpretation of the Web Annotation Data Model spec, which is silent about
+ * the possibility of multiple matches for RangeSelectors)*
  *
  * @example
  * By using a matcher for {@link TextQuoteSelector}s, one
@@ -88,15 +87,15 @@
  * ```
  *
  * @param createMatcher - The function used to process nested selectors.
- * @returns A function that, given a RangeSelector, creates a {@link
- * Matcher} function that applies it to a given {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range}
+ * @returns A function that, given a RangeSelector `selector`, creates a {@link
+ * Matcher} function that can apply it to a given `scope`.
  *
  * @public
  */
 export function makeCreateRangeSelectorMatcher(
-  createMatcher: <T extends Selector>(selector: T) => Matcher<Range, Range>,
-): (selector: RangeSelector) => Matcher<Range, Range> {
+  createMatcher: <T extends Selector, TMatch extends Node | Range>(selector: T)
+    => Matcher<Node | Range, TMatch>,
+): (selector: RangeSelector) => Matcher<Node | Range, Range> {
   return function createRangeSelectorMatcher(selector) {
     const startMatcher = createMatcher(selector.startSelector);
     const endMatcher = createMatcher(selector.endSelector);
@@ -107,10 +106,14 @@
 
       const pairs = cartesian(startMatches, endMatches);
 
-      for await (const [start, end] of pairs) {
-        const result = ownerDocument(scope).createRange();
+      for await (let [start, end] of pairs) {
+        start = toRange(start);
+        end = toRange(end);
 
+        const result = ownerDocument(scope).createRange();
         result.setStart(start.startContainer, start.startOffset);
+        // Note that a RangeSelector’s match *excludes* the endSelector’s match,
+        // hence we take the end’s startContainer & startOffset.
         result.setEnd(end.startContainer, end.startOffset);
 
         if (!result.collapsed) yield result;
diff --git a/packages/dom/src/text-node-chunker.ts b/packages/dom/src/text-node-chunker.ts
index dc916a6..e02ff71 100644
--- a/packages/dom/src/text-node-chunker.ts
+++ b/packages/dom/src/text-node-chunker.ts
@@ -21,6 +21,7 @@
 import type { Chunk, Chunker, ChunkRange } from '@apache-annotator/selector';
 import { normalizeRange } from './normalize-range';
 import { ownerDocument } from './owner-document';
+import { toRange } from './to-range';
 
 export interface PartialTextNode extends Chunk<string> {
   readonly node: Text;
@@ -44,6 +45,7 @@
 }
 
 export class TextNodeChunker implements Chunker<PartialTextNode> {
+  private scope: Range;
   private iter: NodeIterator;
 
   get currentChunk(): PartialTextNode {
@@ -116,13 +118,14 @@
   /**
    * @param scope A Range that overlaps with at least one text node.
    */
-  constructor(private scope: Range) {
+  constructor(scope: Node | Range) {
+    this.scope = toRange(scope);
     this.iter = ownerDocument(scope).createNodeIterator(
-      scope.commonAncestorContainer,
+      this.scope.commonAncestorContainer,
       NodeFilter.SHOW_TEXT,
       {
-        acceptNode(node: Text) {
-          return scope.intersectsNode(node)
+        acceptNode: (node: Text) => {
+          return this.scope.intersectsNode(node)
             ? NodeFilter.FILTER_ACCEPT
             : NodeFilter.FILTER_REJECT;
         },
diff --git a/packages/dom/src/text-position/describe.ts b/packages/dom/src/text-position/describe.ts
index bd2f42b..6e9566b 100644
--- a/packages/dom/src/text-position/describe.ts
+++ b/packages/dom/src/text-position/describe.ts
@@ -21,6 +21,7 @@
 import type { TextPositionSelector } from '@apache-annotator/selector';
 import { describeTextPosition as abstractDescribeTextPosition } from '@apache-annotator/selector';
 import { ownerDocument } from '../owner-document';
+import { toRange } from '../to-range';
 import { TextNodeChunker } from '../text-node-chunker';
 
 /**
@@ -45,32 +46,24 @@
  * // }
  * ```
  *
- * @param range - The range of characters that the selector should describe
- * @param maybeScope - A {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range} that serves as the ‘document’ for purposes of finding occurrences
- * and determining prefix and suffix. Defaults to span the full Document
- * containing the range.
+ * @param range - The {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
+ * | Range} whose text content will be described.
+ * @param scope - A Node or Range that serves as the ‘document’ for purposes of
+ * finding occurrences and determining prefix and suffix. Defaults to span the
+ * full Document that contains the range.
  * @returns The selector describing `range` within `scope`.
  *
  * @public
  */
 export async function describeTextPosition(
   range: Range,
-  maybeScope?: Range,
+  scope?: Node | Range,
 ): Promise<TextPositionSelector> {
-  // Default to search in the whole document.
-  let scope: Range;
-  if (maybeScope !== undefined) {
-    scope = maybeScope;
-  } else {
-    const document = ownerDocument(range);
-    scope = document.createRange();
-    scope.selectNodeContents(document);
-  }
+  scope = toRange(scope ?? ownerDocument(range))
 
   const textChunks = new TextNodeChunker(scope);
   if (textChunks.currentChunk === null)
-    throw new RangeError('Range does not contain any Text nodes.');
+    throw new RangeError('Scope does not contain any Text nodes.');
 
   return await abstractDescribeTextPosition(
     textChunks.rangeToChunkRange(range),
diff --git a/packages/dom/src/text-position/match.ts b/packages/dom/src/text-position/match.ts
index 089def8..ac26caf 100644
--- a/packages/dom/src/text-position/match.ts
+++ b/packages/dom/src/text-position/match.ts
@@ -39,29 +39,22 @@
  * @example
  * ```
  * const selector = { type: 'TextPositionSelector', start: 702, end: 736 };
- *
- * // Search in the whole document.
- * const scope = document.createRange();
- * scope.selectNodeContents(document);
- *
+ * const scope = document.body;
  * const matches = textQuoteSelectorMatcher(selector)(scope);
  * const match = (await matches.next()).value;
- *
  * // ⇒ Range { startContainer: #text, startOffset: 64, endContainer: #text,
  * //   endOffset: 98, … }
  * ```
  *
- * @param selector - The {@link TextPositionSelector}
- * to be anchored
- * @returns A {@link Matcher} function that applies
- * `selector` to a given {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range}
+ * @param selector - The {@link TextPositionSelector} to be anchored.
+ * @returns A {@link Matcher} function that applies `selector` within a given
+ * `scope`.
  *
  * @public
  */
 export function createTextPositionSelectorMatcher(
   selector: TextPositionSelector,
-): Matcher<Range, Range> {
+): Matcher<Node | Range, Range> {
   const abstractMatcher = abstractTextPositionSelectorMatcher(selector);
 
   return async function* matchAll(scope) {
diff --git a/packages/dom/src/text-quote/describe.ts b/packages/dom/src/text-quote/describe.ts
index 8eca8bf..f5be4b9 100644
--- a/packages/dom/src/text-quote/describe.ts
+++ b/packages/dom/src/text-quote/describe.ts
@@ -24,6 +24,7 @@
 } from '@apache-annotator/selector';
 import { describeTextQuote as abstractDescribeTextQuote } from '@apache-annotator/selector';
 import { ownerDocument } from '../owner-document';
+import { toRange } from '../to-range';
 import { TextNodeChunker } from '../text-node-chunker';
 
 /**
@@ -52,10 +53,9 @@
  *
  * @param range - The {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
  * | Range} whose text content will be described
- * @param maybeScope - A {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range} that serves as the ‘document’ for purposes of finding occurrences
- * and determining prefix and suffix. Defaults to span the full Document
- * containing the range.
+ * @param scope - A Node or Range that serves as the ‘document’ for purposes of
+ * finding occurrences and determining prefix and suffix. Defaults to span the
+ * full Document that contains the range.
  * @param options - Options to fine-tune the function’s behaviour.
  * @returns The selector unambiguously describing `range` within `scope`.
  *
@@ -63,24 +63,16 @@
  */
 export async function describeTextQuote(
   range: Range,
-  maybeScope?: Range,
+  scope?: Node | Range,
   options: DescribeTextQuoteOptions = {},
 ): Promise<TextQuoteSelector> {
-  // Default to search in the whole document.
-  let scope: Range;
-  if (maybeScope !== undefined) {
-    scope = maybeScope;
-  } else {
-    const document = ownerDocument(range);
-    scope = document.createRange();
-    scope.selectNodeContents(document);
-  }
+  const scopeAsRange = toRange(scope ?? ownerDocument(range));
 
-  const chunker = new TextNodeChunker(scope);
+  const chunker = new TextNodeChunker(scopeAsRange);
 
   return await abstractDescribeTextQuote(
     chunker.rangeToChunkRange(range),
-    () => new TextNodeChunker(scope),
+    () => new TextNodeChunker(scopeAsRange),
     options,
   );
 }
diff --git a/packages/dom/src/text-quote/match.ts b/packages/dom/src/text-quote/match.ts
index a923586..2b29df6 100644
--- a/packages/dom/src/text-quote/match.ts
+++ b/packages/dom/src/text-quote/match.ts
@@ -44,10 +44,7 @@
  * ```
  * // Find the word ‘banana’.
  * const selector = { type: 'TextQuoteSelector', exact: 'banana' };
- *
- * // Search in the document body.
- * const scope = document.createRange();
- * scope.selectNodeContents(document.body);
+ * const scope = document.body;
  *
  * // Read all matches.
  * const matches = textQuoteSelectorMatcher(selector)(scope);
@@ -58,17 +55,15 @@
  * //   endOffset: 637, … }
  * ```
  *
- * @param selector - The {@link TextQuoteSelector}
- * to be anchored
- * @returns a {@link Matcher} function that applies
- * `selector` to a given {@link https://developer.mozilla.org/en-US/docs/Web/API/Range
- * | Range}
+ * @param selector - The {@link TextQuoteSelector} to be anchored.
+ * @returns A {@link Matcher} function that applies `selector` within a given
+ * `scope`.
  *
  * @public
  */
 export function createTextQuoteSelectorMatcher(
   selector: TextQuoteSelector,
-): Matcher<Range, Range> {
+): Matcher<Node | Range, Range> {
   const abstractMatcher = abstractTextQuoteSelectorMatcher(selector);
 
   return async function* matchAll(scope) {
diff --git a/packages/dom/src/to-range.ts b/packages/dom/src/to-range.ts
new file mode 100644
index 0000000..fd62543
--- /dev/null
+++ b/packages/dom/src/to-range.ts
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ownerDocument } from "./owner-document";
+
+/**
+ * Returns a range that exactly selects the contents of the given node.
+ *
+ * This function is idempotent: If the given argument is already a range, it
+ * simply returns that range.
+ *
+ * @param nodeOrRange The node/range to convert to a range if it is not already
+ * a range.
+ */
+export function toRange(nodeOrRange: Node | Range): Range {
+  if (isRange(nodeOrRange)) {
+    return nodeOrRange;
+  } else {
+    const node = nodeOrRange;
+    const range = ownerDocument(node).createRange();
+    range.selectNodeContents(node);
+    return range;
+  }
+}
+
+function isRange(nodeOrRange: Node | Range): nodeOrRange is Range {
+  return ('startContainer' in nodeOrRange);
+}
diff --git a/packages/dom/test/css/match.test.ts b/packages/dom/test/css/match.test.ts
index 9d4c18f..2c5d682 100644
--- a/packages/dom/test/css/match.test.ts
+++ b/packages/dom/test/css/match.test.ts
@@ -54,9 +54,6 @@
   assert.equal(matches.length, expected.length, 'Unexpected number of matches');
   matches.forEach((match, i) => {
     const expectedElement = evaluateXPath(doc, expected[i]);
-    // The match should be a Range that exactly contains the expected element.
-    assert.equal(match.startContainer.childNodes[match.startOffset], expectedElement);
-    assert.equal(match.endContainer, match.startContainer);
-    assert.equal(match.endOffset, match.startOffset + 1);
+    assert.equal(match, expectedElement);
   });
 }