@tsrx/core 0.0.1

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.
@@ -0,0 +1,772 @@
1
+ /**
2
+ @import * as AST from 'estree'
3
+ @import * as ESTreeJSX from 'estree-jsx'
4
+ @import { Parse } from '../../types/parse'
5
+ */
6
+
7
+ import * as acorn from 'acorn';
8
+ import { tsPlugin } from '@sveltejs/acorn-typescript';
9
+ import { walk } from 'zimmerframe';
10
+
11
+ /**
12
+ * @typedef {(BaseParser: typeof acorn.Parser) => typeof acorn.Parser} AcornPlugin
13
+ */
14
+
15
+ /** @type {Parse.BindingType} */
16
+ export const BINDING_TYPES = {
17
+ BIND_NONE: 0, // Not a binding
18
+ BIND_VAR: 1, // Var-style binding
19
+ BIND_LEXICAL: 2, // Let- or const-style binding
20
+ BIND_FUNCTION: 3, // Function declaration
21
+ BIND_SIMPLE_CATCH: 4, // Simple (identifier pattern) catch binding
22
+ BIND_OUTSIDE: 5, // Special case for function names as bound inside the function
23
+ };
24
+
25
+ /**
26
+ * @this {Parse.DestructuringErrors}
27
+ * @returns {Parse.DestructuringErrors}
28
+ */
29
+ export function DestructuringErrors() {
30
+ if (!(this instanceof DestructuringErrors)) {
31
+ throw new TypeError("'DestructuringErrors' must be invoked with 'new'");
32
+ }
33
+ this.shorthandAssign = -1;
34
+ this.trailingComma = -1;
35
+ this.parenthesizedAssign = -1;
36
+ this.parenthesizedBind = -1;
37
+ this.doubleProto = -1;
38
+ return this;
39
+ }
40
+
41
+ /**
42
+ * Convert JSX node types to regular JavaScript node types
43
+ * @param {ESTreeJSX.JSXIdentifier | ESTreeJSX.JSXMemberExpression | AST.Node} node - The JSX node to convert
44
+ * @returns {AST.Identifier | AST.MemberExpression | AST.Node} The converted node
45
+ */
46
+ export function convert_from_jsx(node) {
47
+ /** @type {AST.Identifier | AST.MemberExpression | AST.Node} */
48
+ let converted_node;
49
+ if (node.type === 'JSXIdentifier') {
50
+ converted_node = /** @type {AST.Identifier} */ (/** @type {unknown} */ (node));
51
+ converted_node.type = 'Identifier';
52
+ } else if (node.type === 'JSXMemberExpression') {
53
+ converted_node = /** @type {AST.MemberExpression} */ (/** @type {unknown} */ (node));
54
+ converted_node.type = 'MemberExpression';
55
+ converted_node.object = /** @type {AST.Identifier | AST.MemberExpression} */ (
56
+ convert_from_jsx(converted_node.object)
57
+ );
58
+ converted_node.property = /** @type {AST.Identifier} */ (
59
+ convert_from_jsx(converted_node.property)
60
+ );
61
+ } else {
62
+ converted_node = node;
63
+ }
64
+ return converted_node;
65
+ }
66
+
67
+ const regex_whitespace_only = /\s/;
68
+
69
+ /**
70
+ * Skip whitespace characters without skipping comments.
71
+ * This is needed because Acorn's skipSpace() also skips comments, which breaks
72
+ * parsing in certain contexts. Updates parser position and line tracking.
73
+ * @param {Parse.Parser} parser
74
+ */
75
+ export function skipWhitespace(parser) {
76
+ const originalStart = parser.start;
77
+ /** @type {acorn.Position | undefined} */
78
+ let lineInfo;
79
+ while (
80
+ parser.start < parser.input.length &&
81
+ regex_whitespace_only.test(parser.input[parser.start])
82
+ ) {
83
+ parser.start++;
84
+ }
85
+ // Update line tracking if whitespace was skipped
86
+ if (parser.start !== originalStart) {
87
+ lineInfo = acorn.getLineInfo(parser.input, parser.start);
88
+ if (parser.pos <= parser.start) {
89
+ parser.curLine = lineInfo.line;
90
+ parser.lineStart = parser.start - lineInfo.column;
91
+ }
92
+ }
93
+
94
+ parser.startLoc = lineInfo || acorn.getLineInfo(parser.input, parser.start);
95
+ }
96
+
97
+ /**
98
+ * @param {AST.Node | null | undefined} node
99
+ * @returns {boolean}
100
+ */
101
+ export function isWhitespaceTextNode(node) {
102
+ if (!node || node.type !== 'Text') {
103
+ return false;
104
+ }
105
+
106
+ const expr = node.expression;
107
+ if (expr && expr.type === 'Literal' && typeof expr.value === 'string') {
108
+ return /^\s*$/.test(expr.value);
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Create a parser by composing Acorn with TypeScript/JSX support and optional framework plugins.
115
+ *
116
+ * This is the core factory for building tsrx-based parsers. Framework plugins (like RipplePlugin)
117
+ * extend the base parser with framework-specific syntax.
118
+ *
119
+ * @param {...(AcornPlugin | Function)} plugins - Framework parser plugins to compose
120
+ * @returns {(source: string, filename?: string, options?: any) => AST.Program} A parse function
121
+ */
122
+ export function createParser(...plugins) {
123
+ const parser = /** @type {Parse.ParserConstructor} */ (
124
+ /** @type {unknown} */ (
125
+ acorn.Parser.extend(
126
+ tsPlugin({ jsx: true }),
127
+ ...plugins.map((p) => /** @type {AcornPlugin} */ (/** @type {unknown} */ (p))),
128
+ )
129
+ )
130
+ );
131
+
132
+ /**
133
+ * @param {string} source
134
+ * @param {string} [filename]
135
+ * @param {any} [options]
136
+ * @returns {AST.Program}
137
+ */
138
+ return function parse(source, filename, options) {
139
+ /** @type {AST.CommentWithLocation[]} */
140
+ const comments = [];
141
+ const output_comments = options?.comments;
142
+
143
+ const { onComment, add_comments } = get_comment_handlers(source, comments);
144
+ /** @type {AST.Program} */
145
+ let ast;
146
+
147
+ try {
148
+ ast = parser.parse(source, {
149
+ sourceType: 'module',
150
+ ecmaVersion: 13,
151
+ allowReturnOutsideFunction: true,
152
+ locations: true,
153
+ onComment,
154
+ rippleOptions: {
155
+ filename,
156
+ errors: options?.errors ?? [],
157
+ loose: options?.loose || false,
158
+ },
159
+ });
160
+ } catch (e) {
161
+ throw e;
162
+ }
163
+
164
+ if (output_comments) {
165
+ for (let i = 0; i < comments.length; i++) {
166
+ output_comments.push(comments[i]);
167
+ }
168
+ }
169
+
170
+ add_comments(ast);
171
+
172
+ return ast;
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Create comment handlers for tracking and attaching comments to AST nodes.
178
+ * Used by parse functions to collect and attach comments during parsing.
179
+ * @param {string} source - The source code being parsed
180
+ * @param {AST.CommentWithLocation[]} comments - Array to collect comments into
181
+ * @param {number} [index=0] - Starting index for comment filtering
182
+ * @returns {{ onComment: Parse.Options['onComment'], add_comments: (ast: AST.Node | AST.CSS.StyleSheet) => void }}
183
+ */
184
+ export function get_comment_handlers(source, comments, index = 0) {
185
+ /**
186
+ * @param {string} text
187
+ * @param {number} startIndex
188
+ * @returns {string | null}
189
+ */
190
+ function getNextNonWhitespaceCharacter(text, startIndex) {
191
+ for (let i = startIndex; i < text.length; i++) {
192
+ const char = text[i];
193
+ if (char !== ' ' && char !== '\t' && char !== '\n' && char !== '\r') {
194
+ return char;
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ return {
201
+ /**
202
+ * @type {Parse.Options['onComment']}
203
+ */
204
+ onComment: (block, value, start, end, start_loc, end_loc, metadata) => {
205
+ if (block && /\n/.test(value)) {
206
+ let a = start;
207
+ while (a > 0 && source[a - 1] !== '\n') a -= 1;
208
+
209
+ let b = a;
210
+ while (/[ \t]/.test(source[b])) b += 1;
211
+
212
+ const indentation = source.slice(a, b);
213
+ value = value.replace(new RegExp(`^${indentation}`, 'gm'), '');
214
+ }
215
+
216
+ comments.push({
217
+ type: block ? 'Block' : 'Line',
218
+ value,
219
+ start,
220
+ end,
221
+ loc: {
222
+ start: start_loc,
223
+ end: end_loc,
224
+ },
225
+ context: metadata ?? null,
226
+ });
227
+ },
228
+
229
+ /**
230
+ * @param {AST.Node | AST.CSS.StyleSheet} ast
231
+ */
232
+ add_comments: (ast) => {
233
+ if (comments.length === 0) return;
234
+
235
+ comments = comments
236
+ .filter((comment) => comment.start >= index)
237
+ .map(({ type, value, start, end, loc, context }) => ({
238
+ type,
239
+ value,
240
+ start,
241
+ end,
242
+ loc,
243
+ context,
244
+ }));
245
+
246
+ walk(ast, null, {
247
+ _(node, { next, path }) {
248
+ const metadata = /** @type {AST.Node} */ (node)?.metadata;
249
+
250
+ /**
251
+ * Check if a comment is inside an attribute expression
252
+ * of any ancestor Elements.
253
+ * @returns {boolean}
254
+ */
255
+ function isCommentInsideAttributeExpression() {
256
+ for (let i = path.length - 1; i >= 0; i--) {
257
+ const ancestor = path[i];
258
+ if (
259
+ ancestor &&
260
+ (ancestor.type === 'JSXAttribute' ||
261
+ ancestor.type === 'Attribute' ||
262
+ ancestor.type === 'JSXExpressionContainer')
263
+ ) {
264
+ return true;
265
+ }
266
+ }
267
+ return false;
268
+ }
269
+
270
+ /**
271
+ * Check if a comment is inside any attribute of ancestor Elements,
272
+ * but NOT if we're currently traversing inside that attribute.
273
+ * @param {AST.CommentWithLocation} comment
274
+ * @returns {boolean}
275
+ */
276
+ function isCommentInsideUnvisitedAttribute(comment) {
277
+ for (let i = path.length - 1; i >= 0; i--) {
278
+ const ancestor = path[i];
279
+ // we would definitely reach the attribute first before getting to the element
280
+ if (ancestor.type === 'JSXAttribute' || ancestor.type === 'Attribute') {
281
+ return false;
282
+ }
283
+ if (ancestor && ancestor.type === 'Element') {
284
+ for (const attr of /** @type {(AST.Attribute & AST.NodeWithLocation)[]} */ (
285
+ ancestor.attributes
286
+ )) {
287
+ if (comment.start >= attr.start && comment.end <= attr.end) {
288
+ return true;
289
+ }
290
+ }
291
+ }
292
+ }
293
+ return false;
294
+ }
295
+
296
+ /**
297
+ * If a comment is located between an empty Element's opening and closing tags,
298
+ * attach it to the Element as `innerComments`.
299
+ * @param {AST.CommentWithLocation} comment
300
+ * @returns {AST.Element | null}
301
+ */
302
+ function getEmptyElementInnerCommentTarget(comment) {
303
+ const element = /** @type {AST.Element | undefined} */ (
304
+ path.findLast((ancestor) => ancestor && ancestor.type === 'Element')
305
+ );
306
+ if (
307
+ !element ||
308
+ element.children.length > 0 ||
309
+ !element.closingElement ||
310
+ !(
311
+ comment.start >= /** @type {AST.NodeWithLocation} */ (element.openingElement).end &&
312
+ comment.end <= /** @type {AST.NodeWithLocation} */ (element).end
313
+ )
314
+ ) {
315
+ return null;
316
+ }
317
+
318
+ return element;
319
+ }
320
+
321
+ // Skip CSS nodes entirely - they use CSS-local positions (relative to
322
+ // the <style> tag content) which would incorrectly match against
323
+ // absolute source positions of JS/HTML comments. Also consume any
324
+ // CSS comments (which have absolute positions) that fall within the
325
+ // parent <style> element's content range so they don't leak to
326
+ // subsequent JS nodes.
327
+ if (node.type === 'StyleSheet') {
328
+ const styleElement = /** @type {AST.Element & AST.NodeWithLocation | undefined} */ (
329
+ path.findLast(
330
+ (ancestor) =>
331
+ ancestor &&
332
+ ancestor.type === 'Element' &&
333
+ ancestor.id &&
334
+ /** @type {AST.Identifier} */ (ancestor.id).name === 'style',
335
+ )
336
+ );
337
+ if (styleElement) {
338
+ const cssStart =
339
+ /** @type {AST.NodeWithLocation} */ (styleElement.openingElement)?.end ??
340
+ styleElement.start;
341
+ const cssEnd =
342
+ /** @type {AST.NodeWithLocation} */ (styleElement.closingElement)?.start ??
343
+ styleElement.end;
344
+ while (comments[0] && comments[0].start >= cssStart && comments[0].end <= cssEnd) {
345
+ comments.shift();
346
+ }
347
+ }
348
+ return;
349
+ }
350
+
351
+ if (metadata && metadata.commentContainerId !== undefined) {
352
+ // For empty template elements, keep comments as `innerComments`.
353
+ // The Prettier plugin uses `innerComments` to preserve them and
354
+ // to avoid collapsing the element into self-closing syntax.
355
+ const isEmptyElement =
356
+ node.type === 'Element' && (!node.children || node.children.length === 0);
357
+ if (!isEmptyElement) {
358
+ while (
359
+ comments[0] &&
360
+ comments[0].context &&
361
+ comments[0].context.containerId === metadata.commentContainerId &&
362
+ comments[0].context.beforeMeaningfulChild
363
+ ) {
364
+ // Check that the comment is actually in this element's own content
365
+ // area, not positionally inside a child element. This handles the
366
+ // case where jsx_parseOpeningElementAt() triggers jsx_readToken()
367
+ // before the child element is pushed to the parser's #path, causing
368
+ // comments inside the child to get the parent's containerId.
369
+ const commentStart = comments[0].start;
370
+ const isInsideChildElement = /** @type {AST.NodeWithChildren} */ (
371
+ node
372
+ ).children?.some(
373
+ (child) =>
374
+ child &&
375
+ child.start !== undefined &&
376
+ child.end !== undefined &&
377
+ commentStart >= child.start &&
378
+ commentStart < child.end,
379
+ );
380
+ if (isInsideChildElement) break;
381
+
382
+ const elementComment = /** @type {AST.CommentWithLocation} */ (comments.shift());
383
+
384
+ (metadata.elementLeadingComments ||= []).push(elementComment);
385
+ }
386
+ }
387
+ }
388
+
389
+ while (
390
+ comments[0] &&
391
+ comments[0].start < /** @type {AST.NodeWithLocation} */ (node).start
392
+ ) {
393
+ // Skip comments that are inside an attribute of an ancestor Element.
394
+ // Since zimmerframe visits children before attributes, we need to leave
395
+ // these comments for when the attribute nodes are visited.
396
+ if (
397
+ isCommentInsideUnvisitedAttribute(
398
+ /** @type {AST.CommentWithLocation} */ (comments[0]),
399
+ )
400
+ ) {
401
+ break;
402
+ }
403
+
404
+ const maybeInner = getEmptyElementInnerCommentTarget(
405
+ /** @type {AST.CommentWithLocation} */ (comments[0]),
406
+ );
407
+ if (maybeInner) {
408
+ (maybeInner.innerComments ||= []).push(
409
+ /** @type {AST.CommentWithLocation} */ (comments.shift()),
410
+ );
411
+ continue;
412
+ }
413
+
414
+ const comment = /** @type {AST.CommentWithLocation} */ (comments.shift());
415
+
416
+ // Skip leading comments for BlockStatement that is a function body
417
+ // These comments should be dangling on the function instead
418
+ if (node.type === 'BlockStatement') {
419
+ const parent = path.at(-1);
420
+ if (
421
+ parent &&
422
+ (parent.type === 'FunctionDeclaration' ||
423
+ parent.type === 'FunctionExpression' ||
424
+ parent.type === 'ArrowFunctionExpression') &&
425
+ parent.body === node
426
+ ) {
427
+ // This is a function body - don't attach comment, let it be handled by function
428
+ (parent.comments ||= []).push(comment);
429
+ continue;
430
+ }
431
+ }
432
+
433
+ if (isCommentInsideAttributeExpression()) {
434
+ (node.leadingComments ||= []).push(comment);
435
+ continue;
436
+ }
437
+
438
+ const ancestorElements = /** @type {(AST.Element & AST.NodeWithLocation)[]} */ (
439
+ path.filter((ancestor) => ancestor && ancestor.type === 'Element' && ancestor.loc)
440
+ ).sort((a, b) => a.loc.start.line - b.loc.start.line);
441
+
442
+ const targetAncestor = ancestorElements.find(
443
+ (ancestor) => comment.loc.start.line < ancestor.loc.start.line,
444
+ );
445
+
446
+ if (targetAncestor) {
447
+ targetAncestor.metadata ??= { path: [] };
448
+ (targetAncestor.metadata.elementLeadingComments ||= []).push(comment);
449
+ continue;
450
+ }
451
+
452
+ (node.leadingComments ||= []).push(comment);
453
+ }
454
+
455
+ next();
456
+
457
+ if (comments[0]) {
458
+ if (node.type === 'Program' && node.body.length === 0) {
459
+ // Collect all comments in an empty program (file with only comments)
460
+ while (comments.length) {
461
+ const comment = /** @type {AST.CommentWithLocation} */ (comments.shift());
462
+ (node.innerComments ||= []).push(comment);
463
+ }
464
+ if (node.innerComments && node.innerComments.length > 0) {
465
+ return;
466
+ }
467
+ }
468
+ if (node.type === 'BlockStatement' && node.body.length === 0) {
469
+ // Collect all comments that fall within this empty block
470
+ while (
471
+ comments[0] &&
472
+ comments[0].start < /** @type {AST.NodeWithLocation} */ (node).end &&
473
+ comments[0].end < /** @type {AST.NodeWithLocation} */ (node).end
474
+ ) {
475
+ const comment = /** @type {AST.CommentWithLocation} */ (comments.shift());
476
+ (node.innerComments ||= []).push(comment);
477
+ }
478
+ if (node.innerComments && node.innerComments.length > 0) {
479
+ return;
480
+ }
481
+ }
482
+ // Handle JSXEmptyExpression - these represent {/* comment */} in JSX
483
+ if (node.type === 'JSXEmptyExpression') {
484
+ // Collect all comments that fall within this JSXEmptyExpression
485
+ while (
486
+ comments[0] &&
487
+ comments[0].start >= /** @type {AST.NodeWithLocation} */ (node).start &&
488
+ comments[0].end <= /** @type {AST.NodeWithLocation} */ (node).end
489
+ ) {
490
+ const comment = /** @type {AST.CommentWithLocation} */ (comments.shift());
491
+ (node.innerComments ||= []).push(comment);
492
+ }
493
+ if (node.innerComments && node.innerComments.length > 0) {
494
+ return;
495
+ }
496
+ }
497
+ // Handle empty Element nodes the same way as empty BlockStatements
498
+ if (node.type === 'Element' && (!node.children || node.children.length === 0)) {
499
+ // Collect all comments that fall within this empty element
500
+ while (
501
+ comments[0] &&
502
+ comments[0].start < /** @type {AST.NodeWithLocation} */ (node).end &&
503
+ comments[0].end < /** @type {AST.NodeWithLocation} */ (node).end
504
+ ) {
505
+ const comment = /** @type {AST.CommentWithLocation} */ (comments.shift());
506
+ (node.innerComments ||= []).push(comment);
507
+ }
508
+ if (node.innerComments && node.innerComments.length > 0) {
509
+ return;
510
+ }
511
+ }
512
+
513
+ const parent = /** @type {AST.Node & AST.NodeWithLocation} */ (path.at(-1));
514
+
515
+ if (parent === undefined || node.end !== parent.end) {
516
+ const slice = source.slice(node.end, comments[0].start);
517
+
518
+ // Check if this node is the last item in an array-like structure
519
+ let is_last_in_array = false;
520
+ /** @type {(AST.Node | null)[] | null} */
521
+ let node_array = null;
522
+ let isParam = false;
523
+ let isArgument = false;
524
+ let isSwitchCaseSibling = false;
525
+
526
+ if (parent) {
527
+ if (
528
+ parent.type === 'BlockStatement' ||
529
+ parent.type === 'Program' ||
530
+ parent.type === 'Component' ||
531
+ parent.type === 'ClassBody'
532
+ ) {
533
+ node_array = parent.body;
534
+ } else if (parent.type === 'SwitchStatement') {
535
+ node_array = parent.cases;
536
+ isSwitchCaseSibling = true;
537
+ } else if (parent.type === 'SwitchCase') {
538
+ node_array = parent.consequent;
539
+ } else if (parent.type === 'ArrayExpression') {
540
+ node_array = parent.elements;
541
+ } else if (parent.type === 'ObjectExpression') {
542
+ node_array = parent.properties;
543
+ } else if (
544
+ parent.type === 'FunctionDeclaration' ||
545
+ parent.type === 'FunctionExpression' ||
546
+ parent.type === 'ArrowFunctionExpression'
547
+ ) {
548
+ node_array = parent.params;
549
+ isParam = true;
550
+ } else if (parent.type === 'CallExpression' || parent.type === 'NewExpression') {
551
+ node_array = parent.arguments;
552
+ isArgument = true;
553
+ }
554
+ }
555
+
556
+ if (node_array && Array.isArray(node_array)) {
557
+ is_last_in_array = node_array.indexOf(node) === node_array.length - 1;
558
+ }
559
+
560
+ if (is_last_in_array) {
561
+ if (isParam || isArgument) {
562
+ while (comments.length) {
563
+ const potentialComment = comments[0];
564
+ if (parent && potentialComment.start >= parent.end) {
565
+ break;
566
+ }
567
+
568
+ const maybeInner = getEmptyElementInnerCommentTarget(potentialComment);
569
+ if (maybeInner) {
570
+ (maybeInner.innerComments ||= []).push(
571
+ /** @type {AST.CommentWithLocation} */ (comments.shift()),
572
+ );
573
+ continue;
574
+ }
575
+
576
+ const nextChar = getNextNonWhitespaceCharacter(source, potentialComment.end);
577
+ if (nextChar === ')') {
578
+ (node.trailingComments ||= []).push(
579
+ /** @type {AST.CommentWithLocation} */ (comments.shift()),
580
+ );
581
+ continue;
582
+ }
583
+
584
+ break;
585
+ }
586
+ } else {
587
+ // Special case: There can be multiple trailing comments after the last node in a block,
588
+ // and they can be separated by newlines
589
+ while (comments.length) {
590
+ const comment = comments[0];
591
+ if (parent && comment.start >= parent.end) break;
592
+
593
+ const maybeInner = getEmptyElementInnerCommentTarget(comment);
594
+ if (maybeInner) {
595
+ (maybeInner.innerComments ||= []).push(
596
+ /** @type {AST.CommentWithLocation} */ (comments.shift()),
597
+ );
598
+ continue;
599
+ }
600
+
601
+ (node.trailingComments ||= []).push(comment);
602
+ comments.shift();
603
+ }
604
+ }
605
+ } else if (/** @type {AST.NodeWithLocation} */ (node).end <= comments[0].start) {
606
+ const maybeInner = getEmptyElementInnerCommentTarget(
607
+ /** @type {AST.CommentWithLocation} */ (comments[0]),
608
+ );
609
+ if (maybeInner) {
610
+ (maybeInner.innerComments ||= []).push(
611
+ /** @type {AST.CommentWithLocation} */ (comments.shift()),
612
+ );
613
+ return;
614
+ }
615
+
616
+ const onlySimpleWhitespace = /^[,) \t]*$/.test(slice);
617
+ const onlyWhitespace = /^\s*$/.test(slice);
618
+ const hasBlankLine = /\n\s*\n/.test(slice);
619
+ const nodeEndLine = node.loc?.end?.line ?? null;
620
+ const commentStartLine = comments[0].loc?.start?.line ?? null;
621
+ const isImmediateNextLine =
622
+ nodeEndLine !== null &&
623
+ commentStartLine !== null &&
624
+ commentStartLine === nodeEndLine + 1;
625
+
626
+ if (isSwitchCaseSibling && !is_last_in_array) {
627
+ if (
628
+ nodeEndLine !== null &&
629
+ commentStartLine !== null &&
630
+ nodeEndLine === commentStartLine
631
+ ) {
632
+ node.trailingComments = [
633
+ /** @type {AST.CommentWithLocation} */ (comments.shift()),
634
+ ];
635
+ }
636
+ return;
637
+ }
638
+
639
+ if (
640
+ onlySimpleWhitespace ||
641
+ (onlyWhitespace && !hasBlankLine && isImmediateNextLine)
642
+ ) {
643
+ // Check if this is a block comment that's inline with the next statement
644
+ // e.g., /** @type {SomeType} */ (a) = 5;
645
+ // These should be leading comments, not trailing
646
+ if (comments[0].type === 'Block' && !is_last_in_array && node_array) {
647
+ const currentIndex = node_array.indexOf(node);
648
+ const nextSibling = node_array[currentIndex + 1];
649
+
650
+ if (nextSibling && nextSibling.loc) {
651
+ const commentEndLine = comments[0].loc?.end?.line;
652
+ const nextSiblingStartLine = nextSibling.loc?.start?.line;
653
+
654
+ // If comment ends on same line as next sibling starts, it's inline with next
655
+ if (commentEndLine === nextSiblingStartLine) {
656
+ // Leave it for next sibling's leading comments
657
+ return;
658
+ }
659
+ }
660
+ }
661
+
662
+ // For function parameters, only attach as trailing comment if it's on the same line
663
+ // Comments on next line after comma should be leading comments of next parameter
664
+ if (isParam) {
665
+ // Check if comment is on same line as the node
666
+ const nodeEndLine = source.slice(0, node.end).split('\n').length;
667
+ const commentStartLine = source.slice(0, comments[0].start).split('\n').length;
668
+ if (nodeEndLine === commentStartLine) {
669
+ node.trailingComments = [
670
+ /** @type {AST.CommentWithLocation} */ (comments.shift()),
671
+ ];
672
+ }
673
+ // Otherwise leave it for next parameter's leading comments
674
+ } else {
675
+ // Line comments on the next line should be leading comments
676
+ // for the next statement, not trailing comments for this one.
677
+ // Only attach as trailing if:
678
+ // 1. It's on the same line as this node, OR
679
+ // 2. This is the last item in the array (no next sibling to attach to)
680
+ const commentOnSameLine =
681
+ nodeEndLine !== null &&
682
+ commentStartLine !== null &&
683
+ nodeEndLine === commentStartLine;
684
+
685
+ if (commentOnSameLine || is_last_in_array) {
686
+ node.trailingComments = [
687
+ /** @type {AST.CommentWithLocation} */ (comments.shift()),
688
+ ];
689
+ }
690
+ // Otherwise leave it for next sibling's leading comments
691
+ }
692
+ } else if (hasBlankLine && onlyWhitespace && node_array) {
693
+ // When there's a blank line between node and comment(s),
694
+ // check if there's also a blank line after the comment(s) before the next node
695
+ // If so, attach comments as trailing to preserve the grouping
696
+ // Only do this for statement-level contexts (BlockStatement, Program),
697
+ // not for Element children or other contexts
698
+ const isStatementContext =
699
+ parent.type === 'BlockStatement' || parent.type === 'Program';
700
+
701
+ // Don't apply for Component - let Prettier handle comment attachment there
702
+ // Component bodies have different comment handling via metadata.elementLeadingComments
703
+ if (!isStatementContext) {
704
+ return;
705
+ }
706
+
707
+ const currentIndex = node_array.indexOf(node);
708
+ const nextSibling = node_array[currentIndex + 1];
709
+
710
+ if (nextSibling && nextSibling.loc) {
711
+ // Find where the comment block ends
712
+ let lastCommentIndex = 0;
713
+ let lastCommentEnd = comments[0].end;
714
+
715
+ // Collect consecutive comments (without blank lines between them)
716
+ while (comments[lastCommentIndex + 1]) {
717
+ const currentComment = comments[lastCommentIndex];
718
+ const nextComment = comments[lastCommentIndex + 1];
719
+ const sliceBetween = source.slice(currentComment.end, nextComment.start);
720
+
721
+ // If there's a blank line, stop
722
+ if (/\n\s*\n/.test(sliceBetween)) {
723
+ break;
724
+ }
725
+
726
+ lastCommentIndex++;
727
+ lastCommentEnd = nextComment.end;
728
+ }
729
+
730
+ // Check if there's a blank line after the last comment and before next sibling
731
+ const sliceAfterComments = source.slice(lastCommentEnd, nextSibling.start);
732
+ const hasBlankLineAfter = /\n\s*\n/.test(sliceAfterComments);
733
+
734
+ if (hasBlankLineAfter) {
735
+ // Don't attach comments as trailing if next sibling is an Element
736
+ // and any comment falls within the Element's line range
737
+ // This means the comments are inside the Element (between opening and closing tags)
738
+ const nextIsElement = nextSibling.type === 'Element';
739
+ const commentsInsideElement =
740
+ nextIsElement &&
741
+ nextSibling.loc &&
742
+ comments.some((c) => {
743
+ if (!c.loc) return false;
744
+ // Check if comment is on a line between Element's start and end lines
745
+ return (
746
+ c.loc.start.line >= nextSibling.loc.start.line &&
747
+ c.loc.end.line <= nextSibling.loc.end.line
748
+ );
749
+ });
750
+
751
+ if (!commentsInsideElement) {
752
+ // Attach all the comments as trailing
753
+ for (let i = 0; i <= lastCommentIndex; i++) {
754
+ (node.trailingComments ||= []).push(
755
+ /** @type {AST.CommentWithLocation} */ (comments.shift()),
756
+ );
757
+ }
758
+ }
759
+ }
760
+ }
761
+ }
762
+ }
763
+ }
764
+ }
765
+ },
766
+ });
767
+ },
768
+ };
769
+ }
770
+
771
+ // Re-export acorn utilities that plugins may need
772
+ export { acorn, tsPlugin };