@tsrx/core 0.0.13 → 0.0.14

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.0.13",
6
+ "version": "0.0.14",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -5,6 +5,9 @@
5
5
 
6
6
  import { error } from '../errors.js';
7
7
 
8
+ export const COMPONENT_RETURN_VALUE_ERROR =
9
+ 'Return statements inside components cannot have a return value.';
10
+
8
11
  const invalid_nestings = {
9
12
  // <p> cannot contain block-level elements
10
13
  p: new Set([
@@ -125,6 +128,50 @@ function get_element_tag(element) {
125
128
  return element.id.type === 'Identifier' ? element.id.name : null;
126
129
  }
127
130
 
131
+ /**
132
+ * @param {AST.ReturnStatement} node
133
+ * @returns {AST.ReturnStatement}
134
+ */
135
+ export function get_return_keyword_node(node) {
136
+ const return_keyword_length = 'return'.length;
137
+ const start = /** @type {AST.NodeWithLocation} */ (node).start ?? 0;
138
+ const loc = /** @type {AST.NodeWithLocation} */ (node).loc;
139
+
140
+ return /** @type {AST.ReturnStatement} */ ({
141
+ ...node,
142
+ end: start + return_keyword_length,
143
+ loc: loc
144
+ ? {
145
+ start: loc.start,
146
+ end: {
147
+ line: loc.start.line,
148
+ column: loc.start.column + return_keyword_length,
149
+ },
150
+ }
151
+ : undefined,
152
+ });
153
+ }
154
+
155
+ /**
156
+ * @param {AST.ReturnStatement} node
157
+ * @param {string | null | undefined} filename
158
+ * @param {CompileError[]} [errors]
159
+ * @param {AST.CommentWithLocation[]} [comments]
160
+ */
161
+ export function validate_component_return_statement(node, filename, errors, comments) {
162
+ if (node.argument === null) {
163
+ return;
164
+ }
165
+
166
+ error(
167
+ COMPONENT_RETURN_VALUE_ERROR,
168
+ filename ?? null,
169
+ get_return_keyword_node(node),
170
+ errors,
171
+ comments,
172
+ );
173
+ }
174
+
128
175
  /**
129
176
  * @param {AST.Element} element
130
177
  * @param {AnalysisContext} context
package/src/index.js CHANGED
@@ -78,12 +78,17 @@ export {
78
78
 
79
79
  // AST utils
80
80
  export {
81
+ get_component_from_path as getComponentFromPath,
81
82
  object,
82
83
  unwrap_pattern as unwrapPattern,
83
84
  extract_identifiers as extractIdentifiers,
84
85
  extract_paths as extractPaths,
85
86
  build_fallback as buildFallback,
86
87
  build_assignment_value as buildAssignmentValue,
88
+ is_class_node as isClassNode,
89
+ is_component_node as isComponentNode,
90
+ is_function_node as isFunctionNode,
91
+ is_inside_component as isInsideComponent,
87
92
  } from './utils/ast.js';
88
93
 
89
94
  // Builders (namespace re-export — members mirror AST node kinds)
@@ -197,4 +202,9 @@ export {
197
202
 
198
203
  // Analyze
199
204
  export { analyze_css as analyzeCss } from './analyze/css-analyze.js';
200
- export { validate_nesting as validateNesting } from './analyze/validation.js';
205
+ export {
206
+ COMPONENT_RETURN_VALUE_ERROR,
207
+ get_return_keyword_node as getReturnKeywordNode,
208
+ validate_component_return_statement as validateComponentReturnStatement,
209
+ validate_nesting as validateNesting,
210
+ } from './analyze/validation.js';
package/src/plugin.js CHANGED
@@ -16,6 +16,143 @@ import {
16
16
  import { regex_newline_characters } from './utils/patterns.js';
17
17
  import { error } from './errors.js';
18
18
 
19
+ /** @type {WeakMap<Record<string, boolean>, Map<string, number>>} */
20
+ const argument_clash_first_positions = new WeakMap();
21
+ /** @type {WeakMap<Record<string, boolean>, Set<string>>} */
22
+ const argument_clash_reported_names = new WeakMap();
23
+
24
+ /**
25
+ * @param {Record<string, boolean>} check_clashes
26
+ * @returns {Map<string, number>}
27
+ */
28
+ function get_argument_clash_first_positions(check_clashes) {
29
+ let first_positions = argument_clash_first_positions.get(check_clashes);
30
+ if (!first_positions) {
31
+ first_positions = new Map();
32
+ argument_clash_first_positions.set(check_clashes, first_positions);
33
+ }
34
+ return first_positions;
35
+ }
36
+
37
+ /**
38
+ * @param {Record<string, boolean>} check_clashes
39
+ * @returns {Set<string>}
40
+ */
41
+ function get_argument_clash_reported_names(check_clashes) {
42
+ let reported_names = argument_clash_reported_names.get(check_clashes);
43
+ if (!reported_names) {
44
+ reported_names = new Set();
45
+ argument_clash_reported_names.set(check_clashes, reported_names);
46
+ }
47
+ return reported_names;
48
+ }
49
+
50
+ /**
51
+ * @param {string} input
52
+ * @param {number} i
53
+ */
54
+ function skip_whitespace_from(input, i) {
55
+ while (i < input.length) {
56
+ const ch = input.charCodeAt(i);
57
+ if (ch !== 32 && ch !== 9 && ch !== 10 && ch !== 13) break;
58
+ i++;
59
+ }
60
+ return i;
61
+ }
62
+
63
+ /**
64
+ * Skip past a string literal opened at `i` with the given quote char code.
65
+ * @param {string} input
66
+ * @param {number} i
67
+ * @param {number} quote
68
+ */
69
+ function skip_string_from(input, i, quote) {
70
+ i++;
71
+ while (i < input.length) {
72
+ const ch = input.charCodeAt(i);
73
+ i++;
74
+ if (ch === 92)
75
+ i++; // backslash escape
76
+ else if (ch === quote) return i;
77
+ }
78
+ return i;
79
+ }
80
+
81
+ /**
82
+ * Scan past a balanced pair starting at `i` (which must point at `open`).
83
+ * Returns the position after the matching close, or -1 if unbalanced.
84
+ * @param {string} input
85
+ * @param {number} i
86
+ * @param {number} open
87
+ * @param {number} close
88
+ */
89
+ function scan_balanced_from(input, i, open, close) {
90
+ let depth = 1;
91
+ i++;
92
+ while (i < input.length) {
93
+ const ch = input.charCodeAt(i);
94
+ if (ch === 34 || ch === 39 || ch === 96) {
95
+ i = skip_string_from(input, i, ch);
96
+ continue;
97
+ }
98
+ if (ch === open) depth++;
99
+ else if (ch === close && --depth === 0) return i + 1;
100
+ i++;
101
+ }
102
+ return -1;
103
+ }
104
+
105
+ /**
106
+ * Best-effort lookahead at a `<` to decide whether it starts a generic arrow
107
+ * expression — `<...>(...)[: T] => ...`. Conservative: returns false on any
108
+ * unexpected shape so JSX continues to parse as JSX.
109
+ * @param {string} input
110
+ * @param {number} pos
111
+ */
112
+ function looks_like_generic_arrow(input, pos) {
113
+ if (input.charCodeAt(pos) !== 60) return false;
114
+
115
+ // Match the angle brackets, skipping over string literals.
116
+ let i = pos + 1;
117
+ let depth = 1;
118
+ while (i < input.length) {
119
+ const ch = input.charCodeAt(i);
120
+ if (ch === 34 || ch === 39 || ch === 96) {
121
+ i = skip_string_from(input, i, ch);
122
+ continue;
123
+ }
124
+ if (ch === 60) depth++;
125
+ else if (ch === 62 && --depth === 0) break;
126
+ i++;
127
+ }
128
+ if (depth !== 0) return false;
129
+
130
+ // `>` must be followed by `(...)`.
131
+ i = skip_whitespace_from(input, i + 1);
132
+ if (input.charCodeAt(i) !== 40) return false;
133
+ i = scan_balanced_from(input, i, 40, 41);
134
+ if (i === -1) return false;
135
+
136
+ // Optional `: ReturnType` before `=>`.
137
+ i = skip_whitespace_from(input, i);
138
+ if (input.charCodeAt(i) === 58) {
139
+ i++;
140
+ while (i < input.length) {
141
+ const ch = input.charCodeAt(i);
142
+ if (ch === 34 || ch === 39 || ch === 96) {
143
+ i = skip_string_from(input, i, ch);
144
+ continue;
145
+ }
146
+ if (ch === 61 && input.charCodeAt(i + 1) === 62) return true;
147
+ if (ch === 59 || ch === 123 || ch === 125) return false;
148
+ i++;
149
+ }
150
+ return false;
151
+ }
152
+
153
+ return input.charCodeAt(i) === 61 && input.charCodeAt(i + 1) === 62;
154
+ }
155
+
19
156
  /**
20
157
  * Acorn parser plugin for Ripple syntax extensions.
21
158
  * Adds support for: component declarations, &[]/&{} lazy destructuring,
@@ -44,6 +181,7 @@ export function TSRXPlugin(config) {
44
181
  #errors = undefined;
45
182
  /** @type {string | null} */
46
183
  #filename = null;
184
+ #functionBodyDepth = 0;
47
185
 
48
186
  /**
49
187
  * @param {Parse.Options} options
@@ -59,20 +197,21 @@ export function TSRXPlugin(config) {
59
197
 
60
198
  /**
61
199
  * @param {number} position
200
+ * @param {number} end
62
201
  * @param {string} message
63
202
  */
64
- #report_recoverable_error(position, message) {
203
+ #report_recoverable_error_range(position, end, message) {
65
204
  const start = Math.max(0, Math.min(position, this.input.length));
66
- const end = Math.min(this.input.length, start + 1);
205
+ const range_end = Math.max(start, Math.min(end, this.input.length));
67
206
  const start_loc = acorn.getLineInfo(this.input, start);
68
- const end_loc = acorn.getLineInfo(this.input, end);
207
+ const end_loc = acorn.getLineInfo(this.input, range_end);
69
208
 
70
209
  error(
71
210
  message,
72
211
  this.#filename,
73
212
  /** @type {AST.NodeWithLocation} */ ({
74
213
  start,
75
- end,
214
+ end: range_end,
76
215
  loc: {
77
216
  start: start_loc,
78
217
  end: end_loc,
@@ -82,6 +221,14 @@ export function TSRXPlugin(config) {
82
221
  );
83
222
  }
84
223
 
224
+ /**
225
+ * @param {number} position
226
+ * @param {string} message
227
+ */
228
+ #report_recoverable_error(position, message) {
229
+ this.#report_recoverable_error_range(position, position + 1, message);
230
+ }
231
+
85
232
  /**
86
233
  * In loose mode, keep parsing after duplicate declaration diagnostics so
87
234
  * editor tooling can continue producing AST and mappings.
@@ -96,7 +243,10 @@ export function TSRXPlugin(config) {
96
243
  ? message.message
97
244
  : String(message);
98
245
 
99
- if (error_message.includes('has already been declared')) {
246
+ if (
247
+ error_message.includes('has already been declared') ||
248
+ error_message === 'Argument name clash'
249
+ ) {
100
250
  this.#report_recoverable_error(position, error_message);
101
251
  return;
102
252
  }
@@ -381,6 +531,28 @@ export function TSRXPlugin(config) {
381
531
  return null;
382
532
  }
383
533
 
534
+ /**
535
+ * Inside a component, `<T,>(x: T) => x` should parse as a generic arrow
536
+ * function, not a JSX element. acorn-typescript's `readToken` would
537
+ * otherwise tokenize `<` as `jsxTagStart` (when `exprAllowed` or the
538
+ * context is `tc_expr`), bypassing our `getTokenFromCode` override. We
539
+ * intercept here, but only when the source from `<` actually looks like
540
+ * a generic arrow expression — so JSX like `<div>` keeps parsing normally.
541
+ *
542
+ * @type {Parse.Parser['readToken']}
543
+ */
544
+ readToken(code) {
545
+ if (
546
+ code === 60 &&
547
+ this.#path.findLast((n) => n.type === 'Component') &&
548
+ looks_like_generic_arrow(this.input, this.pos)
549
+ ) {
550
+ ++this.pos;
551
+ return this.finishToken(tt.relational, '<');
552
+ }
553
+ return super.readToken(code);
554
+ }
555
+
384
556
  /**
385
557
  * Get token from character code - handles Ripple-specific tokens
386
558
  * @type {Parse.Parser['getTokenFromCode']}
@@ -469,22 +641,8 @@ export function TSRXPlugin(config) {
469
641
  }
470
642
  }
471
643
 
472
- // Check if we're inside a nested function (arrow function, function expression, etc.)
473
- // We need to distinguish between being inside a function vs just being in nested scopes
474
- // (like for loops, if blocks, JSX elements, etc.)
475
- const nestedFunctionContext = this.context.some((ctx) => ctx.token === 'function');
476
-
477
- // Inside nested functions, treat < as relational/generic operator
478
- // BUT: if the < is followed by /, it's a closing JSX tag, not a less-than operator
479
- const nextChar =
480
- this.pos + 1 < this.input.length ? this.input.charCodeAt(this.pos + 1) : -1;
481
- const isClosingTag = nextChar === 47; // '/'
482
-
483
- if (nestedFunctionContext && !isClosingTag) {
484
- // Inside function - treat as TypeScript generic, not JSX
485
- ++this.pos;
486
- return this.finishToken(tt.relational, '<');
487
- }
644
+ // `<` inside a nested function body is intercepted earlier in
645
+ // `readToken` so it never reaches this path.
488
646
 
489
647
  // Check if everything before this position on the current line is whitespace
490
648
  let lineStart = this.pos - 1;
@@ -599,6 +757,67 @@ export function TSRXPlugin(config) {
599
757
  return super.parseBindingAtom();
600
758
  }
601
759
 
760
+ /**
761
+ * Acorn reports only the second duplicate function parameter. In loose
762
+ * mode, report the first one too so editor diagnostics can underline both
763
+ * binding sites. Keep strict mode on Acorn's normal fatal path.
764
+ *
765
+ * @type {Parse.Parser['checkLValSimple']}
766
+ */
767
+ checkLValSimple(expr, bindingType = BINDING_TYPES.BIND_NONE, checkClashes) {
768
+ if (
769
+ this.#loose &&
770
+ expr.type === 'Identifier' &&
771
+ bindingType !== BINDING_TYPES.BIND_NONE &&
772
+ checkClashes
773
+ ) {
774
+ const first_positions = get_argument_clash_first_positions(checkClashes);
775
+ const reported_names = get_argument_clash_reported_names(checkClashes);
776
+ const first_position = first_positions.get(expr.name);
777
+
778
+ if (Object.prototype.hasOwnProperty.call(checkClashes, expr.name)) {
779
+ if (first_position != null && !reported_names.has(expr.name)) {
780
+ this.#report_recoverable_error_range(
781
+ first_position,
782
+ first_position + expr.name.length,
783
+ 'Argument name clash',
784
+ );
785
+ reported_names.add(expr.name);
786
+ }
787
+ const start = /** @type {number} */ (expr.start);
788
+ this.#report_recoverable_error_range(
789
+ start,
790
+ /** @type {number} */ (expr.end ?? start + expr.name.length),
791
+ 'Argument name clash',
792
+ );
793
+ return;
794
+ }
795
+
796
+ const result = super.checkLValSimple(expr, bindingType, checkClashes);
797
+ first_positions.set(expr.name, /** @type {number} */ (expr.start));
798
+ return result;
799
+ }
800
+
801
+ return super.checkLValSimple(expr, bindingType, checkClashes);
802
+ }
803
+
804
+ /**
805
+ * Components do not use Acorn's normal function-body parser, but they
806
+ * should still report duplicate parameter names like functions do. Keep
807
+ * this validation on `BIND_OUTSIDE` so params are checked without being
808
+ * declared in the component template scope, preserving existing shadowing
809
+ * behavior.
810
+ *
811
+ * @param {AST.Pattern[]} params
812
+ */
813
+ checkComponentParams(params) {
814
+ /** @type {Record<string, boolean>} */
815
+ const name_hash = Object.create(null);
816
+ for (const param of params || []) {
817
+ this.checkLValInnerPattern(param, BINDING_TYPES.BIND_OUTSIDE, name_hash);
818
+ }
819
+ }
820
+
602
821
  /**
603
822
  * Parse expression atom - handles RippleArray and RippleObject literals
604
823
  * @type {Parse.Parser['parseExprAtom']}
@@ -741,11 +960,24 @@ export function TSRXPlugin(config) {
741
960
  }
742
961
 
743
962
  this.parseFunctionParams(node);
963
+ this.checkComponentParams(node.params);
964
+
965
+ // Reset before `eat(braceL)` so the lookahead `next()` it triggers reads
966
+ // the component body's first token as if we'd entered fresh — no
967
+ // surrounding function body should affect our parseStatement/parseBlock
968
+ // branching while inside the template.
969
+ const parent_function_body_depth = this.#functionBodyDepth;
970
+ this.#functionBodyDepth = 0;
971
+
744
972
  this.eat(tt.braceL);
745
973
  node.body = [];
746
974
  this.#path.push(node);
747
975
 
748
- this.parseTemplateBody(node.body);
976
+ try {
977
+ this.parseTemplateBody(node.body);
978
+ } finally {
979
+ this.#functionBodyDepth = parent_function_body_depth;
980
+ }
749
981
  this.#path.pop();
750
982
  this.exitScope();
751
983
 
@@ -962,6 +1194,19 @@ export function TSRXPlugin(config) {
962
1194
  return this.finishNode(node, isForIn ? 'ForInStatement' : 'ForOfStatement');
963
1195
  }
964
1196
 
1197
+ /**
1198
+ * @type {Parse.Parser['parseFunctionBody']}
1199
+ */
1200
+ parseFunctionBody(node, isArrowFunction, isMethod, forInit, ...args) {
1201
+ this.#functionBodyDepth++;
1202
+
1203
+ try {
1204
+ return super.parseFunctionBody(node, isArrowFunction, isMethod, forInit, ...args);
1205
+ } finally {
1206
+ this.#functionBodyDepth--;
1207
+ }
1208
+ }
1209
+
965
1210
  /**
966
1211
  * @type {Parse.Parser['checkUnreserved']}
967
1212
  */
@@ -1916,12 +2161,16 @@ export function TSRXPlugin(config) {
1916
2161
 
1917
2162
  if (!inside_tsx.openingElement.name) {
1918
2163
  if (this.input.slice(this.pos, this.pos + 2) === '/>') {
2164
+ // Reset exprAllowed so the trailing `/` of `</>` is tokenized
2165
+ // as a slash rather than as the start of a regex literal.
2166
+ this.exprAllowed = false;
1919
2167
  return;
1920
2168
  }
1921
2169
  } else if (this.input.slice(this.pos, this.pos + 4) === '/tsx') {
1922
2170
  const after = this.input.charCodeAt(this.pos + 4);
1923
2171
  // Make sure it's </tsx> and not </tsx:...>
1924
2172
  if (after === 62 /* > */) {
2173
+ this.exprAllowed = false;
1925
2174
  return;
1926
2175
  }
1927
2176
  }
@@ -1988,6 +2237,7 @@ export function TSRXPlugin(config) {
1988
2237
  }
1989
2238
 
1990
2239
  if (this.input.slice(this.pos, this.pos + 5) === '/tsx:') {
2240
+ this.exprAllowed = false;
1991
2241
  return;
1992
2242
  }
1993
2243
 
@@ -2188,6 +2438,7 @@ export function TSRXPlugin(config) {
2188
2438
  if (
2189
2439
  context !== 'for' &&
2190
2440
  context !== 'if' &&
2441
+ this.#functionBodyDepth === 0 &&
2191
2442
  this.context.at(-1) === b_stat &&
2192
2443
  this.type === tt.braceL &&
2193
2444
  this.context.some((c) => c === tstc.tc_expr)
@@ -2277,7 +2528,13 @@ export function TSRXPlugin(config) {
2277
2528
  parseBlock(createNewLexicalScope, node, exitStrict) {
2278
2529
  const parent = this.#path.at(-1);
2279
2530
 
2280
- if (parent?.type === 'Component' || parent?.type === 'Element') {
2531
+ // Inside a JS function body, parse `{...}` as a regular block statement,
2532
+ // even if the nearest `#path` entry is a Component/Element — we're in a
2533
+ // nested function callable, not in a template.
2534
+ if (
2535
+ this.#functionBodyDepth === 0 &&
2536
+ (parent?.type === 'Component' || parent?.type === 'Element')
2537
+ ) {
2281
2538
  if (createNewLexicalScope === void 0) createNewLexicalScope = true;
2282
2539
  if (node === void 0) node = /** @type {AST.BlockStatement} */ (this.startNode());
2283
2540
 
@@ -106,6 +106,23 @@ export function tsx_with_ts_locations() {
106
106
  context.visit(node.typeAnnotation);
107
107
  }
108
108
  },
109
+ Identifier: (node, context) => {
110
+ context.write(node.name, node);
111
+ if (node.optional) {
112
+ context.write('?');
113
+ }
114
+ if (node.typeAnnotation) {
115
+ context.visit(node.typeAnnotation);
116
+ }
117
+ },
118
+ TSNamedTupleMember: (node, context) => {
119
+ context.visit(node.label);
120
+ if (node.optional) {
121
+ context.write('?');
122
+ }
123
+ context.write(': ');
124
+ context.visit(node.elementType);
125
+ },
109
126
  };
110
127
  for (const type of [
111
128
  // JS nodes whose esrap printer emits no location marker, causing