You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@annotator.apache.org by ge...@apache.org on 2020/04/03 11:53:15 UTC

[incubator-annotator] branch fragment-tests created (now ae31a81)

This is an automated email from the ASF dual-hosted git repository.

gerben pushed a change to branch fragment-tests
in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git.


      at ae31a81  Add missing valid characters

This branch includes the following new commits:

     new 50ad35e  Parser code readability
     new 6bba0e4  Half-working attempt at parsing unencoded parentheses.
     new 1630831  Add debug tool for parser
     new 531d5e7  Test fragment-identifier against spec examples
     new 7ad5a15  Test if it throws on invalid input
     new 9f77a08  Test parsing values with parentheses
     new 01dab31  Percent-encode parentheses in stringify()
     new 6b14a01  Add tests for stringify
     new ae31a81  Add missing valid characters

The 9 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



[incubator-annotator] 07/09: Percent-encode parentheses in stringify()

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gerben pushed a commit to branch fragment-tests
in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git

commit 01dab3147e2e7884ea9eef3af207b7f32fbb1955
Author: Gerben <ge...@treora.com>
AuthorDate: Thu Apr 2 21:33:45 2020 +0200

    Percent-encode parentheses in stringify()
---
 packages/fragment-identifier/src/index.js | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/packages/fragment-identifier/src/index.js b/packages/fragment-identifier/src/index.js
index 0ba2e4d..39513ae 100644
--- a/packages/fragment-identifier/src/index.js
+++ b/packages/fragment-identifier/src/index.js
@@ -31,10 +31,10 @@ export function stringify(resource) {
       let value = resource[key];
       if (value instanceof Object) value = value.valueOf();
       if (value instanceof Object) {
-        value = stringify(value);
-        return `${encodeURIComponent(key)}=${value}`;
+        return `${encode(key)}=${stringify(value)}`;
+      } else {
+        return `${encode(key)}=${encode(value)}`;
       }
-      return [key, value].map(encodeURIComponent).join('=');
     })
     .join(',');
 
@@ -42,3 +42,9 @@ export function stringify(resource) {
   if (/State$/.test(resource.type)) return `state(${data})`;
   throw new TypeError('Resource must be a Selector or State');
 }
+
+function encode(string) {
+  return encodeURIComponent(string)
+    .replace(/\(/g, '%28')
+    .replace(/\)/g, '%29')
+}


[incubator-annotator] 08/09: Add tests for stringify

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gerben pushed a commit to branch fragment-tests
in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git

commit 6b14a01f082134cc436fef1360e9ea101e645491
Author: Gerben <ge...@treora.com>
AuthorDate: Fri Apr 3 12:57:12 2020 +0200

    Add tests for stringify
---
 packages/fragment-identifier/test/index.js | 17 +++++++++++++++++
 1 file changed, 17 insertions(+)

diff --git a/packages/fragment-identifier/test/index.js b/packages/fragment-identifier/test/index.js
index 947e03f..cfba7f9 100644
--- a/packages/fragment-identifier/test/index.js
+++ b/packages/fragment-identifier/test/index.js
@@ -29,6 +29,16 @@ const specExamples = Object.fromEntries(Object.entries(specExamplesRaw).map(
     [name, { fragId: uri.split('#')[1], selector, state }]
 ));
 
