From 2c55e38822fcd2fc1a2e9cacaa85b6243de315b9 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Wed, 5 Jun 2024 19:16:56 -0400
Subject: [PATCH 01/14] WIP replacement Notcl parser

---
 src/parser2.ts | 155 +++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 155 insertions(+)
 create mode 100644 src/parser2.ts

diff --git a/src/parser2.ts b/src/parser2.ts
new file mode 100644
index 0000000..75f9842
--- /dev/null
+++ b/src/parser2.ts
@@ -0,0 +1,155 @@
+import {
+  Command,
+  ErrorResult,
+  InterpolatedPiece,
+  Script,
+  SimplifyWord,
+  Word,
+} from "./words";
+
+/**
+ * Parse out a Notcl script into an easier-to-interpret representation.
+ * No script is actually executed yet.
+ *
+ * @param code code to parse
+ * @param offset source position of code, if embedded in a larger source document
+ * @returns parsed list of commands, or error message on failure
+ */
+export function parse(
+  code: string,
+  offset = 0
+): [true, Script] | [false, string] {
+  try {
+    const parser = new Parser(code);
+    const script = parser.parseScript();
+
+    // TODO: report error with error position
+
+    if (parser.lastIndex != code.length) {
+      return [false, "Couldn't parse full script"];
+    }
+
+    return [true, script];
+  } catch (ex) {
+    return [false, String(ex)];
+  }
+}
+
+// ---------------------------
+
+// Parser for evaluating Notcl scripts
+
+type TokenType =
+  | "newline"
+  | "whitespace"
+  | "semicolon"
+  | "{"
+  | "}"
+  | "["
+  | "]"
+  | "quote"
+  | "backslash"
+  | "comment"
+  | "text"
+  | "EOF"
+  | "ERROR";
+
+type Token = [TokenType, string, number];
+
+const Tokens: [TokenType, RegExp][] = [
+  ["newline", /(\n)/y],
+  ["whitespace", /([^\S\n]+)/y],
+  ["text", /([^\s\\;\[\]]+)/y],
+];
+
+class WipScript {
+  script: Command[] = [];
+  wipCommand: Word[] = [];
+  wipWord: InterpolatedPiece[] = [];
+  // TODO: thing to fail {}a & ""a
+
+  addWordPiece(piece: InterpolatedPiece) {
+    this.wipWord.push(piece);
+  }
+  finishWord() {
+    if (this.wipWord.length > 0) {
+      this.wipCommand.push(SimplifyWord(this.wipWord));
+      this.wipWord = [];
+    }
+  }
+  finishCommand() {
+    this.finishWord();
+    if (this.wipCommand.length > 0) {
+      this.script.push(this.wipCommand);
+      this.wipCommand = [];
+    }
+  }
+  finishScript(): Script {
+    this.finishCommand();
+    return this.script;
+  }
+}
+
+class Parser {
+  lastIndex: number = 0;
+  next: Token;
+
+  constructor(public text: string) {
+    this.next = this.advance();
+  }
+
+  advance(): Token {
+    const startPos = this.lastIndex;
+    if (startPos == this.text.length) {
+      return (this.next = ["EOF", "<EOF>", startPos]);
+    }
+
+    for (const [type, regex] of Tokens) {
+      regex.lastIndex = startPos;
+      const matches = regex.exec(this.text);
+      if (matches) {
+        this.lastIndex = regex.lastIndex;
+        return (this.next = [type, matches[1], startPos]);
+      }
+    }
+
+    return (this.next = ["ERROR", "Token not matched", startPos]);
+  }
+
+  parseScript(): Script {
+    const wip = new WipScript();
+
+    while (true) {
+      const [type, chars, pos] = this.next;
+      switch (type) {
+        case "text":
+          wip.addWordPiece({ bare: chars, pos });
+          break;
+
+        case "whitespace":
+          wip.finishWord();
+          break;
+
+        case "newline":
+        case "semicolon":
+          wip.finishCommand();
+          break;
+
+        case "EOF":
+        case "]":
+          return wip.finishScript();
+
+        case "{":
+        case "}":
+        case "[":
+        case "quote":
+        case "backslash":
+        case "comment":
+        case "ERROR":
+          throw new Error(`Unhandled case: ${type} (${chars})`);
+      }
+
+      this.advance();
+    }
+  }
+}

