@tsrx/core 0.1.48 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Core compiler infrastructure for TSRX syntax",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.1.48",
6
+ "version": "0.1.50",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
package/src/index.js CHANGED
@@ -204,7 +204,6 @@ export {
204
204
  is_component_jsx_name,
205
205
  is_jsx_child,
206
206
  set_loc,
207
- to_text_expression,
208
207
  } from './transform/jsx/ast-builders.js';
209
208
  export {
210
209
  render_stylesheets as renderStylesheets,
package/src/plugin.js CHANGED
@@ -273,6 +273,8 @@ export function TSRXPlugin(config) {
273
273
  #controlFlowBlockAllowsNativeReturn = false;
274
274
  #parsingJSXSwitchCaseScriptStatementDepth = 0;
275
275
  #templateControlFlowBlockDepth = 0;
276
+ /** @type {AST.NodeWithLocation | null} */
277
+ #lastClauseKeywordSpan = null;
276
278
  #templateControlFlowTryDepth = 0;
277
279
  /** @type {Parse.Parser['context']} */
278
280
  context = [b_stat];
@@ -1518,43 +1520,57 @@ export function TSRXPlugin(config) {
1518
1520
  const previous_reading_header = this.#readingJSXControlFlowHeader;
1519
1521
  this.#readingJSXControlFlowHeader = true;
1520
1522
  try {
1521
- node = this.#finishJSXControlFlowExpression(
1522
- this.parseStatement(null),
1523
- 'JSXForExpression',
1524
- start,
1525
- startLoc,
1523
+ node = /** @type {AST.JSXForExpression} */ (
1524
+ this.#finishJSXControlFlowExpression(
1525
+ this.parseStatement(null),
1526
+ 'JSXForExpression',
1527
+ start,
1528
+ startLoc,
1529
+ )
1526
1530
  );
1527
1531
  } finally {
1528
1532
  this.#readingJSXControlFlowHeader = previous_reading_header;
1529
1533
  this.#templateControlFlowBlockDepth--;
1530
1534
  }
1531
1535
  if (
1532
- /** @type {any} */ (node).statementType !== 'ForOfStatement' &&
1533
- /** @type {any} */ (node).statementType !== 'ForInStatement' &&
1534
- /** @type {any} */ (node).statementType !== 'ForStatement'
1536
+ node.statementType !== 'ForOfStatement' &&
1537
+ node.statementType !== 'ForInStatement' &&
1538
+ node.statementType !== 'ForStatement'
1535
1539
  ) {
1536
1540
  this.raise(start, 'Expected `for` after `@`.');
1537
1541
  }
1538
- if (/** @type {any} */ (node).body?.type !== 'BlockStatement') {
1539
- this.raise(
1540
- /** @type {any} */ (node).body?.start ?? start,
1541
- 'Expected `{` after JSX control-flow directive.',
1542
- );
1542
+ if (node.body?.type !== 'BlockStatement') {
1543
+ this.raise(node.body?.start ?? start, 'Expected `{` after JSX control-flow directive.');
1543
1544
  }
1544
1545
  if (this.#eatJSXForEmptyKeyword()) {
1545
1546
  if (this.type !== tt.braceL) {
1546
1547
  this.raise(this.start, 'Expected `{` after JSX control-flow directive.');
1547
1548
  }
1549
+ const emptyKeyword = this.#lastClauseKeywordSpan;
1550
+ let empty;
1548
1551
  this.#templateControlFlowBlockDepth++;
1549
1552
  try {
1550
- /** @type {any} */ (node).empty = this.parseBlock();
1553
+ empty = this.parseBlock();
1551
1554
  } finally {
1552
1555
  this.#templateControlFlowBlockDepth--;
1553
1556
  }
1557
+ node.empty = empty;
1558
+ node.emptyKeyword = emptyKeyword;
1559
+ // `@empty { … }` is part of the `@for` statement, but the node was
1560
+ // already finished at the end of the for BODY (the clause is parsed
1561
+ // after `#finishJSXControlFlowExpression`, unlike `@else`/`@catch`,
1562
+ // which their own `parseStatement` consumes first). Extend it, or
1563
+ // every consumer that slices by range — editor mappings, the
1564
+ // playground's AST/position tracking, formatters, diagnostics —
1565
+ // truncates the statement before its `@empty` clause.
1566
+ node.end = empty.end;
1567
+ /** @type {AST.NodeWithLocation} */ (node).loc.end =
1568
+ /** @type {AST.NodeWithLocation} */ (empty).loc.end;
1569
+ if (node.range) node.range[1] = /** @type {number} */ (empty.end);
1554
1570
  } else if (this.#isUnprefixedDirectiveClauseContinuation('empty', ['{'])) {
1555
1571
  this.raise(this.start, 'Expected `@empty` after `@for` block.');
1556
1572
  } else {
1557
- /** @type {any} */ (node).empty = null;
1573
+ node.empty = null;
1558
1574
  }
1559
1575
  return node;
1560
1576
  }
@@ -1584,6 +1600,7 @@ export function TSRXPlugin(config) {
1584
1600
  * @param {string} keyword
1585
1601
  */
1586
1602
  #eatJSXDirectiveClauseKeyword(keyword) {
1603
+ this.#lastClauseKeywordSpan = null;
1587
1604
  const keywordStart = skip_whitespace_from(this.input, this.start);
1588
1605
  if (this.input.charCodeAt(keywordStart) !== CharCode.at) {
1589
1606
  return false;
@@ -1596,6 +1613,19 @@ export function TSRXPlugin(config) {
1596
1613
  return false;
1597
1614
  }
1598
1615
 
1616
+ // The clause keyword is the only authored spelling of `@empty`/`@else`/
1617
+ // `@catch` and friends: the clause node itself starts at its `{`, so
1618
+ // without this span nothing in the tree points at the keyword and
1619
+ // tooling cannot resolve a cursor placed on it.
1620
+ const keywordEnd = wordStart + keyword.length;
1621
+ this.#lastClauseKeywordSpan = {
1622
+ start: keywordStart,
1623
+ end: keywordEnd,
1624
+ loc: {
1625
+ start: acorn.getLineInfo(this.input, keywordStart),
1626
+ end: acorn.getLineInfo(this.input, keywordEnd),
1627
+ },
1628
+ };
1599
1629
  this.pos = wordStart;
1600
1630
  this.start = wordStart;
1601
1631
  this.startLoc = acorn.getLineInfo(this.input, wordStart);
@@ -1714,6 +1744,7 @@ export function TSRXPlugin(config) {
1714
1744
  node.alternate = null;
1715
1745
 
1716
1746
  if (this.#eatJSXDirectiveClauseKeyword('else')) {
1747
+ node.alternateKeyword = this.#lastClauseKeywordSpan;
1717
1748
  node.alternate = this.#eatJSXDirectiveBareClauseKeyword('if')
1718
1749
  ? this.#parseTemplateIfStatement()
1719
1750
  : /** @type {AST.Statement} */ (this.#parseTemplateControlFlowStatement());
@@ -1758,6 +1789,9 @@ export function TSRXPlugin(config) {
1758
1789
  this.startNodeAt(clauseStart, clauseStartLoc)
1759
1790
  );
1760
1791
  current.consequent = [];
1792
+ // `@case`/`@default` is the arm's only authored keyword; the node
1793
+ // itself starts before the leading whitespace.
1794
+ current.keyword = this.#lastClauseKeywordSpan;
1761
1795
  const previous_reading_header = this.#readingJSXControlFlowHeader;
1762
1796
  this.#readingJSXControlFlowHeader = true;
1763
1797
  try {
@@ -3377,11 +3411,17 @@ export function TSRXPlugin(config) {
3377
3411
  let node = /** @type {ESTreeJSX.JSXExpressionContainer} */ (this.startNode());
3378
3412
  this.#jsxExpressionContainerDepth++;
3379
3413
  let pushed_context_baseline = false;
3414
+ // The stack depth with the container's `{` brace context on top, taken
3415
+ // before `next()` so the first expression token's own context pushes
3416
+ // (e.g. a fragment's `<` pushing its tag contexts) are not counted.
3417
+ // This is the depth the stack must return to when the container's
3418
+ // closing `}` is the current token.
3419
+ const container_context_depth = this.context.length;
3380
3420
  try {
3381
3421
  this.next();
3382
3422
 
3383
- // Record the context-stack depth now that the container's `{` brace
3384
- // context is on the stack. A control-flow directive parsed inside this
3423
+ // Record the context-stack depth now that the first expression token
3424
+ // has been read. A control-flow directive parsed inside this
3385
3425
  // container must not strip anything below this floor (see
3386
3426
  // `#filterTemplateScriptContexts`).
3387
3427
  this.#expressionContainerContextBaselines.push(this.context.length);
@@ -3401,6 +3441,19 @@ export function TSRXPlugin(config) {
3401
3441
  this.next();
3402
3442
  }
3403
3443
  if (!consumeBraceAfterScope) {
3444
+ // A control-flow directive expression restores the context stack from
3445
+ // a snapshot taken inside this container
3446
+ // (`#parseTemplateControlFlowBlock`), so the container's closing `}`
3447
+ // — read while that stale snapshot was active — pops the wrong entry
3448
+ // and leaves stale contexts above the enclosing tag's contexts (the
3449
+ // directive's statement brace, or a wrapping fragment's child
3450
+ // context). Once the `}` has been read the stack must be back at one
3451
+ // below the container's depth (its own brace context popped); drop
3452
+ // anything above that so the token after `}` (e.g. the `>` finishing
3453
+ // the enclosing opening tag) tokenizes in the right context.
3454
+ if (this.type === tt.braceR && this.context.length >= container_context_depth) {
3455
+ this.context.length = container_context_depth - 1;
3456
+ }
3404
3457
  this.expect(tt.braceR);
3405
3458
  }
3406
3459
  } finally {
@@ -3719,6 +3772,7 @@ export function TSRXPlugin(config) {
3719
3772
  node.handler = null;
3720
3773
 
3721
3774
  if (this.#eatJSXDirectiveClauseKeyword('pending')) {
3775
+ node.pendingKeyword = this.#lastClauseKeywordSpan;
3722
3776
  node.pending = this.#parseTemplateControlFlowReturnBlock();
3723
3777
  } else if (this.#isUnprefixedDirectiveClauseContinuation('pending', ['{'])) {
3724
3778
  this.raise(this.start, 'Expected `@pending` after `@try` block.');
@@ -3729,6 +3783,7 @@ export function TSRXPlugin(config) {
3729
3783
  const clauseStart = this.start;
3730
3784
  const clauseStartLoc = this.startLoc;
3731
3785
  if (this.#eatJSXDirectiveClauseKeyword('catch')) {
3786
+ node.handlerKeyword = this.#lastClauseKeywordSpan;
3732
3787
  if (this.type === tt._catch || this.value === 'catch') {
3733
3788
  this.next();
3734
3789
  }
@@ -1,6 +1,10 @@
1
+ /** @import * as AST from 'estree' */
2
+
3
+ import { child_nodes, is_ast_node } from '../utils/ast.js';
4
+
1
5
  /**
2
- * @param {any[]} body_nodes
3
- * @returns {any | null}
6
+ * @param {AST.Node[]} body_nodes
7
+ * @returns {AST.TSRXAwaitNode | null}
4
8
  */
5
9
  export function find_first_top_level_await_in_tsrx_function_body(body_nodes) {
6
10
  for (const node of body_nodes) {
@@ -12,12 +16,12 @@ export function find_first_top_level_await_in_tsrx_function_body(body_nodes) {
12
16
  }
13
17
 
14
18
  /**
15
- * @param {any} node
19
+ * @param {AST.Node | AST.Node[] | null | undefined} node
16
20
  * @param {boolean} inside_nested_function
17
- * @returns {any | null}
21
+ * @returns {AST.TSRXAwaitNode | null}
18
22
  */
19
23
  export function find_first_top_level_await(node, inside_nested_function) {
20
- if (!node || typeof node !== 'object') {
24
+ if (!node) {
21
25
  return null;
22
26
  }
23
27
 
@@ -45,17 +49,15 @@ export function find_first_top_level_await(node, inside_nested_function) {
45
49
  if (
46
50
  node.type === 'AwaitExpression' ||
47
51
  (node.type === 'ForOfStatement' && node.await === true) ||
48
- (node.type === 'JSXForExpression' && node.await === true)
52
+ (node.type === 'JSXForExpression' &&
53
+ node.statementType === 'ForOfStatement' &&
54
+ node.await === true)
49
55
  ) {
50
56
  return node;
51
57
  }
52
58
 
53
- for (const key of Object.keys(node)) {
54
- if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') {
55
- continue;
56
- }
57
-
58
- const found = find_first_top_level_await(node[key], false);
59
+ for (const child of child_nodes(node)) {
60
+ const found = find_first_top_level_await(child, false);
59
61
  if (found) return found;
60
62
  }
61
63
 
@@ -2,7 +2,7 @@
2
2
  /** @import * as ESTreeJSX from 'estree-jsx' */
3
3
 
4
4
  import * as b from '../../utils/builders.js';
5
- import { has_location } from '../../utils/ast.js';
5
+ import { has_location, is_ast_node } from '../../utils/ast.js';
6
6
 
7
7
  /**
8
8
  * AST-building utilities shared across every JSX target (React, Preact,
@@ -16,7 +16,7 @@ import { has_location } from '../../utils/ast.js';
16
16
  *
17
17
  * @template {AST.Node} T
18
18
  * @param {T} node
19
- * @param {AST.Node | AST.NodeWithLocation | undefined} source_node
19
+ * @param {AST.Node | AST.NodeWithLocation | null | undefined} source_node
20
20
  * @returns {T}
21
21
  */
22
22
  export function set_loc(node, source_node) {
@@ -127,14 +127,6 @@ export function add_extra_source_mappings_from_matching_expression(generated, so
127
127
  }
128
128
  }
129
129
 
130
- /**
131
- * @param {unknown} value
132
- * @returns {value is AST.Node}
133
- */
134
- function is_ast_node(value) {
135
- return !!value && typeof value === 'object' && 'type' in value;
136
- }
137
-
138
130
  /**
139
131
  * @returns {AST.Literal}
140
132
  */
@@ -385,77 +377,6 @@ export function flatten_switch_consequent(consequent) {
385
377
  return result;
386
378
  }
387
379
 
388
- /**
389
- * @param {AST.Expression | null | undefined} expression
390
- * @returns {boolean}
391
- */
392
- function is_static_string_expression(expression) {
393
- if (!expression) {
394
- return false;
395
- }
396
- if (expression.type === 'Literal') {
397
- return typeof expression.value === 'string';
398
- }
399
- if (expression.type === 'TemplateLiteral') {
400
- return expression.expressions.length === 0;
401
- }
402
- return false;
403
- }
404
-
405
- /**
406
- * Build `expr == null ? '' : expr + ''` — the text-coerce form used when a
407
- * Ripple `{expr}` child must render as a string in JSX (React/Preact drop
408
- * booleans; Solid's default child semantics don't either). Solid uses this
409
- * via `to_jsx_child`; React/Preact wrap it in a JSXExpressionContainer.
410
- *
411
- * When the expression is statically a non-null string at the AST level —
412
- * a string `Literal` (`"hello"`, `'hello'`) or a `TemplateLiteral` with no
413
- * interpolations (`` `hello` ``) — the coercion is provably a no-op and
414
- * the literal is emitted as-is. Identifiers and any other expression type still
415
- * get the ternary because the AST alone can't prove they're non-null strings.
416
- *
417
- * @param {AST.Expression} expression
418
- * @param {AST.Node | AST.NodeWithLocation} [source_node]
419
- * @returns {AST.Expression}
420
- */
421
- export function to_text_expression(expression, source_node = expression) {
422
- if (is_static_string_expression(expression)) {
423
- return set_loc(clone_ast_node(expression), source_node);
424
- }
425
- return set_loc(
426
- /** @type {AST.Expression} */ ({
427
- type: 'ConditionalExpression',
428
- test: {
429
- type: 'BinaryExpression',
430
- operator: '==',
431
- left: clone_ast_node(expression),
432
- right: create_null_literal(),
433
- metadata: { path: [] },
434
- },
435
- consequent: {
436
- type: 'Literal',
437
- value: '',
438
- raw: "''",
439
- metadata: { path: [] },
440
- },
441
- alternate: {
442
- type: 'BinaryExpression',
443
- operator: '+',
444
- left: clone_ast_node(expression),
445
- right: {
446
- type: 'Literal',
447
- value: '',
448
- raw: "''",
449
- metadata: { path: [] },
450
- },
451
- metadata: { path: [] },
452
- },
453
- metadata: { path: [] },
454
- }),
455
- source_node,
456
- );
457
- }
458
-
459
380
  /**
460
381
  * Deep-clone an AST subtree.
461
382
  *
@@ -252,7 +252,7 @@ const LOCATION_WRAPPED_NODE_TYPES = new Set([
252
252
 
253
253
  /**
254
254
  * @param {AST.Node | null | undefined} node
255
- * @returns {boolean}
255
+ * @returns {node is AST.JSXIfExpression | (AST.IfStatement & { statementType: 'IfStatement' })}
256
256
  */
257
257
  export function is_template_if_node(node) {
258
258
  return (
@@ -262,19 +262,24 @@ export function is_template_if_node(node) {
262
262
  }
263
263
 
264
264
  /**
265
+ * A `@for … of …` directive, in either the expression or the retyped statement
266
+ * form. `JSXForExpression` also covers `@for … in …` and plain `@for (;;)`, so
267
+ * both arms must check `statementType` — callers read for-of-only fields such
268
+ * as `await` off the result.
269
+ *
265
270
  * @param {AST.Node | null | undefined} node
266
- * @returns {boolean}
271
+ * @returns {node is AST.JSXForOfExpression | (AST.ForOfStatement & { statementType: 'ForOfStatement' })}
267
272
  */
268
273
  export function is_template_for_of_node(node) {
269
274
  return (
270
- node?.type === 'JSXForExpression' ||
271
- (node?.type === 'ForOfStatement' && node?.statementType === 'ForOfStatement')
275
+ (node?.type === 'JSXForExpression' || node?.type === 'ForOfStatement') &&
276
+ node.statementType === 'ForOfStatement'
272
277
  );
273
278
  }
274
279
 
275
280
  /**
276
281
  * @param {AST.Node | null | undefined} node
277
- * @returns {boolean}
282
+ * @returns {node is AST.JSXSwitchExpression | (AST.SwitchStatement & { statementType: 'SwitchStatement' })}
278
283
  */
279
284
  export function is_template_switch_node(node) {
280
285
  return (
@@ -285,7 +290,7 @@ export function is_template_switch_node(node) {
285
290
 
286
291
  /**
287
292
  * @param {AST.Node | null | undefined} node
288
- * @returns {boolean}
293
+ * @returns {node is AST.JSXTryExpression | (AST.TryStatement & { statementType: 'TryStatement' })}
289
294
  */
290
295
  export function is_template_try_node(node) {
291
296
  return (