From 3b1a74cb12d1b7508e181e35d5b4e362359a31a5 Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Sat, 26 Sep 2026 04:18:27 -0700 Subject: [PATCH 1/2] feat: end types and trailing `!` correctly, read `delete` as an operator, and stop after an empty-placeholder error Inside a type, `void` was read as a prefix operator, so a type ending in `void` (`export type H = () => void`, ``, `(): void {`) carried on into the next line, the open tag end, or the function body. In a type it is always the `void` type, so it is now only a unary keyword outside types. A trailing `!` was always read as a prefix operator waiting for an operand, so a TypeScript non-null assertion ending an unenclosed value (`x!`, `f()!`, `a[0]!`) swallowed what followed. Directly after an operand it is now postfix; after a keyword operator (`typeof!`, `in!`) it stays prefix. `isValidStatement` skipped the `type `/`interface `/`declare ` look-ahead that statement tags use, so it lexed types as JavaScript and disagreed with the parser. Both now share `prepareStatement`. `delete` was missing from the prefix keywords, so `
` split into two attributes. It now continues like `typeof` and `void`. `return`, `throw` and `yield` stay out: JavaScript allows no line break after them. An empty placeholder (`${}`) reported its error and then still emitted `onPlaceholder` with an end past the input. It now stops at the error. Co-Authored-By: Claude Opus 5.5 --- .changeset/delete-unary-keyword.md | 5 ++ .changeset/empty-placeholder-error.md | 5 ++ .changeset/trailing-non-null-assertion.md | 5 ++ .changeset/type-ends-at-void.md | 5 ++ .changeset/valid-type-statement.md | 5 ++ .../attr-operators-space-after.expected.txt | 16 ++--- .../attr-value-delete.expected.txt | 16 +++++ .../fixtures/attr-value-delete/input.marko | 3 + ...on-null-assertion-before-attr.expected.txt | 11 ++++ .../input.marko | 1 + ...assertion-before-open-tag-end.expected.txt | 15 +++++ .../input.marko | 2 + ...l-assertion-before-self-close.expected.txt | 11 ++++ .../input.marko | 1 + .../non-null-assertion-at-eol.expected.txt | 15 +++++ .../non-null-assertion-at-eol/input.marko | 3 + .../placeholder-empty.expected.txt | 4 +- ...ix-not-after-keyword-operator.expected.txt | 30 ++++++++++ .../input.marko | 8 +++ .../ts-tag-var-type-void.expected.txt | 12 ++++ .../fixtures/ts-tag-var-type-void/input.marko | 2 + ...atement-void-before-statement.expected.txt | 7 +++ .../input.marko | 2 + ...ype-statement-void-before-tag.expected.txt | 8 +++ .../input.marko | 2 + ...-void-return-type-before-body.expected.txt | 10 ++++ .../input.marko | 4 ++ src/__tests__/validate.test.ts | 11 ++++ src/states/EXPRESSION.ts | 59 +++++++++++++++---- src/states/PLACEHOLDER.ts | 2 +- src/states/TAG_NAME.ts | 43 +++++++++----- src/util/validators.ts | 15 ++--- 32 files changed, 289 insertions(+), 49 deletions(-) create mode 100644 .changeset/delete-unary-keyword.md create mode 100644 .changeset/empty-placeholder-error.md create mode 100644 .changeset/trailing-non-null-assertion.md create mode 100644 .changeset/type-ends-at-void.md create mode 100644 .changeset/valid-type-statement.md create mode 100644 src/__tests__/fixtures/attr-value-delete/__snapshots__/attr-value-delete.expected.txt create mode 100644 src/__tests__/fixtures/attr-value-delete/input.marko create mode 100644 src/__tests__/fixtures/attr-value-non-null-assertion-before-attr/__snapshots__/attr-value-non-null-assertion-before-attr.expected.txt create mode 100644 src/__tests__/fixtures/attr-value-non-null-assertion-before-attr/input.marko create mode 100644 src/__tests__/fixtures/attr-value-non-null-assertion-before-open-tag-end/__snapshots__/attr-value-non-null-assertion-before-open-tag-end.expected.txt create mode 100644 src/__tests__/fixtures/attr-value-non-null-assertion-before-open-tag-end/input.marko create mode 100644 src/__tests__/fixtures/attr-value-non-null-assertion-before-self-close/__snapshots__/attr-value-non-null-assertion-before-self-close.expected.txt create mode 100644 src/__tests__/fixtures/attr-value-non-null-assertion-before-self-close/input.marko create mode 100644 src/__tests__/fixtures/non-null-assertion-at-eol/__snapshots__/non-null-assertion-at-eol.expected.txt create mode 100644 src/__tests__/fixtures/non-null-assertion-at-eol/input.marko create mode 100644 src/__tests__/fixtures/prefix-not-after-keyword-operator/__snapshots__/prefix-not-after-keyword-operator.expected.txt create mode 100644 src/__tests__/fixtures/prefix-not-after-keyword-operator/input.marko create mode 100644 src/__tests__/fixtures/ts-tag-var-type-void/__snapshots__/ts-tag-var-type-void.expected.txt create mode 100644 src/__tests__/fixtures/ts-tag-var-type-void/input.marko create mode 100644 src/__tests__/fixtures/ts-type-statement-void-before-statement/__snapshots__/ts-type-statement-void-before-statement.expected.txt create mode 100644 src/__tests__/fixtures/ts-type-statement-void-before-statement/input.marko create mode 100644 src/__tests__/fixtures/ts-type-statement-void-before-tag/__snapshots__/ts-type-statement-void-before-tag.expected.txt create mode 100644 src/__tests__/fixtures/ts-type-statement-void-before-tag/input.marko create mode 100644 src/__tests__/fixtures/ts-void-return-type-before-body/__snapshots__/ts-void-return-type-before-body.expected.txt create mode 100644 src/__tests__/fixtures/ts-void-return-type-before-body/input.marko diff --git a/.changeset/delete-unary-keyword.md b/.changeset/delete-unary-keyword.md new file mode 100644 index 00000000..0e68726d --- /dev/null +++ b/.changeset/delete-unary-keyword.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": minor +--- + +Read `delete` as a prefix operator, like `typeof` and `void`, so `
` is one attribute value and a statement ending in `delete` continues onto the next line. diff --git a/.changeset/empty-placeholder-error.md b/.changeset/empty-placeholder-error.md new file mode 100644 index 00000000..e90cb973 --- /dev/null +++ b/.changeset/empty-placeholder-error.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": minor +--- + +An empty placeholder (`${}`) now stops at its error instead of also emitting an `onPlaceholder` whose end is past the input. diff --git a/.changeset/trailing-non-null-assertion.md b/.changeset/trailing-non-null-assertion.md new file mode 100644 index 00000000..c6461073 --- /dev/null +++ b/.changeset/trailing-non-null-assertion.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": minor +--- + +Treat a trailing TypeScript non-null assertion directly after an operand (`x!`, `f()!`, `a[0]!`) as postfix, so it ends an unenclosed attribute, tag variable or statement value. `
` now reports two attributes rather than one value `x! id="a"`, and `
` no longer fails as an unterminated regular expression. diff --git a/.changeset/type-ends-at-void.md b/.changeset/type-ends-at-void.md new file mode 100644 index 00000000..2c9a1271 --- /dev/null +++ b/.changeset/type-ends-at-void.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": minor +--- + +End a type at a line-final `void`. A type statement such as `export type H = () => void` carried on into the next line, and a `void` return type before `{` (`function foo(): void {`) read the body as a type; `void` inside a type is now always the `void` type rather than a prefix operator. diff --git a/.changeset/valid-type-statement.md b/.changeset/valid-type-statement.md new file mode 100644 index 00000000..07380ca6 --- /dev/null +++ b/.changeset/valid-type-statement.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": minor +--- + +`isValidStatement` now reads a statement starting with `type `, `interface ` or `declare ` as a type, as the parser does, so a multi-line type argument list is `enclosed` and a type followed by an unindented line is `invalid`. diff --git a/src/__tests__/fixtures/attr-operators-space-after/__snapshots__/attr-operators-space-after.expected.txt b/src/__tests__/fixtures/attr-operators-space-after/__snapshots__/attr-operators-space-after.expected.txt index 0da82f15..e0be3d07 100644 --- a/src/__tests__/fixtures/attr-operators-space-after/__snapshots__/attr-operators-space-after.expected.txt +++ b/src/__tests__/fixtures/attr-operators-space-after/__snapshots__/attr-operators-space-after.expected.txt @@ -5,9 +5,10 @@ │ │╰─ attrName ╰─ ╰─ tagName 2╭─ a=x! y a - │ │││ ╰─ attrName - │ ││╰─ attrValue.value "x! y" - │ │├─ attrValue "=x! y" + │ │││ │ ╰─ attrName + │ │││ ╰─ attrName + │ ││╰─ attrValue.value "x!" + │ │├─ attrValue "=x!" │ │╰─ attrName │ ├─ closeTagEnd(a) │ ├─ openTagEnd @@ -368,10 +369,11 @@ │ ├─ closeTagEnd(a) ╰─ ╰─ openTagStart 48╭─ - │ ││││ │╰─ openTagEnd:selfClosed "/>" - │ ││││ ╰─ attrName - │ │││╰─ attrValue.value "x! y" - │ ││├─ attrValue "=x! y" + │ ││││ │ │╰─ openTagEnd:selfClosed "/>" + │ ││││ │ ╰─ attrName + │ ││││ ╰─ attrName + │ │││╰─ attrValue.value "x!" + │ ││├─ attrValue "=x!" │ ││╰─ attrName │ │╰─ tagName ╰─ ╰─ openTagStart diff --git a/src/__tests__/fixtures/attr-value-delete/__snapshots__/attr-value-delete.expected.txt b/src/__tests__/fixtures/attr-value-delete/__snapshots__/attr-value-delete.expected.txt new file mode 100644 index 00000000..4d3259d2 --- /dev/null +++ b/src/__tests__/fixtures/attr-value-delete/__snapshots__/attr-value-delete.expected.txt @@ -0,0 +1,16 @@ +1╭─
+ │ ││ │││ │││ │╰─ openTagEnd:selfClosed "/>" + │ ││ │││ │││ ╰─ attrName + │ ││ │││ ││╰─ attrValue.value "delete! y" + │ ││ │││ │╰─ attrValue "=delete! y" + │ ││ │││ ╰─ attrName + │ ││ ││╰─ attrValue.value "delete x.y" + │ ││ │╰─ attrValue "=delete x.y" + │ ││ ╰─ attrName + │ │╰─ tagName "div" + ╰─ ╰─ openTagStart +2╭─ static const d = delete + ╰─ ╰─ tagName "static" +3├─ e.f +4╭─ + ╰─ ╰─ openTagEnd \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-value-delete/input.marko b/src/__tests__/fixtures/attr-value-delete/input.marko new file mode 100644 index 00000000..abf04526 --- /dev/null +++ b/src/__tests__/fixtures/attr-value-delete/input.marko @@ -0,0 +1,3 @@ +
+static const d = delete +e.f diff --git a/src/__tests__/fixtures/attr-value-non-null-assertion-before-attr/__snapshots__/attr-value-non-null-assertion-before-attr.expected.txt b/src/__tests__/fixtures/attr-value-non-null-assertion-before-attr/__snapshots__/attr-value-non-null-assertion-before-attr.expected.txt new file mode 100644 index 00000000..711daead --- /dev/null +++ b/src/__tests__/fixtures/attr-value-non-null-assertion-before-attr/__snapshots__/attr-value-non-null-assertion-before-attr.expected.txt @@ -0,0 +1,11 @@ +1╭─
+ │ ││ │ ││ │ ││ ╰─ openTagEnd:selfClosed "/>" + │ ││ │ ││ │ │╰─ attrValue.value "\"a\"" + │ ││ │ ││ │ ╰─ attrValue "=\"a\"" + │ ││ │ ││ ╰─ attrName "id" + │ ││ │ │╰─ attrValue.value "input.x!" + │ ││ │ ╰─ attrValue "=input.x!" + │ ││ ╰─ attrName "title" + │ │╰─ tagName "div" + ╰─ ╰─ openTagStart +2╰─ \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-value-non-null-assertion-before-attr/input.marko b/src/__tests__/fixtures/attr-value-non-null-assertion-before-attr/input.marko new file mode 100644 index 00000000..5da827b5 --- /dev/null +++ b/src/__tests__/fixtures/attr-value-non-null-assertion-before-attr/input.marko @@ -0,0 +1 @@ +
diff --git a/src/__tests__/fixtures/attr-value-non-null-assertion-before-open-tag-end/__snapshots__/attr-value-non-null-assertion-before-open-tag-end.expected.txt b/src/__tests__/fixtures/attr-value-non-null-assertion-before-open-tag-end/__snapshots__/attr-value-non-null-assertion-before-open-tag-end.expected.txt new file mode 100644 index 00000000..9636cc45 --- /dev/null +++ b/src/__tests__/fixtures/attr-value-non-null-assertion-before-open-tag-end/__snapshots__/attr-value-non-null-assertion-before-open-tag-end.expected.txt @@ -0,0 +1,15 @@ +1╭─ + │ ││ ││││ ╰─ openTagEnd + │ ││ │││╰─ attrValue.value "input.x!" + │ ││ ││├─ attrValue "=input.x!" + │ ││ ││╰─ attrName + │ ││ │╰─ tagVar.value + │ ││ ╰─ tagVar "/z" + │ │╰─ tagName "const" + ╰─ ╰─ openTagStart +2╭─ + │ │ │ ╰─ closeTagEnd(const) + │ │ ╰─ closeTagName "const" + │ ├─ text "\n" + ╰─ ╰─ closeTagStart " + diff --git a/src/__tests__/fixtures/attr-value-non-null-assertion-before-self-close/__snapshots__/attr-value-non-null-assertion-before-self-close.expected.txt b/src/__tests__/fixtures/attr-value-non-null-assertion-before-self-close/__snapshots__/attr-value-non-null-assertion-before-self-close.expected.txt new file mode 100644 index 00000000..503a7df4 --- /dev/null +++ b/src/__tests__/fixtures/attr-value-non-null-assertion-before-self-close/__snapshots__/attr-value-non-null-assertion-before-self-close.expected.txt @@ -0,0 +1,11 @@ +1╭─
+ │ ││ │││ │││ ╰─ openTagEnd:selfClosed "/>" + │ ││ │││ ││╰─ attrValue.value "f()!" + │ ││ │││ │╰─ attrValue "=f()!" + │ ││ │││ ╰─ attrName + │ ││ ││╰─ attrValue.value "a[0]!" + │ ││ │╰─ attrValue "=a[0]!" + │ ││ ╰─ attrName + │ │╰─ tagName "div" + ╰─ ╰─ openTagStart +2╰─ \ No newline at end of file diff --git a/src/__tests__/fixtures/attr-value-non-null-assertion-before-self-close/input.marko b/src/__tests__/fixtures/attr-value-non-null-assertion-before-self-close/input.marko new file mode 100644 index 00000000..f1f82ef6 --- /dev/null +++ b/src/__tests__/fixtures/attr-value-non-null-assertion-before-self-close/input.marko @@ -0,0 +1 @@ +
diff --git a/src/__tests__/fixtures/non-null-assertion-at-eol/__snapshots__/non-null-assertion-at-eol.expected.txt b/src/__tests__/fixtures/non-null-assertion-at-eol/__snapshots__/non-null-assertion-at-eol.expected.txt new file mode 100644 index 00000000..174570ee --- /dev/null +++ b/src/__tests__/fixtures/non-null-assertion-at-eol/__snapshots__/non-null-assertion-at-eol.expected.txt @@ -0,0 +1,15 @@ +1╭─ static const a = b! + ╰─ ╰─ tagName "static" +2╭─ div title=input.x! + │ │ │ │╰─ attrValue.value "input.x!" + │ │ │ ╰─ attrValue "=input.x!" + │ │ ╰─ attrName "title" + │ ├─ openTagEnd + ╰─ ╰─ tagName "div" +3╭─ span + │ │ ╰─ tagName "span" + ╰─ ╰─ openTagEnd +4╭─ + │ ├─ openTagEnd + │ ├─ closeTagEnd(div) + ╰─ ╰─ closeTagEnd(span) \ No newline at end of file diff --git a/src/__tests__/fixtures/non-null-assertion-at-eol/input.marko b/src/__tests__/fixtures/non-null-assertion-at-eol/input.marko new file mode 100644 index 00000000..05fa8067 --- /dev/null +++ b/src/__tests__/fixtures/non-null-assertion-at-eol/input.marko @@ -0,0 +1,3 @@ +static const a = b! +div title=input.x! + span diff --git a/src/__tests__/fixtures/placeholder-empty/__snapshots__/placeholder-empty.expected.txt b/src/__tests__/fixtures/placeholder-empty/__snapshots__/placeholder-empty.expected.txt index 61314626..d23c7ba3 100644 --- a/src/__tests__/fixtures/placeholder-empty/__snapshots__/placeholder-empty.expected.txt +++ b/src/__tests__/fixtures/placeholder-empty/__snapshots__/placeholder-empty.expected.txt @@ -1,7 +1,5 @@ 1╭─
${}
- │ ││ ││ ├─ placeholder:escape.value "}
" - │ ││ ││ ╰─ error(MALFORMED_PLACEHOLDER:Invalid placeholder, the expression cannot be missing) - │ ││ │╰─ placeholder:escape "${}
" + │ ││ │ ╰─ error(MALFORMED_PLACEHOLDER:Invalid placeholder, the expression cannot be missing) │ ││ ╰─ openTagEnd │ │╰─ tagName "div" ╰─ ╰─ openTagStart \ No newline at end of file diff --git a/src/__tests__/fixtures/prefix-not-after-keyword-operator/__snapshots__/prefix-not-after-keyword-operator.expected.txt b/src/__tests__/fixtures/prefix-not-after-keyword-operator/__snapshots__/prefix-not-after-keyword-operator.expected.txt new file mode 100644 index 00000000..daeae6ab --- /dev/null +++ b/src/__tests__/fixtures/prefix-not-after-keyword-operator/__snapshots__/prefix-not-after-keyword-operator.expected.txt @@ -0,0 +1,30 @@ +1╭─ static const a = typeof! + ╰─ ╰─ tagName "static" +2├─ b +3╭─ static const c = d in! + │ ├─ openTagEnd + ╰─ ╰─ tagName "static" +4├─ e +5╭─ static const f = g instanceof! + │ ├─ openTagEnd + ╰─ ╰─ tagName "static" +6├─ h +7╭─ static const i = main! + │ ├─ openTagEnd + ╰─ ╰─ tagName "static" +8╭─
+ │ ││ │││ │││ │││ │╰─ openTagEnd:selfClosed "/>" + │ ││ │││ │││ │││ ╰─ attrName + │ ││ │││ │││ ││╰─ attrValue.value "input.new!" + │ ││ │││ │││ │╰─ attrValue "=input.new!" + │ ││ │││ │││ ╰─ attrName + │ ││ │││ ││╰─ attrValue.value "void! y" + │ ││ │││ │╰─ attrValue "=void! y" + │ ││ │││ ╰─ attrName + │ ││ ││╰─ attrValue.value "typeof! x" + │ ││ │╰─ attrValue "=typeof! x" + │ ││ ╰─ attrName + │ │╰─ tagName "div" + │ ├─ openTagEnd + ╰─ ╰─ openTagStart +9╰─ \ No newline at end of file diff --git a/src/__tests__/fixtures/prefix-not-after-keyword-operator/input.marko b/src/__tests__/fixtures/prefix-not-after-keyword-operator/input.marko new file mode 100644 index 00000000..77e17679 --- /dev/null +++ b/src/__tests__/fixtures/prefix-not-after-keyword-operator/input.marko @@ -0,0 +1,8 @@ +static const a = typeof! +b +static const c = d in! +e +static const f = g instanceof! +h +static const i = main! +
diff --git a/src/__tests__/fixtures/ts-tag-var-type-void/__snapshots__/ts-tag-var-type-void.expected.txt b/src/__tests__/fixtures/ts-tag-var-type-void/__snapshots__/ts-tag-var-type-void.expected.txt new file mode 100644 index 00000000..a4905fee --- /dev/null +++ b/src/__tests__/fixtures/ts-tag-var-type-void/__snapshots__/ts-tag-var-type-void.expected.txt @@ -0,0 +1,12 @@ +1╭─ + │ ││ ││ ╰─ openTagEnd + │ ││ │╰─ tagVar.value "x: void" + │ ││ ╰─ tagVar "/x: void" + │ │╰─ tagName "let" + ╰─ ╰─ openTagStart +2╭─ + │ │ │ ╰─ closeTagEnd(let) + │ │ ╰─ closeTagName "let" + │ ├─ text "\n" + ╰─ ╰─ closeTagStart " + diff --git a/src/__tests__/fixtures/ts-type-statement-void-before-statement/__snapshots__/ts-type-statement-void-before-statement.expected.txt b/src/__tests__/fixtures/ts-type-statement-void-before-statement/__snapshots__/ts-type-statement-void-before-statement.expected.txt new file mode 100644 index 00000000..039ab0e8 --- /dev/null +++ b/src/__tests__/fixtures/ts-type-statement-void-before-statement/__snapshots__/ts-type-statement-void-before-statement.expected.txt @@ -0,0 +1,7 @@ +1╭─ static type F = () => void + ╰─ ╰─ tagName "static" +2╭─ static const x = 1 + │ ├─ openTagEnd + ╰─ ╰─ tagName "static" +3╭─ + ╰─ ╰─ openTagEnd \ No newline at end of file diff --git a/src/__tests__/fixtures/ts-type-statement-void-before-statement/input.marko b/src/__tests__/fixtures/ts-type-statement-void-before-statement/input.marko new file mode 100644 index 00000000..e68705b7 --- /dev/null +++ b/src/__tests__/fixtures/ts-type-statement-void-before-statement/input.marko @@ -0,0 +1,2 @@ +static type F = () => void +static const x = 1 diff --git a/src/__tests__/fixtures/ts-type-statement-void-before-tag/__snapshots__/ts-type-statement-void-before-tag.expected.txt b/src/__tests__/fixtures/ts-type-statement-void-before-tag/__snapshots__/ts-type-statement-void-before-tag.expected.txt new file mode 100644 index 00000000..29af8c7a --- /dev/null +++ b/src/__tests__/fixtures/ts-type-statement-void-before-tag/__snapshots__/ts-type-statement-void-before-tag.expected.txt @@ -0,0 +1,8 @@ +1╭─ export type H = (v: string) => void + ╰─ ╰─ tagName "export" +2╭─
+ │ ││ ╰─ openTagEnd:selfClosed "/>" + │ │╰─ tagName "div" + │ ├─ openTagEnd + ╰─ ╰─ openTagStart +3╰─ \ No newline at end of file diff --git a/src/__tests__/fixtures/ts-type-statement-void-before-tag/input.marko b/src/__tests__/fixtures/ts-type-statement-void-before-tag/input.marko new file mode 100644 index 00000000..8a192ee0 --- /dev/null +++ b/src/__tests__/fixtures/ts-type-statement-void-before-tag/input.marko @@ -0,0 +1,2 @@ +export type H = (v: string) => void +
diff --git a/src/__tests__/fixtures/ts-void-return-type-before-body/__snapshots__/ts-void-return-type-before-body.expected.txt b/src/__tests__/fixtures/ts-void-return-type-before-body/__snapshots__/ts-void-return-type-before-body.expected.txt new file mode 100644 index 00000000..5b679656 --- /dev/null +++ b/src/__tests__/fixtures/ts-void-return-type-before-body/__snapshots__/ts-void-return-type-before-body.expected.txt @@ -0,0 +1,10 @@ +1╭─ static function foo(): void { + ╰─ ╰─ tagName "static" +2├─ if (a < b) {} +3├─ } +4╭─
+ │ ││ ╰─ openTagEnd:selfClosed "/>" + │ │╰─ tagName "div" + │ ├─ openTagEnd + ╰─ ╰─ openTagStart +5╰─ \ No newline at end of file diff --git a/src/__tests__/fixtures/ts-void-return-type-before-body/input.marko b/src/__tests__/fixtures/ts-void-return-type-before-body/input.marko new file mode 100644 index 00000000..d18db89f --- /dev/null +++ b/src/__tests__/fixtures/ts-void-return-type-before-body/input.marko @@ -0,0 +1,4 @@ +static function foo(): void { + if (a < b) {} +} +
diff --git a/src/__tests__/validate.test.ts b/src/__tests__/validate.test.ts index 9c4c471d..90fc0ed6 100644 --- a/src/__tests__/validate.test.ts +++ b/src/__tests__/validate.test.ts @@ -40,6 +40,17 @@ describe("validation helpers", () => { it("treats newlines in enclosed template literals as guarded", () => { assert.equal(isValidStatement("(`foo\nbar`)"), 2); }); + + it("reads type arguments in a type statement as a group", () => { + assert.equal( + isValidStatement("type A = Record<\n string,\n number\n>"), + 2, + ); + }); + + it("ends a type statement at an unindented newline", () => { + assert.equal(isValidStatement("type A = B\nfoo"), 0); + }); }); describe("isValidScriptlet", () => { diff --git a/src/states/EXPRESSION.ts b/src/states/EXPRESSION.ts index 2f9fd156..c3fd96fe 100644 --- a/src/states/EXPRESSION.ts +++ b/src/states/EXPRESSION.ts @@ -39,9 +39,13 @@ const unaryKeywords = [ "function", "new", "typeof", - "void", ] as const; +// Only JavaScript has `delete` and `void` operators; in a type `void` is the +// `void` type. `return`, `throw` and `yield` are left out: JavaScript allows +// no line break after them, so they never continue an expression. +const jsUnaryKeywords = [...unaryKeywords, "delete", "void"] as const; + const tsUnaryKeywords = [ ...unaryKeywords, "asserts", @@ -60,6 +64,8 @@ const binaryKeywords = [ "satisfies", ] as const; +const relationalKeywords = ["in", "instanceof"] as const; + export const EXPRESSION: StateDefinition = { name: "EXPRESSION", @@ -469,7 +475,6 @@ function lookBehindForOperator( case CODE.CARET: case CODE.COLON: case CODE.EQUAL: - case CODE.EXCLAMATION: case CODE.OPEN_ANGLE_BRACKET: case CODE.PERCENT: case CODE.PIPE: @@ -477,6 +482,23 @@ function lookBehindForOperator( case CODE.TILDE: return curPos; + case CODE.EXCLAMATION: { + // After an operand, `!` is a TypeScript non-null assertion (postfix); + // after a keyword operator (`typeof!a`, `a in!b`) it is the prefix `!`. + const prevCode = data.charCodeAt(curPos - 1); + switch (prevCode) { + case CODE.CLOSE_PAREN: + case CODE.CLOSE_SQUARE_BRACKET: + return -1; + default: + return isWordCode(prevCode) && + lookBehindForOperator(expression, data, curPos) === -1 && + lookBehindForKeyword(data, curPos - 1, relationalKeywords) === -1 + ? -1 + : curPos; + } + } + case CODE.CLOSE_ANGLE_BRACKET: return data.charCodeAt(curPos - 1) === CODE.EQUAL ? curPos - 1 @@ -511,17 +533,11 @@ function lookBehindForOperator( // before `pos` is not one, no keyword can match. if (code < CODE.LOWER_A || code > CODE.LOWER_Z) return -1; - for (const keyword of expression.inType - ? tsUnaryKeywords - : unaryKeywords) { - const keywordPos = lookBehindFor(data, curPos, keyword); - if (keywordPos !== -1) { - return isWordOrPeriodCode(data.charCodeAt(keywordPos - 1)) - ? -1 - : keywordPos; - } - } - return -1; + return lookBehindForKeyword( + data, + curPos, + expression.inType ? tsUnaryKeywords : jsUnaryKeywords, + ); } } } @@ -654,6 +670,23 @@ function lookBehindWhile( return 0; } +// Returns where a whole keyword ending at `pos` starts, or -1. +function lookBehindForKeyword( + data: string, + pos: number, + keywords: readonly string[], +) { + for (const keyword of keywords) { + const keywordPos = lookBehindFor(data, pos, keyword); + if (keywordPos !== -1) { + return isWordOrPeriodCode(data.charCodeAt(keywordPos - 1)) + ? -1 + : keywordPos; + } + } + return -1; +} + function lookBehindFor(data: string, pos: number, str: string) { let i = str.length; const endPos = pos - i + 1; diff --git a/src/states/PLACEHOLDER.ts b/src/states/PLACEHOLDER.ts index 750aa2f2..082687d4 100644 --- a/src/states/PLACEHOLDER.ts +++ b/src/states/PLACEHOLDER.ts @@ -43,7 +43,7 @@ export const PLACEHOLDER: StateDefinition = { return(child) { if (child.start === child.end) { - this.emitError( + return this.emitError( child, ErrorCode.MALFORMED_PLACEHOLDER, "Invalid placeholder, the expression cannot be missing", diff --git a/src/states/TAG_NAME.ts b/src/states/TAG_NAME.ts index 13272e52..13a768c9 100644 --- a/src/states/TAG_NAME.ts +++ b/src/states/TAG_NAME.ts @@ -2,6 +2,7 @@ import { isWhitespaceCode, matchesCloseCurlyBrace, type Meta, + Parser, type Ranges, STATE, type StateDefinition, @@ -96,21 +97,11 @@ export const TAG_NAME: StateDefinition = { ); } - const expr = this.enterState(STATE.EXPRESSION); - expr.operators = true; - expr.terminatedByEOL = true; - expr.consumeIndentedContent = true; - - const typeStatementMatch = - this.lookAheadFor("declare ") || - this.lookAheadFor("interface ") || - this.lookAheadFor("type "); - if (typeStatementMatch) { - expr.inType = true; - expr.forceType = true; - this.pos += typeStatementMatch.length; - this.consumeWhitespace(); - } + prepareStatement( + this.enterState(STATE.EXPRESSION), + this, + this.pos + 1, + ); } } @@ -197,3 +188,25 @@ export const TAG_NAME: StateDefinition = { quasis.push({ start: nextStart, end: nextStart }); }, }; + +// Sets up a root statement's expression, whose code starts at `pos`. +export function prepareStatement( + expr: STATE.ExpressionMeta, + parser: Parser, + pos = parser.pos, +) { + expr.operators = true; + expr.terminatedByEOL = true; + expr.consumeIndentedContent = true; + + const typeStatementMatch = + parser.lookAheadFor("declare ", pos) || + parser.lookAheadFor("interface ", pos) || + parser.lookAheadFor("type ", pos); + if (typeStatementMatch) { + expr.inType = true; + expr.forceType = true; + parser.pos = pos + typeStatementMatch.length; + parser.consumeWhitespace(); + } +} diff --git a/src/util/validators.ts b/src/util/validators.ts index 740970b6..40dcab06 100644 --- a/src/util/validators.ts +++ b/src/util/validators.ts @@ -1,5 +1,6 @@ import { type Meta, Parser, STATE, type StateDefinition } from "../internal.ts"; import { + prepareStatement, shouldTerminateConciseAttrValue, shouldTerminateHtmlAttrValue, } from "../states/index.ts"; @@ -33,12 +34,6 @@ export function isValidStatement(code: string): Validity { return isValid(code, true, prepareStatement); } -function prepareStatement(expr: STATE.ExpressionMeta) { - expr.operators = true; - expr.terminatedByEOL = true; - expr.consumeIndentedContent = true; -} - export function isValidScriptlet(code: string): Validity { return isValid(code, true, prepareScriptlet); } @@ -52,10 +47,10 @@ export function isValidAttrValue(code: string, concise: boolean): Validity { return isValid(code, concise, prepareAttrValue); } -function prepareAttrValue(expr: STATE.ExpressionMeta, concise: boolean) { +function prepareAttrValue(expr: STATE.ExpressionMeta, parser: Parser) { expr.operators = true; expr.terminatedByWhitespace = true; - expr.shouldTerminate = concise + expr.shouldTerminate = parser.isConcise ? shouldTerminateConciseAttrValue : shouldTerminateHtmlAttrValue; } @@ -63,7 +58,7 @@ function prepareAttrValue(expr: STATE.ExpressionMeta, concise: boolean) { function isValid( data: string, concise: boolean, - prepare: (expr: STATE.ExpressionMeta, concise: boolean) => void, + prepare: (expr: STATE.ExpressionMeta, parser: Parser) => void, ): Validity { let hasError = false; const parser = new Parser({ @@ -85,7 +80,7 @@ function isValid( parser.activeState = ROOT_STATE; parser.activeRange = ROOT_RANGE; const expr = parser.enterState(STATE.EXPRESSION); - prepare(expr, concise); + prepare(expr, parser); while (parser.pos <= maxPos) { const childActive = parser.activeRange !== expr; From c82177190533a8b03c46af12616c0fb89e6bd9ed Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Mon, 28 Sep 2026 09:13:59 -0700 Subject: [PATCH 2/2] docs: count only changes to correctly handled input as breaking A misparse fix changes events only for input that errored or compiled to the wrong code, so it ships as a feature. Raising the parser floor in the compiler and prettier-plugin-marko together keeps them agreeing on which values need parentheses. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f6cda945..5277f11f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ An HTML parser recognizes content and string placeholders and allows JavaScript ## Conventions -When the parser misreports a structure, fix it here rather than working around it in prettier, the compiler, or language-tools. Changing which events existing input produces breaks every consumer: treat it as a breaking change. +When the parser misreports a structure, fix it here rather than working around it in prettier, the compiler, or language-tools. Changing the events for input that already parses and compiles correctly breaks every consumer: treat it as a breaking change. Fixing a misparse (input that errored, or compiled to the wrong code) is a feature, not a breaking change: release it as a minor, and raise the htmljs-parser floor in the compiler and prettier-plugin-marko together, since both decide what is valid with this parser. ## Agent feedback