import assert from "node:assert";
import { describe, test } from "node:test";
import { parse } from "../../src/compiler/parse.js";
import { undetermined } from "../../src/compiler/parserHelpers.js";
import * as ops from "../../src/runtime/ops.js";
import { stripCodeLocations } from "./stripCodeLocations.js";

describe("Origami parser", () => {
  test("additiveExpression", () => {
    assertParse("additiveExpression", "1 + 2", [
      ops.addition,
      [ops.literal, 1],
      [ops.literal, 2],
    ]);
    assertParse("additiveExpression", "5 - 4", [
      ops.subtraction,
      [ops.literal, 5],
      [ops.literal, 4],
    ]);
  });

  test("arrayLiteral", () => {
    assertParse("arrayLiteral", "[]", [ops.array]);
    assertParse("arrayLiteral", "[1, 2, 3]", [
      ops.array,
      [ops.literal, 1],
      [ops.literal, 2],
      [ops.literal, 3],
    ]);
    assertParse("arrayLiteral", "[ 1 , 2 , 3 ]", [
      ops.array,
      [ops.literal, 1],
      [ops.literal, 2],
      [ops.literal, 3],
    ]);
    assertParse("arrayLiteral", "[1,,,4]", [
      ops.array,
      [ops.literal, 1],
      [ops.literal, undefined],
      [ops.literal, undefined],
      [ops.literal, 4],
    ]);
    assertParse("arrayLiteral", "[ 1, ...[2, 3]]", [
      ops.merge,
      [ops.array, [ops.literal, 1]],
      [ops.array, [ops.literal, 2], [ops.literal, 3]],
    ]);
    assertParse(
      "arrayLiteral",
      `[
        1
        2
        3
      ]`,
      [ops.array, [ops.literal, 1], [ops.literal, 2], [ops.literal, 3]]
    );
  });

  test("arrowFunction", () => {
    assertParse("arrowFunction", "() => foo", [
      ops.lambda,
      [],
      [ops.scope, "foo"],
    ]);
    assertParse("arrowFunction", "x => y", [
      ops.lambda,
      ["x"],
      [ops.scope, "y"],
    ]);
    assertParse("arrowFunction", "(a, b, c) ⇒ fn(a, b, c)", [
      ops.lambda,
      ["a", "b", "c"],
      [
        [ops.builtin, "fn"],
        [ops.scope, "a"],
        [ops.scope, "b"],
        [ops.scope, "c"],
      ],
    ]);
    assertParse("arrowFunction", "a => b => fn(a, b)", [
      ops.lambda,
      ["a"],
      [
        ops.lambda,
        ["b"],
        [
          [ops.builtin, "fn"],
          [ops.scope, "a"],
          [ops.scope, "b"],
        ],
      ],
    ]);
  });

  test("bitwiseAndExpression", () => {
    assertParse("bitwiseAndExpression", "5 & 3", [
      ops.bitwiseAnd,
      [ops.literal, 5],
      [ops.literal, 3],
    ]);
  });

  test("bitwiseOrExpression", () => {
    assertParse("bitwiseOrExpression", "5 | 3", [
      ops.bitwiseOr,
      [ops.literal, 5],
      [ops.literal, 3],
    ]);
  });

  test("bitwiseXorExpression", () => {
    assertParse("bitwiseXorExpression", "5 ^ 3", [
      ops.bitwiseXor,
      [ops.literal, 5],
      [ops.literal, 3],
    ]);
  });

  test("callExpression", () => {
    assertParse("callExpression", "fn()", [[ops.builtin, "fn"], undefined]);
    assertParse("callExpression", "foo.js(arg)", [
      [ops.scope, "foo.js"],
      [ops.scope, "arg"],
    ]);
    assertParse("callExpression", "fn(a, b)", [
      [ops.builtin, "fn"],
      [ops.scope, "a"],
      [ops.scope, "b"],
    ]);
    assertParse("callExpression", "foo.js( a , b )", [
      [ops.scope, "foo.js"],
      [ops.scope, "a"],
      [ops.scope, "b"],
    ]);
    assertParse("callExpression", "fn()(arg)", [
      [[ops.builtin, "fn"], undefined],
      [ops.scope, "arg"],
    ]);
    assertParse("callExpression", "tree/", [ops.unpack, [ops.scope, "tree/"]]);
    assertParse("callExpression", "tree/foo/bar", [
      ops.traverse,
      [ops.scope, "tree/"],
      [ops.literal, "foo/"],
      [ops.literal, "bar"],
    ]);
    assertParse("callExpression", "tree/foo/bar/", [
      ops.traverse,
      [ops.scope, "tree/"],
      [ops.literal, "foo/"],
      [ops.literal, "bar/"],
    ]);
    assertParse("callExpression", "/foo/bar", [
      ops.traverse,
      [ops.rootDirectory, [ops.literal, "foo/"]],
      [ops.literal, "bar"],
    ]);
    assertParse("callExpression", "foo.js()/key", [
      ops.traverse,
      [[ops.scope, "foo.js"], undefined],
      [ops.literal, "key"],
    ]);
    assertParse("callExpression", "tree/key()", [
      [ops.traverse, [ops.scope, "tree/"], [ops.literal, "key"]],
      undefined,
    ]);
    assertParse("callExpression", "(tree)/", [ops.unpack, [ops.scope, "tree"]]);
    assertParse("callExpression", "fn()/key()", [
      [ops.traverse, [[ops.builtin, "fn"], undefined], [ops.literal, "key"]],
      undefined,
    ]);
    assertParse("callExpression", "(foo.js())('arg')", [
      [[ops.scope, "foo.js"], undefined],
      [ops.literal, "arg"],
    ]);
    assertParse("callExpression", "fn('a')('b')", [
      [
        [ops.builtin, "fn"],
        [ops.literal, "a"],
      ],
      [ops.literal, "b"],
    ]);
    assertParse("callExpression", "(foo.js())(a, b)", [
      [[ops.scope, "foo.js"], undefined],
      [ops.scope, "a"],
      [ops.scope, "b"],
    ]);
    assertParse("callExpression", "{ a: 1, b: 2}/b", [
      ops.traverse,
      [ops.object, ["a", [ops.literal, 1]], ["b", [ops.literal, 2]]],
      [ops.literal, "b"],
    ]);
    assertParse("callExpression", "indent`hello`", [
      [ops.builtin, "indent"],
      [ops.literal, ["hello"]],
    ]);
    assertParse("callExpression", "fn.js`Hello, world.`", [
      [ops.scope, "fn.js"],
      [ops.literal, ["Hello, world."]],
    ]);
    assertParse("callExpression", "files:src/assets", [
      ops.traverse,
      [
        [ops.builtin, "files:"],
        [ops.literal, "src/"],
      ],
      [ops.literal, "assets"],
    ]);
    assertParse("callExpression", "new:(js:Date, '2025-01-01')", [
      [ops.builtin, "new:"],
      [
        [ops.builtin, "js:"],
        [ops.literal, "Date"],
      ],
      [ops.literal, "2025-01-01"],
    ]);
    assertParse("callExpression", "map(markdown, mdHtml)", [
      [ops.builtin, "map"],
      [ops.scope, "markdown"],
      [ops.scope, "mdHtml"],
    ]);
    assertParse("callExpression", "package:@weborigami/dropbox/auth(creds)", [
      [
        ops.traverse,
        [
          [ops.builtin, "package:"],
          [ops.literal, "@weborigami/"],
        ],
        [ops.literal, "dropbox/"],
        [ops.literal, "auth"],
      ],
      [ops.scope, "creds"],
    ]);
  });

  test("commaExpression", () => {
    assertParse("commaExpression", "1", [ops.literal, 1]);
    assertParse("commaExpression", "a, b, c", [
      ops.comma,
      [ops.scope, "a"],
      [ops.scope, "b"],
      [ops.scope, "c"],
    ]);
  });

  test("conditionalExpression", () => {
    assertParse("conditionalExpression", "1", [ops.literal, 1]);
    assertParse("conditionalExpression", "true ? 1 : 0", [
      ops.conditional,
      [ops.scope, "true"],
      [ops.lambda, [], [ops.literal, "1"]],
      [ops.lambda, [], [ops.literal, "0"]],
    ]);
    assertParse("conditionalExpression", "false ? () => 1 : 0", [
      ops.conditional,
      [ops.scope, "false"],
      [ops.lambda, [], [ops.lambda, [], [ops.literal, "1"]]],
      [ops.lambda, [], [ops.literal, "0"]],
    ]);
    assertParse("conditionalExpression", "false ? =1 : 0", [
      ops.conditional,
      [ops.scope, "false"],
      [ops.lambda, [], [ops.lambda, ["_"], [ops.literal, "1"]]],
      [ops.lambda, [], [ops.literal, "0"]],
    ]);
  });

  test("equalityExpression", () => {
    assertParse("equalityExpression", "1 === 1", [
      ops.strictEqual,
      [ops.literal, 1],
      [ops.literal, 1],
    ]);
    assertParse("equalityExpression", "a === b === c", [
      ops.strictEqual,
      [ops.strictEqual, [undetermined, "a"], [undetermined, "b"]],
      [undetermined, "c"],
    ]);
    assertParse("equalityExpression", "1 !== 1", [
      ops.notStrictEqual,
      [ops.literal, 1],
      [ops.literal, 1],
    ]);
  });

  test("exponentiationExpression", () => {
    assertParse("exponentiationExpression", "2 ** 2 ** 3", [
      ops.exponentiation,
      [ops.literal, 2],
      [ops.exponentiation, [ops.literal, 2], [ops.literal, 3]],
    ]);
  });

  test("expression", () => {
    assertParse(
      "expression",
      `
        {
          index.html = index.ori(teamData.yaml)
          thumbnails = map(images, { value: thumbnail.js })
        }
      `,
      [
        ops.object,
        [
          "index.html",
          [
            ops.getter,
            [
              [ops.scope, "index.ori"],
              [ops.scope, "teamData.yaml"],
            ],
          ],
        ],
        [
          "thumbnails",
          [
            ops.getter,
            [
              [ops.builtin, "map"],
              [ops.scope, "images"],
              [ops.object, ["value", [ops.scope, "thumbnail.js"]]],
            ],
          ],
        ],
      ]
    );

    // Builtin on its own is the function itself, not a function call
    assertParse("expression", "mdHtml:", [ops.builtin, "mdHtml:"]);

    // Consecutive slahes in a path are removed
    assertParse("expression", "path//key", [
      ops.traverse,
      [ops.scope, "path/"],
      [ops.literal, "key"],
    ]);

    // Single slash at start of something = absolute file path
    assertParse("expression", "/path", [
      ops.rootDirectory,
      [ops.literal, "path"],
    ]);

    // Consecutive slashes at start of something = comment
    assertParse("expression", "path //comment", [ops.scope, "path"], false);
    assertParse("expression", "page.ori(mdHtml:(about.md))", [
      [ops.scope, "page.ori"],
      [
        [ops.builtin, "mdHtml:"],
        [ops.scope, "about.md"],
      ],
    ]);

    // Slash on its own is the root folder
    assertParse("expression", "keys /", [
      [ops.builtin, "keys"],
      [ops.rootDirectory],
    ]);

    assertParse("expression", "'Hello' -> test.orit", [
      [ops.scope, "test.orit"],
      [ops.literal, "Hello"],
    ]);
    assertParse("expression", "obj.json", [ops.scope, "obj.json"]);
    assertParse("expression", "(fn a, b, c)", [
      [ops.builtin, "fn"],
      [undetermined, "a"],
      [undetermined, "b"],
      [undetermined, "c"],
    ]);
    assertParse("expression", "foo.bar('hello', 'world')", [
      [ops.scope, "foo.bar"],
      [ops.literal, "hello"],
      [ops.literal, "world"],
    ]);
    assertParse("expression", "(key)('a')", [
      [ops.scope, "key"],
      [ops.literal, "a"],
    ]);
    assertParse("expression", "1", [ops.literal, 1]);
    assertParse("expression", "{ a: 1, b: 2 }", [
      ops.object,
      ["a", [ops.literal, 1]],
      ["b", [ops.literal, 2]],
    ]);
    assertParse("expression", "serve { index.html: 'hello' }", [
      [ops.builtin, "serve"],
      [ops.object, ["index.html", [ops.literal, "hello"]]],
    ]);
    assertParse("expression", "fn =`x`", [
      [ops.builtin, "fn"],
      [ops.lambda, ["_"], [ops.template, [ops.literal, ["x"]]]],
    ]);
    assertParse("expression", "copy app.js(formulas), files:snapshot", [
      [ops.builtin, "copy"],
      [
        [ops.scope, "app.js"],
        [ops.scope, "formulas"],
      ],
      [
        [ops.builtin, "files:"],
        [ops.literal, "snapshot"],
      ],
    ]);
    assertParse("expression", "map =`<li>${_}</li>`", [
      [ops.builtin, "map"],
      [
        ops.lambda,
        ["_"],
        [
          ops.template,
          [ops.literal, ["<li>", "</li>"]],
          [ops.concat, [ops.scope, "_"]],
        ],
      ],
    ]);
    assertParse("expression", `https://example.com/about/`, [
      [ops.builtin, "https:"],
      [ops.literal, "example.com/"],
      [ops.literal, "about/"],
    ]);
    assertParse("expression", "tag`Hello, ${name}!`", [
      [ops.builtin, "tag"],
      [ops.literal, ["Hello, ", "!"]],
      [ops.concat, [ops.scope, "name"]],
    ]);
    assertParse("expression", "(post, slug) => fn.js(post, slug)", [
      ops.lambda,
      ["post", "slug"],
      [
        [ops.scope, "fn.js"],
        [ops.scope, "post"],
        [ops.scope, "slug"],
      ],
    ]);

    // Verify parser treatment of identifiers containing operators
    assertParse("expression", "a + b", [
      ops.addition,
      [undetermined, "a"],
      [undetermined, "b"],
    ]);
    assertParse("expression", "a+b", [ops.scope, "a+b"]);
    assertParse("expression", "a - b", [
      ops.subtraction,
      [undetermined, "a"],
      [undetermined, "b"],
    ]);
    assertParse("expression", "a-b", [ops.scope, "a-b"]);
    assertParse("expression", "a&b", [ops.scope, "a&b"]);
    assertParse("expression", "a & b", [
      ops.bitwiseAnd,
      [undetermined, "a"],
      [undetermined, "b"],
    ]);
  });

  test("group", () => {
    assertParse("group", "(hello)", [ops.scope, "hello"]);
    assertParse("group", "(((nested)))", [ops.scope, "nested"]);
    assertParse("group", "(fn())", [[ops.builtin, "fn"], undefined]);
    assertParse("group", "(a -> b)", [
      [ops.builtin, "b"],
      [ops.scope, "a"],
    ]);
  });

  test("homeDirectory", () => {
    assertParse("homeDirectory", "~", [ops.homeDirectory]);
  });

  test("host", () => {
    assertParse("host", "abc", [ops.literal, "abc"]);
    assertParse("host", "abc:123", [ops.literal, "abc:123"]);
    assertParse("host", "foo\\ bar", [ops.literal, "foo bar"]);
  });

  test("identifier", () => {
    assertParse("identifier", "abc", "abc", false);
    assertParse("identifier", "index.html", "index.html", false);
    assertParse("identifier", "foo\\ bar", "foo bar", false);
    assertParse("identifier", "x-y-z", "x-y-z", false);
  });

  test("implicitParenthesesCallExpression", () => {
    assertParse("implicitParenthesesCallExpression", "fn arg", [
      [ops.builtin, "fn"],
      [undetermined, "arg"],
    ]);
    assertParse("implicitParenthesesCallExpression", "page.ori 'a', 'b'", [
      [ops.scope, "page.ori"],
      [ops.literal, "a"],
      [ops.literal, "b"],
    ]);
    assertParse("implicitParenthesesCallExpression", "fn a(b), c", [
      [ops.builtin, "fn"],
      [
        [ops.builtin, "a"],
        [ops.scope, "b"],
      ],
      [undetermined, "c"],
    ]);
    assertParse("implicitParenthesesCallExpression", "(fn()) 'arg'", [
      [[ops.builtin, "fn"], undefined],
      [ops.literal, "arg"],
    ]);
    assertParse("implicitParenthesesCallExpression", "tree/key arg", [
      [ops.traverse, [ops.scope, "tree/"], [ops.literal, "key"]],
      [undetermined, "arg"],
    ]);
    assertParse("implicitParenthesesCallExpression", "foo.js bar.ori 'arg'", [
      [ops.scope, "foo.js"],
      [
        [ops.scope, "bar.ori"],
        [ops.literal, "arg"],
      ],
    ]);
  });

  test("list", () => {
    assertParse("list", "1", [[ops.literal, 1]]);
    assertParse("list", "1,2,3", [
      [ops.literal, 1],
      [ops.literal, 2],
      [ops.literal, 3],
    ]);
    assertParse("list", "1, 2, 3,", [
      [ops.literal, 1],
      [ops.literal, 2],
      [ops.literal, 3],
    ]);
    assertParse("list", "1 , 2 , 3", [
      [ops.literal, 1],
      [ops.literal, 2],
      [ops.literal, 3],
    ]);
    assertParse("list", "1\n2\n3", [
      [ops.literal, 1],
      [ops.literal, 2],
      [ops.literal, 3],
    ]);
    assertParse("list", "'a' , 'b' , 'c'", [
      [ops.literal, "a"],
      [ops.literal, "b"],
      [ops.literal, "c"],
    ]);
  });

  test("logicalAndExpression", () => {
    assertParse("logicalAndExpression", "true && false", [
      ops.logicalAnd,
      [ops.scope, "true"],
      [ops.lambda, [], [undetermined, "false"]],
    ]);
  });

  test("logicalOrExpression", () => {
    assertParse("logicalOrExpression", "1 || 0", [
      ops.logicalOr,
      [ops.literal, 1],
      [ops.literal, 0],
    ]);
    assertParse("logicalOrExpression", "false || false || true", [
      ops.logicalOr,
      [ops.scope, "false"],
      [ops.lambda, [], [undetermined, "false"]],
      [ops.lambda, [], [undetermined, "true"]],
    ]);
    assertParse("logicalOrExpression", "1 || 2 && 0", [
      ops.logicalOr,
      [ops.literal, 1],
      [ops.lambda, [], [ops.logicalAnd, [ops.literal, 2], [ops.literal, 0]]],
    ]);
  });

  test("multiLineComment", () => {
    assertParse("multiLineComment", "/*\nHello, world!\n*/", null, false);
  });

  test("multiplicativeExpression", () => {
    assertParse("multiplicativeExpression", "3 * 4", [
      ops.multiplication,
      [ops.literal, 3],
      [ops.literal, 4],
    ]);
    assertParse("multiplicativeExpression", "5 / 2", [
      ops.division,
      [ops.literal, 5],
      [ops.literal, 2],
    ]);
    assertParse("multiplicativeExpression", "6 % 5", [
      ops.remainder,
      [ops.literal, 6],
      [ops.literal, 5],
    ]);
  });

  test("namespace", () => {
    assertParse("namespace", "js:", [ops.builtin, "js:"]);
  });

  test("nullishCoalescingExpression", () => {
    assertParse("nullishCoalescingExpression", "a ?? b", [
      ops.nullishCoalescing,
      [ops.scope, "a"],
      [ops.lambda, [], [undetermined, "b"]],
    ]);
    assertParse("nullishCoalescingExpression", "a ?? b ?? c", [
      ops.nullishCoalescing,
      [ops.scope, "a"],
      [ops.lambda, [], [undetermined, "b"]],
      [ops.lambda, [], [undetermined, "c"]],
    ]);
  });

  test("numericLiteral", () => {
    assertParse("numericLiteral", "123", [ops.literal, 123]);
    assertParse("numericLiteral", ".5", [ops.literal, 0.5]);
    assertParse("numericLiteral", "123.45", [ops.literal, 123.45]);
  });

  test("objectLiteral", () => {
    assertParse("objectLiteral", "{}", [ops.object]);
    assertParse("objectLiteral", "{ a: 1, b }", [
      ops.object,
      ["a", [ops.literal, 1]],
      ["b", [ops.inherited, "b"]],
    ]);
    assertParse("objectLiteral", "{ sub: { a: 1 } }", [
      ops.object,
      ["sub", [ops.object, ["a", [ops.literal, 1]]]],
    ]);
    assertParse("objectLiteral", "{ sub: { a/: 1 } }", [
      ops.object,
      ["sub", [ops.object, ["a/", [ops.literal, 1]]]],
    ]);
    assertParse("objectLiteral", `{ "a": 1, "b": 2 }`, [
      ops.object,
      ["a", [ops.literal, 1]],
      ["b", [ops.literal, 2]],
    ]);
    assertParse("objectLiteral", "{ a = b, b = 2 }", [
      ops.object,
      ["a", [ops.getter, [ops.scope, "b"]]],
      ["b", [ops.literal, 2]],
    ]);
    assertParse(
      "objectLiteral",
      `{
        a = b
        b = 2
      }`,
      [
        ops.object,
        ["a", [ops.getter, [ops.scope, "b"]]],
        ["b", [ops.literal, 2]],
      ]
    );
    assertParse("objectLiteral", "{ a: { b: 1 } }", [
      ops.object,
      ["a", [ops.object, ["b", [ops.literal, 1]]]],
    ]);
    assertParse("objectLiteral", "{ a: { b = 1 } }", [
      ops.object,
      ["a", [ops.object, ["b", [ops.literal, 1]]]],
    ]);
    assertParse("objectLiteral", "{ a: { b = fn() } }", [
      ops.object,
      [
        "a/",
        [ops.object, ["b", [ops.getter, [[ops.builtin, "fn"], undefined]]]],
      ],
    ]);
    assertParse("objectLiteral", "{ x = fn.js('a') }", [
      ops.object,
      [
        "x",
        [
          ops.getter,
          [
            [ops.scope, "fn.js"],
            [ops.literal, "a"],
          ],
        ],
      ],
    ]);
    assertParse("objectLiteral", "{ a: 1, ...b }", [
      ops.merge,
      [ops.object, ["a", [ops.literal, 1]]],
      [undetermined, "b"],
    ]);
    assertParse("objectLiteral", "{ (a): 1 }", [
      ops.object,
      ["(a)", [ops.literal, 1]],
    ]);
  });

  test("objectEntry", () => {
    assertParse("objectEntry", "foo", ["foo", [ops.inherited, "foo"]]);
    assertParse("objectEntry", "x: y", ["x", [ops.scope, "y"]]);
    assertParse("objectEntry", "a: a", ["a", [ops.inherited, "a"]]);
    assertParse("objectEntry", "a: (a) => a", [
      "a",
      [ops.lambda, ["a"], [ops.scope, "a"]],
    ]);
    assertParse("objectEntry", "posts/: map(posts, post.ori)", [
      "posts/",
      [
        [ops.builtin, "map"],
        [ops.inherited, "posts"],
        [ops.scope, "post.ori"],
      ],
    ]);
  });

  test("objectGetter", () => {
    assertParse("objectGetter", "data = obj.json", [
      "data",
      [ops.getter, [ops.scope, "obj.json"]],
    ]);
    assertParse("objectGetter", "foo = page.ori 'bar'", [
      "foo",
      [
        ops.getter,
        [
          [ops.scope, "page.ori"],
          [ops.literal, "bar"],
        ],
      ],
    ]);
  });

  test("objectProperty", () => {
    assertParse("objectProperty", "a: 1", ["a", [ops.literal, 1]]);
    assertParse("objectProperty", "name: 'Alice'", [
      "name",
      [ops.literal, "Alice"],
    ]);
    assertParse("objectProperty", "x: fn('a')", [
      "x",
      [
        [ops.builtin, "fn"],
        [ops.literal, "a"],
      ],
    ]);
  });

  test("objectPublicKey", () => {
    assertParse("objectPublicKey", "a", "a", false);
    assertParse("objectPublicKey", "markdown/", "markdown/", false);
    assertParse("objectPublicKey", "foo\\ bar", "foo bar", false);
  });

  test("parenthesesArguments", () => {
    assertParse("parenthesesArguments", "()", [undefined]);
    assertParse("parenthesesArguments", "(a, b, c)", [
      [ops.scope, "a"],
      [ops.scope, "b"],
      [ops.scope, "c"],
    ]);
  });

  test("path", () => {
    assertParse("path", "/tree/", [[ops.literal, "tree/"]]);
    assertParse("path", "/month/12", [
      [ops.literal, "month/"],
      [ops.literal, "12"],
    ]);
    assertParse("path", "/tree/foo/bar", [
      [ops.literal, "tree/"],
      [ops.literal, "foo/"],
      [ops.literal, "bar"],
    ]);
    assertParse("path", "/a///b", [
      [ops.literal, "a/"],
      [ops.literal, "b"],
    ]);
  });

  test("pathArguments", () => {
    assertParse("pathArguments", "/", [ops.traverse]);
    assertParse("pathArguments", "/tree", [
      ops.traverse,
      [ops.literal, "tree"],
    ]);
    assertParse("pathArguments", "/tree/", [
      ops.traverse,
      [ops.literal, "tree/"],
    ]);
  });

  test("pipelineExpression", () => {
    assertParse("pipelineExpression", "foo", [ops.scope, "foo"]);
    assertParse("pipelineExpression", "a -> b", [
      [ops.builtin, "b"],
      [ops.scope, "a"],
    ]);
    assertParse("pipelineExpression", "input → one.js → two.js", [
      [ops.scope, "two.js"],
      [
        [ops.scope, "one.js"],
        [ops.scope, "input"],
      ],
    ]);
    assertParse("pipelineExpression", "fn a -> b", [
      [ops.builtin, "b"],
      [
        [ops.builtin, "fn"],
        [undetermined, "a"],
      ],
    ]);
  });

  test("primary", () => {
    assertParse("primary", "foo.js", [ops.scope, "foo.js"]);
    assertParse("primary", "[1, 2]", [
      ops.array,
      [ops.literal, 1],
      [ops.literal, 2],
    ]);
  });

  test("program", () => {
    assertParse(
      "program",
      `#!/usr/bin/env ori invoke
'Hello'
`,
      [ops.literal, "Hello"],
      false
    );
  });

  test("protocolExpression", () => {
    assertParse("protocolExpression", "foo://bar", [
      [ops.builtin, "foo:"],
      [ops.literal, "bar"],
    ]);
    assertParse("protocolExpression", "http://example.com", [
      [ops.builtin, "http:"],
      [ops.literal, "example.com"],
    ]);
    assertParse("protocolExpression", "https://example.com/about/", [
      [ops.builtin, "https:"],
      [ops.literal, "example.com/"],
      [ops.literal, "about/"],
    ]);
    assertParse("protocolExpression", "https://example.com/about/index.html", [
      [ops.builtin, "https:"],
      [ops.literal, "example.com/"],
      [ops.literal, "about/"],
      [ops.literal, "index.html"],
    ]);
    assertParse("protocolExpression", "http://localhost:5000/foo", [
      [ops.builtin, "http:"],
      [ops.literal, "localhost:5000/"],
      [ops.literal, "foo"],
    ]);
  });

  test("qualifiedReference", () => {
    assertParse("qualifiedReference", "js:Date", [
      [ops.builtin, "js:"],
      [ops.literal, "Date"],
    ]);
  });

  test("relationalExpression", () => {
    assertParse("relationalExpression", "1 < 2", [
      ops.lessThan,
      [ops.literal, 1],
      [ops.literal, 2],
    ]);
    assertParse("relationalExpression", "3 > 4", [
      ops.greaterThan,
      [ops.literal, 3],
      [ops.literal, 4],
    ]);
    assertParse("relationalExpression", "5 <= 6", [
      ops.lessThanOrEqual,
      [ops.literal, 5],
      [ops.literal, 6],
    ]);
    assertParse("relationalExpression", "7 >= 8", [
      ops.greaterThanOrEqual,
      [ops.literal, 7],
      [ops.literal, 8],
    ]);
  });

  test("rootDirectory", () => {
    assertParse("rootDirectory", "/", [ops.rootDirectory]);
  });

  test("scopeReference", () => {
    assertParse("scopeReference", "keys", [undetermined, "keys"]);
    assertParse("scopeReference", "greet.js", [ops.scope, "greet.js"]);
    // scopeReference checks whether a slash follows; hard to test in isolation
    // assertParse("scopeReference", "markdown/", [ops.scope, "markdown"]);
  });

  test("shiftExpression", () => {
    assertParse("shiftExpression", "1 << 2", [
      ops.shiftLeft,
      [ops.literal, 1],
      [ops.literal, 2],
    ]);
    assertParse("shiftExpression", "3 >> 4", [
      ops.shiftRightSigned,
      [ops.literal, 3],
      [ops.literal, 4],
    ]);
    assertParse("shiftExpression", "5 >>> 6", [
      ops.shiftRightUnsigned,
      [ops.literal, 5],
      [ops.literal, 6],
    ]);
  });

  test("shorthandFunction", () => {
    assertParse("shorthandFunction", "=message", [
      ops.lambda,
      ["_"],
      [undetermined, "message"],
    ]);
    assertParse("shorthandFunction", "=`Hello, ${name}.`", [
      ops.lambda,
      ["_"],
      [
        ops.template,
        [ops.literal, ["Hello, ", "."]],
        [ops.concat, [ops.scope, "name"]],
      ],
    ]);
    assertParse("shorthandFunction", "=indent`hello`", [
      ops.lambda,
      ["_"],
      [
        [ops.builtin, "indent"],
        [ops.literal, ["hello"]],
      ],
    ]);
  });

  test("singleLineComment", () => {
    assertParse("singleLineComment", "// Hello, world!", null, false);
  });

  test("spread", () => {
    assertParse("spread", "...a", [ops.spread, [undetermined, "a"]]);
    assertParse("spread", "…a", [ops.spread, [undetermined, "a"]]);
  });

  test("stringLiteral", () => {
    assertParse("stringLiteral", '"foo"', [ops.literal, "foo"]);
    assertParse("stringLiteral", "'bar'", [ops.literal, "bar"]);
    assertParse("stringLiteral", '"foo bar"', [ops.literal, "foo bar"]);
    assertParse("stringLiteral", "'bar baz'", [ops.literal, "bar baz"]);
    assertParse("stringLiteral", `"foo\\"s bar"`, [ops.literal, `foo"s bar`]);
    assertParse("stringLiteral", `'bar\\'s baz'`, [ops.literal, `bar's baz`]);
    assertParse("stringLiteral", `«string»`, [ops.literal, "string"]);
    assertParse("stringLiteral", `"\\0\\b\\f\\n\\r\\t\\v"`, [
      ops.literal,
      "\0\b\f\n\r\t\v",
    ]);
  });

  test("templateDocument", () => {
    assertParse("templateDocument", "hello${foo}world", [
      ops.lambda,
      ["_"],
      [
        ops.template,
        [ops.literal, ["hello", "world"]],
        [ops.concat, [ops.scope, "foo"]],
      ],
    ]);
    assertParse("templateDocument", "Documents can contain ` backticks", [
      ops.lambda,
      ["_"],
      [ops.template, [ops.literal, ["Documents can contain ` backticks"]]],
    ]);
  });

  test("templateLiteral", () => {
    assertParse("templateLiteral", "`Hello, world.`", [
      ops.template,
      [ops.literal, ["Hello, world."]],
    ]);
    assertParse("templateLiteral", "`foo ${x} bar`", [
      ops.template,
      [ops.literal, ["foo ", " bar"]],
      [ops.concat, [ops.scope, "x"]],
    ]);
    assertParse("templateLiteral", "`${`nested`}`", [
      ops.template,
      [ops.literal, ["", ""]],
      [ops.concat, [ops.template, [ops.literal, ["nested"]]]],
    ]);
    assertParse("templateLiteral", "`${ map:(people, =`${name}`) }`", [
      ops.template,
      [ops.literal, ["", ""]],
      [
        ops.concat,
        [
          [ops.builtin, "map:"],
          [ops.scope, "people"],
          [
            ops.lambda,
            ["_"],
            [
              ops.template,
              [ops.literal, ["", ""]],
              [ops.concat, [ops.scope, "name"]],
            ],
          ],
        ],
      ],
    ]);
  });

  test("templateSubtitution", () => {
    assertParse("templateSubstitution", "${foo}", [ops.scope, "foo"], false);
  });

  test("whitespace block", () => {
    assertParse(
      "__",
      `  
  // First comment
  // Second comment
     `,
      null,
      false
    );
  });

  test("unaryExpression", () => {
    assertParse("unaryExpression", "!true", [
      ops.logicalNot,
      [undetermined, "true"],
    ]);
    assertParse("unaryExpression", "+1", [ops.unaryPlus, [ops.literal, 1]]);
    assertParse("unaryExpression", "-2", [ops.unaryMinus, [ops.literal, 2]]);
    assertParse("unaryExpression", "~3", [ops.bitwiseNot, [ops.literal, 3]]);
  });
});

function assertParse(startRule, source, expected, checkLocation = true) {
  const code = parse(source, {
    grammarSource: { text: source },
    startRule,
  });

  // Verify that the parser returned a `location` property and that it spans the
  // entire source. We skip this check in cases where the source starts or ends
  // with a comment; the parser will strip those.
  if (checkLocation) {
    assert(code.location, "no location");
    const resultSource = code.location.source.text.slice(
      code.location.start.offset,
      code.location.end.offset
    );
    assert.equal(resultSource, source.trim());
  }

  const actual = stripCodeLocations(code);
  assert.deepEqual(actual, expected);
}