From 618de2ac9957c6c12b7df271566e702db2a101fa Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Wed, 5 Jun 2024 19:32:58 -0400
Subject: [PATCH 02/14] Add source position to words in new parser

---
 src/parser2.ts | 13 ++++++++++---
 1 file changed, 10 insertions(+), 3 deletions(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index 75f9842..163ed25 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -66,15 +66,20 @@ class WipScript {
   script: Command[] = [];
   wipCommand: Word[] = [];
   wipWord: InterpolatedPiece[] = [];
+  wordPos: number | undefined = undefined;
   // TODO: thing to fail {}a & ""a
 
-  addWordPiece(piece: InterpolatedPiece) {
+  addWordPiece(piece: InterpolatedPiece, pos?: number) {
+    if (this.wipWord.length == 0) {
+      this.wordPos = pos;
+    }
     this.wipWord.push(piece);
   }
   finishWord() {
     if (this.wipWord.length > 0) {
-      this.wipCommand.push(SimplifyWord(this.wipWord));
+      this.wipCommand.push(SimplifyWord(this.wipWord, this.wordPos));
       this.wipWord = [];
+      this.wordPos = undefined;
     }
   }
   finishCommand() {
@@ -123,7 +128,7 @@ class Parser {
       const [type, chars, pos] = this.next;
       switch (type) {
         case "text":
-          wip.addWordPiece({ bare: chars, pos });
+          wip.addWordPiece({ bare: chars }, pos);
           break;
 
         case "whitespace":
@@ -147,6 +152,8 @@ class Parser {
         case "comment":
         case "ERROR":
           throw new Error(`Unhandled case: ${type} (${chars})`);
+        default:
+          throw new Error(`Unhandled case: ${type satisfies never} (${chars})`);
       }
 
       this.advance();

From 9b81056d1d003be5c292e45faa7c432fc559bcee Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Wed, 5 Jun 2024 19:43:19 -0400
Subject: [PATCH 03/14] Add more recognized tokens

---
 src/parser2.ts | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/src/parser2.ts b/src/parser2.ts
index 163ed25..83ca5fe 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -59,6 +59,14 @@ type Token = [TokenType, string, number];
 const Tokens: [TokenType, RegExp][] = [
   ["newline", /(\n)/y],
   ["whitespace", /([^\S\n]+)/y],
+  ["semicolon", /(;)/y],
+  ["{", /(\{)/y],
+  ["}", /(\})/y],
+  ["[", /(\[)/y],
+  ["]", /(\])/y],
+  ["quote", /(")/y],
+  ["backslash", /(\\)/y],
+  ["comment", /(\#)/y],
   ["text", /([^\s\\;\[\]]+)/y],
 ];
 

From 63d6fa836a7b9af60f6f4d60c30cc1896caa9f7f Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Wed, 5 Jun 2024 20:02:20 -0400
Subject: [PATCH 04/14] Add expect() & startOfWord() helpers to new parser

---
 src/parser2.ts | 22 +++++++++++++++-------
 1 file changed, 15 insertions(+), 7 deletions(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index 83ca5fe..513f563 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -22,15 +22,11 @@ export function parse(
   try {
     const parser = new Parser(code);
     const script = parser.parseScript();
-
-    // TODO: report error with error position
-
-    if (parser.lastIndex != code.length) {
-      return [false, "Couldn't parse full script"];
-    }
+    parser.expect("EOF");
 
     return [true, script];
   } catch (ex) {
+    // TODO: report error with error position
     return [false, String(ex)];
   }
 }
@@ -77,8 +73,12 @@ class WipScript {
   wordPos: number | undefined = undefined;
   // TODO: thing to fail {}a & ""a
 
+  startOfWord(): boolean {
+    return this.wipWord.length == 0;
+  }
+
   addWordPiece(piece: InterpolatedPiece, pos?: number) {
-    if (this.wipWord.length == 0) {
+    if (this.startOfWord()) {
       this.wordPos = pos;
     }
     this.wipWord.push(piece);
@@ -129,6 +129,14 @@ class Parser {
     return (this.next = ["ERROR", "Token not matched", startPos]);
   }
 
+  expect(type: TokenType) {
+    if (this.next[0] != type) {
+      throw new Error(
+        `Expected ${type}, found ${this.next[0]} (${this.next[1]})`
+      );
+    }
+  }
+
   parseScript(): Script {
     const wip = new WipScript();
 

From ab91d71170b29f363899a058ed16d96717a01c85 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Wed, 5 Jun 2024 20:02:47 -0400
Subject: [PATCH 05/14] Support [] interpolation in new parser

---
 src/parser2.ts | 9 ++++++++-
 1 file changed, 8 insertions(+), 1 deletion(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index 513f563..92c7233 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -147,6 +147,14 @@ class Parser {
           wip.addWordPiece({ bare: chars }, pos);
           break;
 
+        case "[": {
+          this.advance();
+          const script = this.parseScript();
+          wip.addWordPiece({ script });
+          this.expect("]");
+          break;
+        }
+
         case "whitespace":
           wip.finishWord();
           break;
@@ -162,7 +170,6 @@ class Parser {
 
         case "{":
         case "}":
-        case "[":
         case "quote":
         case "backslash":
         case "comment":

From c1ce90fd63637326b2bba3ab5a28fa7fdd98fa16 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Wed, 5 Jun 2024 20:26:09 -0400
Subject: [PATCH 06/14] WIP brace parsing for WIP parser

---
 src/parser2.ts | 41 ++++++++++++++++++++++++++++++++++++++---
 1 file changed, 38 insertions(+), 3 deletions(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index 92c7233..9481d80 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -63,7 +63,7 @@ const Tokens: [TokenType, RegExp][] = [
   ["quote", /(")/y],
   ["backslash", /(\\)/y],
   ["comment", /(\#)/y],
-  ["text", /([^\s\\;\[\]]+)/y],
+  ["text", /([^\s;\{\}\[\]"\\\#]+)/y],
 ];
 
 class WipScript {
@@ -144,9 +144,18 @@ class Parser {
       const [type, chars, pos] = this.next;
       switch (type) {
         case "text":
+        case "}":
           wip.addWordPiece({ bare: chars }, pos);
           break;
 
+        case "{": {
+          this.advance();
+          const text = this.parseBrace();
+          wip.addWordPiece({ text }, pos);
+          this.expect("}");
+          break;
+        }
+
         case "[": {
           this.advance();
           const script = this.parseScript();
@@ -168,8 +177,6 @@ class Parser {
         case "]":
           return wip.finishScript();
 
-        case "{":
-        case "}":
         case "quote":
         case "backslash":
         case "comment":
@@ -182,4 +189,32 @@ class Parser {
       this.advance();
     }
   }
+
+  parseBrace(): string {
+    let wip = "";
+
+    while (true) {
+      const [type, chars, pos] = this.next;
+      switch (type) {
+        case "{": {
+          wip += "{";
+          this.advance();
+          wip += this.parseBrace();
+          this.expect("}");
+          wip += "}";
+          break;
+        }
+        case "}":
+          return wip;
+        case "EOF":
+          throw new Error("Reached end of input while parsing a brace word");
+        case "ERROR":
+          throw new Error(chars);
+        default:
+          wip += chars;
+      }
+
+      this.advance();
+    }
+  }
 }

From c61496fcc3ea203ea5d8de00df46befe6c147cb5 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Wed, 5 Jun 2024 20:44:13 -0400
Subject: [PATCH 07/14] Be strict about trailing characters after braces. Don't
 give mid-word braces magic meaning.

---
 src/parser2.ts | 26 +++++++++++++++++++-------
 1 file changed, 19 insertions(+), 7 deletions(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index 9481d80..be21529 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -71,24 +71,31 @@ class WipScript {
   wipCommand: Word[] = [];
   wipWord: InterpolatedPiece[] = [];
   wordPos: number | undefined = undefined;
-  // TODO: thing to fail {}a & ""a
+  endOfWordError: string | undefined = undefined;
 
   startOfWord(): boolean {
     return this.wipWord.length == 0;
   }
 
   addWordPiece(piece: InterpolatedPiece, pos?: number) {
+    if (this.endOfWordError) {
+      throw new Error(this.endOfWordError);
+    }
     if (this.startOfWord()) {
       this.wordPos = pos;
     }
     this.wipWord.push(piece);
   }
+  freezeWord(error: string) {
+    this.endOfWordError = error;
+  }
   finishWord() {
     if (this.wipWord.length > 0) {
       this.wipCommand.push(SimplifyWord(this.wipWord, this.wordPos));
-      this.wipWord = [];
-      this.wordPos = undefined;
     }
+    this.wipWord = [];
+    this.wordPos = undefined;
+    this.endOfWordError = undefined;
   }
   finishCommand() {
     this.finishWord();
@@ -149,10 +156,15 @@ class Parser {
           break;
 
         case "{": {
-          this.advance();
-          const text = this.parseBrace();
-          wip.addWordPiece({ text }, pos);
-          this.expect("}");
+          if (wip.startOfWord()) {
+            this.advance();
+            const text = this.parseBrace();
+            wip.addWordPiece({ text }, pos);
+            this.expect("}");
+            wip.freezeWord("Extra characters after closing brace");
+          } else {
+            wip.addWordPiece({ bare: chars }, pos);
+          }
           break;
         }
 

From b2c5f7ea904e37e301d6be5d37eb644cc8d38f76 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Fri, 7 Jun 2024 22:21:04 -0400
Subject: [PATCH 08/14] Handle backslashes in brace words correctly

---
 src/parser2.ts | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/src/parser2.ts b/src/parser2.ts
index be21529..93829cd 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -208,6 +208,11 @@ class Parser {
     while (true) {
       const [type, chars, pos] = this.next;
       switch (type) {
+        case "backslash":
+          wip += "\\";
+          this.advance();
+          wip += this.next[1];
+          break;
         case "{": {
           wip += "{";
           this.advance();

From b536d304201afea5d77068ea19ca7d2fe22231bd Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 8 Jun 2024 00:03:17 -0400
Subject: [PATCH 09/14] sketch out backslash escape handling

---
 src/parser2.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 44 insertions(+), 2 deletions(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index 93829cd..44796cc 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -189,11 +189,53 @@ class Parser {
         case "]":
           return wip.finishScript();
 
+        case "backslash": {
+          this.advance();
+          const [type, chars, pos] = this.next;
+          switch (type) {
+            case "newline":
+              wip.finishWord();
+              break;
+
+            case "whitespace":
+            case "semicolon":
+            case "{":
+            case "}":
+            case "[":
+            case "]":
+            case "quote":
+            case "backslash":
+            case "comment":
+              wip.addWordPiece({ text: chars });
+              break;
+            case "text":
+              switch (chars) {
+                case "n":
+                  wip.addWordPiece({ text: "\n" });
+                  break;
+                default:
+                  throw new Error(`Unknown backslash escape: ${chars}`);
+              }
+              break;
+            case "EOF":
+              throw new Error(
+                "Reached end of input while parsing a backslash escape"
+              );
+            case "ERROR":
+              throw new Error(chars);
+            default:
+              throw new Error(
+                `Unhandled case: ${type satisfies never} (${chars})`
+              );
+          }
+          break;
+        }
+
         case "quote":
-        case "backslash":
         case "comment":
-        case "ERROR":
           throw new Error(`Unhandled case: ${type} (${chars})`);
+        case "ERROR":
+          throw new Error(chars);
         default:
           throw new Error(`Unhandled case: ${type satisfies never} (${chars})`);
       }

From fa3be1e003aa7fbd3318518d071fd5e5a299f6b9 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 8 Jun 2024 12:32:58 -0400
Subject: [PATCH 10/14] Support comments in new parser

---
 src/parser2.ts | 31 ++++++++++++++++++++++++++++---
 src/words.ts   |  8 ++++++--
 2 files changed, 34 insertions(+), 5 deletions(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index 44796cc..fb7131d 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -77,6 +77,10 @@ class WipScript {
     return this.wipWord.length == 0;
   }
 
+  startOfCommand(): boolean {
+    return this.wipWord.length == 0 && this.wipCommand.length == 0;
+  }
+
   addWordPiece(piece: InterpolatedPiece, pos?: number) {
     if (this.endOfWordError) {
       throw new Error(this.endOfWordError);
@@ -185,6 +189,28 @@ class Parser {
           wip.finishCommand();
           break;
 
+        case "comment":
+          if (wip.startOfCommand()) {
+            skipComment: while (this.advance()) {
+              const [type, chars, pos] = this.next;
+              switch (type) {
+                case "newline":
+                case "EOF":
+                  break skipComment;
+                case "backslash":
+                  this.advance();
+                  continue;
+                case "ERROR":
+                  throw new Error(chars);
+                default:
+                  continue;
+              }
+            }
+          } else {
+            wip.addWordPiece({ bare: chars }, pos);
+          }
+          break;
+
         case "EOF":
         case "]":
           return wip.finishScript();
@@ -206,12 +232,12 @@ class Parser {
             case "quote":
             case "backslash":
             case "comment":
-              wip.addWordPiece({ text: chars });
+              wip.addWordPiece({ text: chars }, pos);
               break;
             case "text":
               switch (chars) {
                 case "n":
-                  wip.addWordPiece({ text: "\n" });
+                  wip.addWordPiece({ text: "\n" }, pos);
                   break;
                 default:
                   throw new Error(`Unknown backslash escape: ${chars}`);
@@ -232,7 +258,6 @@ class Parser {
         }
 
         case "quote":
-        case "comment":
           throw new Error(`Unhandled case: ${type} (${chars})`);
         case "ERROR":
           throw new Error(chars);
diff --git a/src/words.ts b/src/words.ts
index 5c8f5b7..7d70c6e 100644
--- a/src/words.ts
+++ b/src/words.ts
@@ -1,4 +1,4 @@
-import { escapeHtml } from './helpers';
+import { escapeHtml } from "./helpers";
 
 export type SourcePos = number;
 
@@ -117,7 +117,11 @@ export function SimplifyWord(
   if (consolidated.length == 0) {
     return { text: "", pos: sourcePosition };
   } else if (consolidated.length == 1 && IsTextPiece(consolidated[0])) {
-    return { ...consolidated[0], pos: sourcePosition };
+    if (pieces.every((piece) => "bare" in piece)) {
+      return { bare: AsText(consolidated[0]), pos: sourcePosition };
+    } else {
+      return { ...consolidated[0], pos: sourcePosition };
+    }
   } else {
     return { pieces: consolidated };
   }

From a2c8eb66b991c68aa41b031c554c9857c41ace17 Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 8 Jun 2024 13:11:46 -0400
Subject: [PATCH 11/14] Factor out backslash parse helper

---
 src/parser2.ts | 80 +++++++++++++++++++++++++++-----------------------
 1 file changed, 43 insertions(+), 37 deletions(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index fb7131d..bb0a2ca 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -217,43 +217,7 @@ class Parser {
 
         case "backslash": {
           this.advance();
-          const [type, chars, pos] = this.next;
-          switch (type) {
-            case "newline":
-              wip.finishWord();
-              break;
-
-            case "whitespace":
-            case "semicolon":
-            case "{":
-            case "}":
-            case "[":
-            case "]":
-            case "quote":
-            case "backslash":
-            case "comment":
-              wip.addWordPiece({ text: chars }, pos);
-              break;
-            case "text":
-              switch (chars) {
-                case "n":
-                  wip.addWordPiece({ text: "\n" }, pos);
-                  break;
-                default:
-                  throw new Error(`Unknown backslash escape: ${chars}`);
-              }
-              break;
-            case "EOF":
-              throw new Error(
-                "Reached end of input while parsing a backslash escape"
-              );
-            case "ERROR":
-              throw new Error(chars);
-            default:
-              throw new Error(
-                `Unhandled case: ${type satisfies never} (${chars})`
-              );
-          }
+          this.parseBackslashEscape(wip, "bare");
           break;
         }
 
@@ -269,6 +233,48 @@ class Parser {
     }
   }
 
+  parseBackslashEscape(wip: WipScript, wordType: "bare" | "quote") {
+    const [type, chars, pos] = this.next;
+    switch (type) {
+      case "newline":
+        if (wordType == "bare") {
+          wip.finishWord();
+        } else {
+          // ignore newline
+        }
+        break;
+
+      case "whitespace":
+      case "semicolon":
+      case "{":
+      case "}":
+      case "[":
+      case "]":
+      case "quote":
+      case "backslash":
+      case "comment":
+        wip.addWordPiece({ text: chars }, pos);
+        break;
+      case "text":
+        switch (chars) {
+          case "n":
+            wip.addWordPiece({ text: "\n" }, pos);
+            break;
+          default:
+            throw new Error(`Unknown backslash escape: ${chars}`);
+        }
+        break;
+      case "EOF":
+        throw new Error(
+          "Reached end of input while parsing a backslash escape"
+        );
+      case "ERROR":
+        throw new Error(chars);
+      default:
+        throw new Error(`Unhandled case: ${type satisfies never} (${chars})`);
+    }
+  }
+
   parseBrace(): string {
     let wip = "";
 

From ac1a38e75fc40fa99143777097b4dedceca906cc Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 8 Jun 2024 13:28:21 -0400
Subject: [PATCH 12/14] Tidy up error reporting

---
 src/parser2.ts | 52 +++++++++++++++++++++++++++++---------------------
 1 file changed, 30 insertions(+), 22 deletions(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index bb0a2ca..c9a65a8 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -35,6 +35,12 @@ export function parse(
 
 // Parser for evaluating Notcl scripts
 
+export class ParseError extends Error {
+  constructor(message: string, public pos: number) {
+    super(message);
+  }
+}
+
 type TokenType =
   | "newline"
   | "whitespace"
@@ -47,8 +53,7 @@ type TokenType =
   | "backslash"
   | "comment"
   | "text"
-  | "EOF"
-  | "ERROR";
+  | "EOF";
 
 type Token = [TokenType, string, number];
 
@@ -81,9 +86,9 @@ class WipScript {
     return this.wipWord.length == 0 && this.wipCommand.length == 0;
   }
 
-  addWordPiece(piece: InterpolatedPiece, pos?: number) {
+  addWordPiece(piece: InterpolatedPiece, pos: number) {
     if (this.endOfWordError) {
-      throw new Error(this.endOfWordError);
+      throw new ParseError(this.endOfWordError, pos);
     }
     if (this.startOfWord()) {
       this.wordPos = pos;
@@ -137,13 +142,14 @@ class Parser {
       }
     }
 
-    return (this.next = ["ERROR", "Token not matched", startPos]);
+    throw new ParseError("Token not matched", startPos);
   }
 
   expect(type: TokenType) {
     if (this.next[0] != type) {
-      throw new Error(
-        `Expected ${type}, found ${this.next[0]} (${this.next[1]})`
+      throw new ParseError(
+        `Expected ${type}, found ${this.next[0]} (${this.next[1]})`,
+        this.next[2]
       );
     }
   }
@@ -175,7 +181,7 @@ class Parser {
         case "[": {
           this.advance();
           const script = this.parseScript();
-          wip.addWordPiece({ script });
+          wip.addWordPiece({ script }, pos);
           this.expect("]");
           break;
         }
@@ -200,8 +206,6 @@ class Parser {
                 case "backslash":
                   this.advance();
                   continue;
-                case "ERROR":
-                  throw new Error(chars);
                 default:
                   continue;
               }
@@ -223,10 +227,11 @@ class Parser {
 
         case "quote":
           throw new Error(`Unhandled case: ${type} (${chars})`);
-        case "ERROR":
-          throw new Error(chars);
         default:
-          throw new Error(`Unhandled case: ${type satisfies never} (${chars})`);
+          throw new ParseError(
+            `Unhandled case: ${type satisfies never} (${chars})`,
+            pos
+          );
       }
 
       this.advance();
@@ -261,17 +266,19 @@ class Parser {
             wip.addWordPiece({ text: "\n" }, pos);
             break;
           default:
-            throw new Error(`Unknown backslash escape: ${chars}`);
+            throw new ParseError(`Unknown backslash escape: ${chars}`, pos);
         }
         break;
       case "EOF":
-        throw new Error(
-          "Reached end of input while parsing a backslash escape"
+        throw new ParseError(
+          "Reached end of input while parsing a backslash escape",
+          pos
         );
-      case "ERROR":
-        throw new Error(chars);
       default:
-        throw new Error(`Unhandled case: ${type satisfies never} (${chars})`);
+        throw new ParseError(
+          `Unhandled case: ${type satisfies never} (${chars})`,
+          pos
+        );
     }
   }
 
@@ -297,9 +304,10 @@ class Parser {
         case "}":
           return wip;
         case "EOF":
-          throw new Error("Reached end of input while parsing a brace word");
-        case "ERROR":
-          throw new Error(chars);
+          throw new ParseError(
+            "Reached end of input while parsing a brace word",
+            pos
+          );
         default:
           wip += chars;
       }

From 6b1c2c48ef13f3130e8fff9e6810e3b74b7e1b3d Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 8 Jun 2024 14:00:45 -0400
Subject: [PATCH 13/14] Support quoted words

---
 src/parser2.ts | 62 +++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 61 insertions(+), 1 deletion(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index c9a65a8..3fc959c 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -178,6 +178,19 @@ class Parser {
           break;
         }
 
+        case "quote": {
+          if (wip.startOfWord()) {
+            wip.addWordPiece({ text: "" }, pos);
+            this.advance();
+            this.parseQuoteWord(wip);
+            this.expect("quote");
+            wip.freezeWord("Extra characters after quoted word");
+          } else {
+            wip.addWordPiece({ bare: chars }, pos);
+          }
+          break;
+        }
+
         case "[": {
           this.advance();
           const script = this.parseScript();
@@ -225,8 +238,55 @@ class Parser {
           break;
         }
 
+        default:
+          throw new ParseError(
+            `Unhandled case: ${type satisfies never} (${chars})`,
+            pos
+          );
+      }
+
+      this.advance();
+    }
+  }
+
+  parseQuoteWord(wip: WipScript) {
+    while (true) {
+      const [type, chars, pos] = this.next;
+      switch (type) {
+        case "text":
+        case "{":
+        case "}":
+        case "]":
+        case "whitespace":
+        case "newline":
+        case "semicolon":
+        case "comment":
+          wip.addWordPiece({ text: chars }, pos);
+          break;
+
+        case "[": {
+          this.advance();
+          const script = this.parseScript();
+          wip.addWordPiece({ script }, pos);
+          this.expect("]");
+          break;
+        }
+
+        case "EOF":
+          throw new ParseError(
+            "Reached end of input while parsing a quoted word",
+            pos
+          );
+
+        case "backslash": {
+          this.advance();
+          this.parseBackslashEscape(wip, "quote");
+          break;
+        }
+
         case "quote":
-          throw new Error(`Unhandled case: ${type} (${chars})`);
+          return;
+
         default:
           throw new ParseError(
             `Unhandled case: ${type satisfies never} (${chars})`,

From d22099323675f3a3dff8e3e4c8ac8dcf55861f6a Mon Sep 17 00:00:00 2001
From: Tangent Wantwight <tangent128@gmail.com>
Date: Sat, 8 Jun 2024 14:03:31 -0400
Subject: [PATCH 14/14] All original parser tests pass

---
 src/parser2.ts | 12 ++++++++----
 1 file changed, 8 insertions(+), 4 deletions(-)

diff --git a/src/parser2.ts b/src/parser2.ts
index 3fc959c..47f9e9f 100644
--- a/src/parser2.ts
+++ b/src/parser2.ts
@@ -234,7 +234,7 @@ class Parser {
 
         case "backslash": {
           this.advance();
-          this.parseBackslashEscape(wip, "bare");
+          this.parseBackslashEscape(wip, pos, "bare");
           break;
         }
 
@@ -280,7 +280,7 @@ class Parser {
 
         case "backslash": {
           this.advance();
-          this.parseBackslashEscape(wip, "quote");
+          this.parseBackslashEscape(wip, pos, "quote");
           break;
         }
 
@@ -298,8 +298,12 @@ class Parser {
     }
   }
 
-  parseBackslashEscape(wip: WipScript, wordType: "bare" | "quote") {
-    const [type, chars, pos] = this.next;
+  parseBackslashEscape(
+    wip: WipScript,
+    pos: number,
+    wordType: "bare" | "quote"
+  ) {
+    const [type, chars] = this.next;
     switch (type) {
       case "newline":
         if (wordType == "bare") {