+const specialCasesToStringify = {
+  'Value with parentheses (to be percent-encoded)': {
+    fragId: 'selector(type=TextQuoteSelector,exact=example%20%28with%20parentheses%29)',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'example (with parentheses)',
+    },
+  },
+};
+
 describe('stringify', () => {
   // Test examples in the spec, ignoring their URI encoding
   for (const [name, example] of Object.entries(specExamples)) {
@@ -40,6 +50,13 @@ describe('stringify', () => {
       );
     });
   }
+
+  for (const [name, example] of Object.entries(specialCasesToStringify)) {
+    it(`should properly stringify: '${name}'`, () => {
+      const result = stringify(example.selector || example.state);
+      assert.equal(result, example.fragId);
+    });
+  }
 });
 
 const specialCasesToParse = {


[incubator-annotator] 04/09: Test fragment-identifier against spec examples

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gerben pushed a commit to branch fragment-tests
in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git

commit 531d5e75d0ec61a4bdf095c96b73633c7fda80cd
Author: Gerben <ge...@treora.com>
AuthorDate: Thu Apr 2 21:46:25 2020 +0200

    Test fragment-identifier against spec examples
    
    Derived from earlier work: https://github.com/Treora/selector-state-frags/blob/51fe53df390301f768b6e876a670d12d1249b2b1/test/test.js
---
 packages/fragment-identifier/test/index.js         |  55 +++++++
 .../fragment-identifier/test/spec-examples.json    | 180 +++++++++++++++++++++
 2 files changed, 235 insertions(+)

diff --git a/packages/fragment-identifier/test/index.js b/packages/fragment-identifier/test/index.js
new file mode 100644
index 0000000..761de0d
--- /dev/null
+++ b/packages/fragment-identifier/test/index.js
@@ -0,0 +1,55 @@
+/**
+ * @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 { parse, stringify } from '../src';
+
+// Test examples from the spec: https://www.w3.org/TR/2017/NOTE-selectors-states-20170223/#json-examples-converted-to-fragment-identifiers
+import specExamplesRaw from './spec-examples.json';
+
+// The JSON file has the full examples; pull out the parts we need.
+const specExamples = Object.fromEntries(Object.entries(specExamplesRaw).map(
+  ([name, { uri, obj: { selector, state } }]) =>
+    [name, { fragId: uri.split('#')[1], selector, state }]
+));
+
+describe('stringify', () => {
+  // Test examples in the spec, ignoring their URI encoding
+  for (const [name, example] of Object.entries(specExamples)) {
+    it(`should properly stringify (disregarding URI-encoding): '${name}'`, () => {
+      const result = stringify(example.selector || example.state);
+      assert.equal(
+        decodeURIComponent(result),
+        decodeURIComponent(example.fragId)
+      );
+    });
+  }
+});
+
+describe('parse', () => {
+  for (const [name, example] of Object.entries(specExamples)) {
+    it(`should properly parse: ${name}`, () => {
+      const expected = (example.selector !== undefined)
+        ? { selector: example.selector }
+        : { state: example.state };
+      const result = parse(example.fragId);
+      assert.deepEqual(result, expected);
+    });
+  }
+});
diff --git a/packages/fragment-identifier/test/spec-examples.json b/packages/fragment-identifier/test/spec-examples.json
new file mode 100644
index 0000000..832e02b
--- /dev/null
+++ b/packages/fragment-identifier/test/spec-examples.json
@@ -0,0 +1,180 @@
+{
+  "Example 2 <=> 16: Fragment selector": {
+    "uri": "http://example.org/video1#selector(type=FragmentSelector,conformsTo=http://www.w3.org/TR/media-frags/,value=t%3D30%2C60)",
+    "obj": {
+      "source": "http://example.org/video1",
+      "selector": {
+        "type": "FragmentSelector",
+        "conformsTo": "http://www.w3.org/TR/media-frags/",
+        "value": "t=30,60"
+      }
+    }
+  },
+
+  "Example 3 <=> 17: CSS selector": {
+    "uri": "http://example.org/page1.html#selector(type=CssSelector,value=%23elemid%20>%20.elemclass%20+%20p)",
+    "obj": {
+      "source": "http://example.org/page1.html",
+      "selector": {
+        "type": "CssSelector",
+        "value": "#elemid > .elemclass + p"
+      }
+    }
+  },
+
+  "Example 4 <=> 18: XPath selector": {
+    "uri": "http://example.org/page1.html#selector(type=XPathSelector,value=/html/body/p[2]/table/tr[2]/td[3]/span)",
+    "obj": {
+      "source": "http://example.org/page1.html",
+      "selector": {
+        "type": "XPathSelector",
+        "value": "/html/body/p[2]/table/tr[2]/td[3]/span"
+      }
+    }
+  },
+
+  "Example 5 <=> 19: TextQuote selector": {
+    "uri": "http://example.org/page1#selector(type=TextQuoteSelector,exact=annotation,prefix=this%20is%20an%20,suffix=%20that%20has%20some)",
+    "obj": {
+      "source": "http://example.org/page1",
+      "selector": {
+        "type": "TextQuoteSelector",
+        "exact": "annotation",
+        "prefix": "this is an ",
+        "suffix": " that has some"
+      }
+    }
+  },
+
+  "Example 6 <=> 20: TextPosition selector": {
+    "uri": "http://example.org/ebook1#selector(type=TextPositionSelector,start=412,end=795)",
+    "obj": {
+      "source": "http://example.org/ebook1",
+      "selector": {
+        "type": "TextPositionSelector",
+        "start": 412,
+        "end": 795
+      }
+    }
+  },
+
+  "Example 7 <=> 21: Data position selector": {
+    "uri": "http://example.org/diskimg1#selector(type=DataPositionSelector,start=4096,end=4104)",
+    "obj": {
+      "source": "http://example.org/diskimg1",
+      "selector": {
+        "type": "DataPositionSelector",
+        "start": 4096,
+        "end": 4104
+      }
+    }
+  },
+
+  "Example 8 <=> 22: SVG selector; external SVG": {
+    "uri": "http://example.org/map1#selector(type=SvgSelector,id=http://example.org/svg1)",
+    "obj": {
+      "source": "http://example.org/map1",
+      "selector": {
+        "type": "SvgSelector",
+        "id": "http://example.org/svg1"
+      }
+    }
+  },
+
+  "Example 9 <=> 23: SVG selector; embedded SVG": {
+    "uri": "http://example.org/map1#selector(type=SvgSelector,value=<svg:svg>%20...%20</svg:svg>)",
+    "obj": {
+      "source": "http://example.org/map1",
+      "selector": {
+        "type": "SvgSelector",
+        "value": "<svg:svg> ... </svg:svg>"
+      }
+    }
+  },
+
+  "Example 10 <=> 24: Range selector": {
+    "uri": "http://example.org/page1.html#selector(type=RangeSelector,startSelector=selector(type=XPathSelector,value=//table[1]/tr[1]/td[2]),endSelector=selector(type=XPathSelector,value=//table[1]/tr[1]/td[4]))",
+    "obj": {
+      "source": "http://example.org/page1.html",
+      "selector": {
+        "type": "RangeSelector",
+        "startSelector": {
+        "type": "XPathSelector",
+        "value": "//table[1]/tr[1]/td[2]"
+        },
+        "endSelector": {
+        "type": "XPathSelector",
+        "value": "//table[1]/tr[1]/td[4]"
+        }
+      }
+    }
+  },
+
+  "Example 11 <=> 25: Selector refinement": {
+    "uri": "http://example.org/page1#selector(type=FragmentSelector,value=para5,refinedBy=selector(type=TextQuoteSelector,exact=Selected%20Text,prefix=text%20before%20the%20,suffix=%20and%20text%20after%20it))",
+    "obj": {
+      "source": "http://example.org/page1",
+      "selector": {
+        "type": "FragmentSelector",
+        "value": "para5",
+        "refinedBy": {
+        "type": "TextQuoteSelector",
+        "exact": "Selected Text",
+        "prefix": "text before the ",
+        "suffix": " and text after it"
+        }
+      }
+    }
+  },
+
+  "Example 13 <=> 26: Time state": {
+    "uri": "http://example.org/page1#state(type=TimeState,cached=http://archive.example.org/copy1,sourceDate=2015-07-20T13:30:00Z)",
+    "obj": {
+      "source": "http://example.org/page1",
+      "state": {
+        "type": "TimeState",
+        "cached": "http://archive.example.org/copy1",
+        "sourceDate": "2015-07-20T13:30:00Z"
+      }
+    }
+  },
+
+  "Example 14 <=> 27: HTTP request state": {
+    "uri": "http://example.org/resource1#state(type=HttpRequestState,value=Accept:%20application/pdf)",
+    "obj": {
+      "source": "http://example.org/resource1",
+      "state": {
+        "type": "HttpRequestState",
+        "value": "Accept: application/pdf"
+      }
+    }
+  },
+
+  "Example 15 <=> 28: Refinement of states": {
+    "uri": "http://example.org/ebook1#state(type=TimeState,sourceDate=2016-02-01T12:05:23Z,refinedBy=state(type=HttpRequestState,value=Accept:%20application/epub+zip))",
+    "obj": {
+      "source": "http://example.org/ebook1",
+      "state": {
+        "type": "TimeState",
+        "sourceDate": "2016-02-01T12:05:23Z",
+        "refinedBy": {
+        "type": "HttpRequestState",
+        "value": "Accept: application/epub+zip"
+        }
+      }
+    }
+  },
+
+  "Example 29: Serializing IRI to URL (and vice versa)": {
+    "uri": "http://jp.example.org/page1#selector(type=TextQuoteSelector,exact=%E3%83%9A%E3%83%B3%E3%82%92,prefix=%E7%A7%81%E3%81%AF%E3%80%81,suffix=%E6%8C%81%E3%81%A3%E3%81%A6%E3%81%84%E3%81%BE%E3%81%99)",
+    "obj": {
+      "source": "http://jp.example.org/page1",
+      "selector": {
+        "type": "TextQuoteSelector",
+        "exact": "ペンを",
+        "prefix": "私は、",
+        "suffix": "持っています"
+      }
+    }
+  }
+}


[incubator-annotator] 05/09: Test if it throws on invalid input

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gerben pushed a commit to branch fragment-tests
in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git

commit 7ad5a153498382f06999471a76bd84b241bd43ed
Author: Gerben <ge...@treora.com>
AuthorDate: Fri Apr 3 12:55:28 2020 +0200

    Test if it throws on invalid input
---
 packages/fragment-identifier/test/index.js | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/packages/fragment-identifier/test/index.js b/packages/fragment-identifier/test/index.js
index 761de0d..b06981a 100644
--- a/packages/fragment-identifier/test/index.js
+++ b/packages/fragment-identifier/test/index.js
@@ -52,4 +52,9 @@ describe('parse', () => {
       assert.deepEqual(result, expected);
     });
   }
+
+  it('should throw when given an unknown type of fragment identifier', () => {
+    assert.throws(() => parse('section4'));
+    assert.throws(() => parse('t=3,8'));
+  });
 });


[incubator-annotator] 09/09: Add missing valid characters

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gerben pushed a commit to branch fragment-tests
in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git

commit ae31a814fd915f1321e5cf978ee3905c692af394
Author: Gerben <ge...@treora.com>
AuthorDate: Fri Apr 3 13:41:34 2020 +0200

    Add missing valid characters
    
    And remove unnecessary escaping.
    
    Basing on the grammar in RFC 3986. Characters that are invalid in
    fragment identifiers are nevertheless left in place for compatibility
    (i.e. < > [ ])
---
 packages/fragment-identifier/src/fragment.pegjs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/fragment-identifier/src/fragment.pegjs b/packages/fragment-identifier/src/fragment.pegjs
index c19c6bd..7e4d5b5 100644
--- a/packages/fragment-identifier/src/fragment.pegjs
+++ b/packages/fragment-identifier/src/fragment.pegjs
@@ -71,5 +71,5 @@ atom
 //   selector(type=TextQuoteSelector,exact=example%20(that%20fails))
 //   selector(type=TextQuoteSelector,exact=another))failure)
 validchar
-    = [a-zA-Z0-9\<\>\/\[\]\:%+@.\-!\$\&\;*_\(]
+    = [a-zA-Z0-9<>/[\]:%+@.\-!$&;*_~';(]
     / $( ")" &[^,)] )


[incubator-annotator] 06/09: Test parsing values with parentheses

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gerben pushed a commit to branch fragment-tests
in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git

commit 9f77a08a3d42b1507a68096f661d60842ddac5d8
Author: Gerben <ge...@treora.com>
AuthorDate: Fri Apr 3 12:56:35 2020 +0200

    Test parsing values with parentheses
---
 packages/fragment-identifier/test/index.js | 51 +++++++++++++++++++++++++++++-
 1 file changed, 50 insertions(+), 1 deletion(-)

diff --git a/packages/fragment-identifier/test/index.js b/packages/fragment-identifier/test/index.js
index b06981a..947e03f 100644
--- a/packages/fragment-identifier/test/index.js
+++ b/packages/fragment-identifier/test/index.js
@@ -42,8 +42,57 @@ describe('stringify', () => {
   }
 });
 
+const specialCasesToParse = {
+  'One closing parenthesis inside a value': {
+    fragId: 'selector(type=TextQuoteSelector,exact=(not)%20a%20problem)',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: '(not) a problem',
+    },
+  },
+
+  'Two closing parenthesis inside a value': {
+    fragId: 'selector(type=TextQuoteSelector,exact=Hey))%20this%20breaks)',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'Hey)) this breaks',
+    },
+  },
+
+  'Two closing parentheses: one of value, one of selector': {
+    fragId: 'selector(type=TextQuoteSelector,exact=example%20(that%20fails))',
+    selector: {
+      type: 'TextQuoteSelector',
+      exact: 'example (that fails)',
+    },
+  },
+
+  'Three closing parentheses: one of the value, two of nested selectors': {
+    // Example from <https://github.com/w3c/web-annotation/issues/443>
+    fragId: `
+      selector(
+        type=RangeSelector,
+        startSelector=selector(type=TextQuoteSelector,exact=(but),
+        endSelector=selector(type=TextQuoteSelector,exact=crazy))
+      )
+      `.replace(/\s/g, ''),
+    selector: {
+      type: 'RangeSelector',
+      startSelector: {
+        type: 'TextQuoteSelector',
+        exact: '(but',
+      },
+      endSelector: {
+        type: 'TextQuoteSelector',
+        exact: 'crazy)',
+      },
+    },
+  },
+};
+
 describe('parse', () => {
-  for (const [name, example] of Object.entries(specExamples)) {
+  const allCasesToParse = { ...specExamples, ...specialCasesToParse };
+  for (const [name, example] of Object.entries(allCasesToParse)) {
     it(`should properly parse: ${name}`, () => {
       const expected = (example.selector !== undefined)
         ? { selector: example.selector }


[incubator-annotator] 01/09: Parser code readability

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gerben pushed a commit to branch fragment-tests
in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git

commit 50ad35e935b5109e6a924c339ec5e987024d237f
Author: Gerben <ge...@treora.com>
AuthorDate: Thu Apr 2 21:28:30 2020 +0200

    Parser code readability
---
 packages/fragment-identifier/src/fragment.pegjs | 22 +---------------------
 1 file changed, 1 insertion(+), 21 deletions(-)

diff --git a/packages/fragment-identifier/src/fragment.pegjs b/packages/fragment-identifier/src/fragment.pegjs
index 871e94a..5fc3d5f 100644
--- a/packages/fragment-identifier/src/fragment.pegjs
+++ b/packages/fragment-identifier/src/fragment.pegjs
@@ -1,19 +1,3 @@
-{
-    function collect() {
-      var ret = {};
-      var len = arguments.length;
-      for (var i=0; i<len; i++) {
-        for (var p in arguments[i]) {
-          if (arguments[i].hasOwnProperty(p)) {
-            ret[p] = arguments[i][p];
-          }
-        }
-      }
-      return ret;
-    }
-}
-
-
 start =
     top
 
@@ -26,11 +10,7 @@ top
 params
     = k1: key_value_pair k2:("," key_value_pair)*
         {
-            var f = k1;
-            for( var i = 0; i < k2.length; i++ ) {
-                f = collect(f, k2[i][1])
-            }
-            return f;
+            return k2.reduce((acc, cur) => Object.assign(acc, cur[1]), k1);
         }
 
 key_value_pair


[incubator-annotator] 02/09: Half-working attempt at parsing unencoded parentheses.

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gerben pushed a commit to branch fragment-tests
in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git

commit 6bba0e48fc0bf8c42133085f9a8580de78ebb729
Author: Gerben <ge...@treora.com>
AuthorDate: Thu Apr 2 21:34:07 2020 +0200

    Half-working attempt at parsing unencoded parentheses.
---
 packages/fragment-identifier/src/fragment.pegjs | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/packages/fragment-identifier/src/fragment.pegjs b/packages/fragment-identifier/src/fragment.pegjs
index 5fc3d5f..8e6d22c 100644
--- a/packages/fragment-identifier/src/fragment.pegjs
+++ b/packages/fragment-identifier/src/fragment.pegjs
@@ -47,5 +47,15 @@ value
 atom
     = chars:validchar+ { return chars.join(""); }
 
+// FIXME
+// While an opening parenthesis is always valid, a closing parenthesis
+// *might* be part of the value, but could also be the delimiter that
+// closes a selector(…) or state(…). The attempt below uses a look-ahead
+// to not match ')' before a comma or before another ')', or at the end
+// of the input. However, it will fail if a last param’s value ends with
+// ')', or if a key or value contains '))'. For example:
+//   selector(type=TextQuoteSelector,exact=example%20(that%20fails))
+//   selector(type=TextQuoteSelector,exact=another))failure)
 validchar
-    = [a-zA-Z0-9\<\>\/\[\]\:%+@.\-!\$\&\;*_]
+    = [a-zA-Z0-9\<\>\/\[\]\:%+@.\-!\$\&\;*_\(]
+    / $( ")" &[^,)] )


[incubator-annotator] 03/09: Add debug tool for parser

Posted by ge...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

gerben pushed a commit to branch fragment-tests
in repository https://gitbox.apache.org/repos/asf/incubator-annotator.git

commit 16308314883d4a133cc396596b180877e26550af
Author: Gerben <ge...@treora.com>
AuthorDate: Fri Apr 3 12:42:29 2020 +0200

    Add debug tool for parser
---
 packages/fragment-identifier/src/fragment.pegjs | 14 ++++++++++++++
 1 file changed, 14 insertions(+)

diff --git a/packages/fragment-identifier/src/fragment.pegjs b/packages/fragment-identifier/src/fragment.pegjs
index 8e6d22c..c19c6bd 100644
--- a/packages/fragment-identifier/src/fragment.pegjs
+++ b/packages/fragment-identifier/src/fragment.pegjs
@@ -1,3 +1,17 @@
+{
+    function debug(input, range) {
+      // Prints the location of the parser.
+      // Use e.g. in an action: { debug(input, range); return text(); }
+      const [start, end] = range();
+      const underline = (end > start + 1)
+        ? ' '.repeat(start) + '\\' + '_'.repeat(end - start - 2) + '/'
+        : ' '.repeat(start) + '^';
+      console.log(input);
+      console.log(underline);
+    }
+}
+
+
 start =
     top