@herb-tools/core 0.8.10 → 0.9.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.
Files changed (39) hide show
  1. package/dist/herb-core.browser.js +22728 -320
  2. package/dist/herb-core.browser.js.map +1 -1
  3. package/dist/herb-core.cjs +22815 -321
  4. package/dist/herb-core.cjs.map +1 -1
  5. package/dist/herb-core.esm.js +22728 -320
  6. package/dist/herb-core.esm.js.map +1 -1
  7. package/dist/herb-core.umd.js +22815 -321
  8. package/dist/herb-core.umd.js.map +1 -1
  9. package/dist/types/ast-utils.d.ts +185 -4
  10. package/dist/types/backend.d.ts +6 -6
  11. package/dist/types/diagnostic.d.ts +6 -0
  12. package/dist/types/errors.d.ts +390 -25
  13. package/dist/types/extract-ruby-options.d.ts +6 -0
  14. package/dist/types/herb-backend.d.ts +15 -7
  15. package/dist/types/index.d.ts +2 -0
  16. package/dist/types/node-type-guards.d.ts +113 -32
  17. package/dist/types/nodes.d.ts +465 -49
  18. package/dist/types/parse-result.d.ts +7 -1
  19. package/dist/types/parser-options.d.ts +33 -2
  20. package/dist/types/prism/index.d.ts +28 -0
  21. package/dist/types/prism/inspect.d.ts +3 -0
  22. package/dist/types/util.d.ts +0 -1
  23. package/dist/types/visitor.d.ts +19 -1
  24. package/package.json +4 -1
  25. package/src/ast-utils.ts +564 -8
  26. package/src/backend.ts +7 -7
  27. package/src/diagnostic.ts +7 -0
  28. package/src/errors.ts +1221 -76
  29. package/src/extract-ruby-options.ts +11 -0
  30. package/src/herb-backend.ts +30 -15
  31. package/src/index.ts +2 -0
  32. package/src/node-type-guards.ts +281 -33
  33. package/src/nodes.ts +1309 -100
  34. package/src/parse-result.ts +11 -0
  35. package/src/parser-options.ts +62 -2
  36. package/src/prism/index.ts +44 -0
  37. package/src/prism/inspect.ts +118 -0
  38. package/src/util.ts +0 -12
  39. package/src/visitor.ts +66 -1
package/src/ast-utils.ts CHANGED
@@ -14,8 +14,11 @@ import {
14
14
  HTMLElementNode,
15
15
  HTMLOpenTagNode,
16
16
  HTMLCloseTagNode,
17
+ HTMLAttributeNode,
17
18
  HTMLAttributeNameNode,
18
- HTMLCommentNode
19
+ HTMLAttributeValueNode,
20
+ HTMLCommentNode,
21
+ WhitespaceNode
19
22
  } from "./nodes.js"
20
23
 
21
24
  import {
@@ -24,11 +27,21 @@ import {
24
27
  isERBNode,
25
28
  isERBContentNode,
26
29
  isHTMLCommentNode,
30
+ isHTMLElementNode,
31
+ isHTMLOpenTagNode,
32
+ isHTMLTextNode,
33
+ isWhitespaceNode,
34
+ isHTMLAttributeNameNode,
35
+ isHTMLAttributeValueNode,
27
36
  areAllOfType,
28
- filterLiteralNodes
37
+ filterLiteralNodes,
38
+ filterHTMLAttributeNodes
29
39
  } from "./node-type-guards.js"
30
40
 
31
- import type { Location } from "./location.js"
41
+ import { Location } from "./location.js"
42
+ import { Range } from "./range.js"
43
+ import { Token } from "./token.js"
44
+
32
45
  import type { Position } from "./position.js"
33
46
 
34
47
  export type ERBOutputNode = ERBNode & {
@@ -60,7 +73,7 @@ export function isERBCommentNode(node: Node): node is ERBCommentNode {
60
73
  if (!isERBNode(node)) return false
61
74
  if (!node.tag_opening?.value) return false
62
75
 
63
- return node.tag_opening?.value === "<%#" || (node.tag_opening?.value !== "<%#" && (node.content?.value || "").trimStart().startsWith("#"))
76
+ return node.tag_opening?.value === "<%#" || (node.tag_opening?.value !== "<%#" && (node.content?.value || "").trimStart().startsWith("#"))
64
77
  }
65
78
 
66
79
 
@@ -175,7 +188,7 @@ export function hasStaticAttributeName(attributeNameNode: HTMLAttributeNameNode)
175
188
  /**
176
189
  * Checks if an HTML attribute name node has dynamic content (contains ERB)
177
190
  */
178
- export function hasDynamicAttributeName(attributeNameNode: HTMLAttributeNameNode): boolean {
191
+ export function hasDynamicAttributeNameNode(attributeNameNode: HTMLAttributeNameNode): boolean {
179
192
  if (!attributeNameNode.children) {
180
193
  return false
181
194
  }
@@ -208,10 +221,26 @@ export function getCombinedAttributeName(attributeNameNode: HTMLAttributeNameNod
208
221
  }
209
222
 
210
223
  /**
211
- * Gets the tag name of an HTML element node
224
+ * Gets the tag name of an HTML element, open tag, or close tag node.
225
+ * Returns null if the node is null/undefined.
212
226
  */
213
- export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): string {
214
- return node.tag_name?.value ?? ""
227
+ export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): string
228
+ export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode | null | undefined): string | null
229
+ export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode | null | undefined): string | null {
230
+ if (!node) return null
231
+
232
+ return node.tag_name?.value ?? null
233
+ }
234
+
235
+ /**
236
+ * Gets the lowercased tag name of an HTML element, open tag, or close tag node.
237
+ * Similar to `Element.localName` in the DOM API.
238
+ * Returns null if the node is null/undefined.
239
+ */
240
+ export function getTagLocalName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): string
241
+ export function getTagLocalName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode | null | undefined): string | null
242
+ export function getTagLocalName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode | null | undefined): string | null {
243
+ return getTagName(node)?.toLowerCase() ?? null
215
244
  }
216
245
 
217
246
  /**
@@ -221,6 +250,365 @@ export function isCommentNode(node: Node): node is HTMLCommentNode | ERBCommentN
221
250
  return isHTMLCommentNode(node) || isERBCommentNode(node)
222
251
  }
223
252
 
253
+ /**
254
+ * Gets the open tag node from an HTMLElementNode, handling both regular and conditional open tags.
255
+ * For conditional open tags, returns null.
256
+ * If given an HTMLOpenTagNode directly, returns it as-is.
257
+ */
258
+ export function getOpenTag(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): HTMLOpenTagNode | null {
259
+ if (!node) return null
260
+ if (isHTMLOpenTagNode(node)) return node
261
+ if (isHTMLElementNode(node)) return isHTMLOpenTagNode(node.open_tag) ? node.open_tag : null
262
+
263
+ return null
264
+ }
265
+
266
+ /**
267
+ * Gets attributes from an HTMLElementNode or HTMLOpenTagNode
268
+ */
269
+ export function getAttributes(node: HTMLElementNode | HTMLOpenTagNode | null | undefined): HTMLAttributeNode[] {
270
+ const openTag = getOpenTag(node)
271
+
272
+ return openTag ? filterHTMLAttributeNodes(openTag.children) : []
273
+ }
274
+
275
+ /**
276
+ * Gets the attribute name from an HTMLAttributeNode (lowercased)
277
+ * Returns null if the attribute name contains dynamic content (ERB)
278
+ */
279
+ export function getAttributeName(attributeNode: HTMLAttributeNode, lowercase = true): string | null {
280
+ if (!isHTMLAttributeNameNode(attributeNode.name)) return null
281
+
282
+ const staticName = getStaticAttributeName(attributeNode.name)
283
+
284
+ if (!lowercase) return staticName
285
+
286
+ return staticName ? staticName.toLowerCase() : null
287
+ }
288
+
289
+ /**
290
+ * Checks if an attribute value contains only static content (no ERB).
291
+ * Accepts an HTMLAttributeNode directly, or an element/open tag + attribute name.
292
+ * Returns false for null/undefined input.
293
+ */
294
+ export function hasStaticAttributeValue(attributeNode: HTMLAttributeNode | null | undefined): boolean
295
+ export function hasStaticAttributeValue(node: HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName: string): boolean
296
+ export function hasStaticAttributeValue(nodeOrAttribute: HTMLAttributeNode | HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName?: string): boolean {
297
+ const attributeNode = attributeName
298
+ ? getAttribute(nodeOrAttribute as HTMLElementNode | HTMLOpenTagNode, attributeName)
299
+ : nodeOrAttribute as HTMLAttributeNode | null | undefined
300
+
301
+ if (!attributeNode?.value?.children) return false
302
+
303
+ return attributeNode.value.children.every(isLiteralNode)
304
+ }
305
+
306
+ /**
307
+ * Gets the static string value of an attribute (returns null if it contains ERB).
308
+ * Accepts an HTMLAttributeNode directly, or an element/open tag + attribute name.
309
+ * Returns null for null/undefined input.
310
+ */
311
+ export function getStaticAttributeValue(attributeNode: HTMLAttributeNode | null | undefined): string | null
312
+ export function getStaticAttributeValue(node: HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName: string): string | null
313
+ export function getStaticAttributeValue(nodeOrAttribute: HTMLAttributeNode | HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName?: string): string | null {
314
+ const attributeNode = attributeName
315
+ ? getAttribute(nodeOrAttribute as HTMLElementNode | HTMLOpenTagNode, attributeName)
316
+ : nodeOrAttribute as HTMLAttributeNode | null | undefined
317
+
318
+ if (!attributeNode) return null
319
+ if (!hasStaticAttributeValue(attributeNode)) return null
320
+
321
+ const valueNode = attributeNode.value
322
+ if (!valueNode) return null
323
+
324
+ return filterLiteralNodes(valueNode.children).map(child => child.content).join("") || ""
325
+ }
326
+
327
+ /**
328
+ * Attributes whose values are space-separated token lists.
329
+ */
330
+ export const TOKEN_LIST_ATTRIBUTES = new Set([
331
+ "class", "data-controller", "data-action",
332
+ ])
333
+
334
+ /**
335
+ * Splits a space-separated attribute value into individual tokens.
336
+ * Accepts a string, or an element/open tag + attribute name to look up.
337
+ * Returns an empty array for null/undefined/empty input.
338
+ */
339
+ export function getTokenList(value: string | null | undefined): string[]
340
+ export function getTokenList(node: HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName: string): string[]
341
+ export function getTokenList(valueOrNode: string | HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName?: string): string[] {
342
+ const value = attributeName
343
+ ? getStaticAttributeValue(valueOrNode as HTMLElementNode | HTMLOpenTagNode, attributeName)
344
+ : valueOrNode as string | null | undefined
345
+
346
+ if (!value) return []
347
+
348
+ return value.trim().split(/\s+/).filter(token => token.length > 0)
349
+ }
350
+
351
+ /**
352
+ * Finds an attribute by name in a list of attribute nodes
353
+ */
354
+ export function findAttributeByName(attributes: Node[], attributeName: string): HTMLAttributeNode | null {
355
+ for (const attribute of filterHTMLAttributeNodes(attributes)) {
356
+ const name = getAttributeName(attribute)
357
+
358
+ if (name === attributeName.toLowerCase()) {
359
+ return attribute
360
+ }
361
+ }
362
+
363
+ return null
364
+ }
365
+
366
+ /**
367
+ * Gets a specific attribute from an HTMLElementNode or HTMLOpenTagNode by name
368
+ */
369
+ export function getAttribute(node: HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName: string): HTMLAttributeNode | null {
370
+ const attributes = getAttributes(node)
371
+
372
+ return findAttributeByName(attributes, attributeName)
373
+ }
374
+
375
+ /**
376
+ * Checks if an element or open tag has a specific attribute
377
+ */
378
+ export function hasAttribute(node: HTMLElementNode | HTMLOpenTagNode | null | undefined, attributeName: string): boolean {
379
+ if (!node) return false
380
+
381
+ return getAttribute(node, attributeName) !== null
382
+ }
383
+
384
+ /**
385
+ * Checks if an attribute has a dynamic (ERB-containing) name.
386
+ */
387
+ export function hasDynamicAttributeName(attributeNode: HTMLAttributeNode): boolean {
388
+ if (!isHTMLAttributeNameNode(attributeNode.name)) return false
389
+
390
+ return hasDynamicAttributeNameNode(attributeNode.name)
391
+ }
392
+
393
+ /**
394
+ * Gets the combined string representation of an attribute name (including ERB syntax).
395
+ * Accepts an HTMLAttributeNode (wraps the core HTMLAttributeNameNode-level check).
396
+ */
397
+ export function getCombinedAttributeNameString(attributeNode: HTMLAttributeNode): string {
398
+ if (!isHTMLAttributeNameNode(attributeNode.name)) return ""
399
+
400
+ return getCombinedAttributeName(attributeNode.name)
401
+ }
402
+
403
+ /**
404
+ * Checks if an attribute value contains dynamic content (ERB)
405
+ */
406
+ export function hasDynamicAttributeValue(attributeNode: HTMLAttributeNode): boolean {
407
+ if (!attributeNode.value?.children) return false
408
+
409
+ return attributeNode.value.children.some(isERBContentNode)
410
+ }
411
+
412
+ /**
413
+ * Gets the value nodes array from an attribute for dynamic inspection
414
+ */
415
+ export function getAttributeValueNodes(attributeNode: HTMLAttributeNode): Node[] {
416
+ return attributeNode.value?.children || []
417
+ }
418
+
419
+ /**
420
+ * Checks if an attribute value contains any static content (for validation purposes)
421
+ */
422
+ export function hasStaticAttributeValueContent(attributeNode: HTMLAttributeNode): boolean {
423
+ return hasStaticContent(getAttributeValueNodes(attributeNode))
424
+ }
425
+
426
+ /**
427
+ * Gets the static content of an attribute value (all literal parts combined).
428
+ * Unlike getStaticAttributeValue, this extracts only the static portions from mixed content.
429
+ * Returns the concatenated literal content, or null if no literal nodes exist.
430
+ */
431
+ export function getStaticAttributeValueContent(attributeNode: HTMLAttributeNode): string | null {
432
+ return getStaticContentFromNodes(getAttributeValueNodes(attributeNode))
433
+ }
434
+
435
+ /**
436
+ * Gets the combined attribute value including both static text and ERB tag syntax.
437
+ * For ERB nodes, includes the full tag syntax (e.g., "<%= foo %>").
438
+ * Returns null if the attribute has no value.
439
+ */
440
+ export function getAttributeValue(attributeNode: HTMLAttributeNode): string | null {
441
+ const valueNode = attributeNode.value
442
+ if (!valueNode) return null
443
+
444
+ if (valueNode.type !== "AST_HTML_ATTRIBUTE_VALUE_NODE" || !valueNode.children?.length) {
445
+ return null
446
+ }
447
+
448
+ let result = ""
449
+
450
+ for (const child of valueNode.children) {
451
+ if (isERBContentNode(child)) {
452
+ if (child.content) {
453
+ result += `${child.tag_opening?.value}${child.content.value}${child.tag_closing?.value}`
454
+ }
455
+ } else if (isLiteralNode(child)) {
456
+ result += child.content
457
+ }
458
+ }
459
+
460
+ return result
461
+ }
462
+
463
+ /**
464
+ * Checks if an attribute has a value node
465
+ */
466
+ export function hasAttributeValue(attributeNode: HTMLAttributeNode): boolean {
467
+ return isHTMLAttributeValueNode(attributeNode.value)
468
+ }
469
+
470
+ /**
471
+ * Gets the quote type used for an attribute value
472
+ */
473
+ export function getAttributeValueQuoteType(node: HTMLAttributeNode | HTMLAttributeValueNode): "single" | "double" | "none" | null {
474
+ const valueNode = isHTMLAttributeValueNode(node) ? node : node.value
475
+ if (!valueNode) return null
476
+
477
+ if (valueNode.quoted && valueNode.open_quote) {
478
+ return valueNode.open_quote.value === '"' ? "double" : "single"
479
+ }
480
+
481
+ return "none"
482
+ }
483
+
484
+ /**
485
+ * Checks if an attribute value is quoted
486
+ */
487
+ export function isAttributeValueQuoted(attributeNode: HTMLAttributeNode): boolean {
488
+ if (!isHTMLAttributeValueNode(attributeNode.value)) return false
489
+
490
+ return !!attributeNode.value.quoted
491
+ }
492
+
493
+ /**
494
+ * Iterates over all attributes of an element or open tag node
495
+ */
496
+ export function forEachAttribute(node: HTMLElementNode | HTMLOpenTagNode, callback: (attributeNode: HTMLAttributeNode) => void): void {
497
+ for (const attribute of getAttributes(node)) {
498
+ callback(attribute)
499
+ }
500
+ }
501
+
502
+ // --- Class Name Grouping Utilities ---
503
+
504
+ /**
505
+ * Checks if a node is a whitespace-only literal or text node (no visible content)
506
+ */
507
+ export function isPureWhitespaceNode(node: Node): boolean {
508
+ if (isWhitespaceNode(node)) return true
509
+ if (isLiteralNode(node)) return !node.content.trim()
510
+ if (isHTMLTextNode(node)) return !(node.content ?? "").trim()
511
+
512
+ return false
513
+ }
514
+
515
+ /**
516
+ * Splits literal nodes at whitespace boundaries into separate nodes.
517
+ * Non-literal nodes are passed through unchanged.
518
+ *
519
+ * For example, a literal `"bg-blue-500 text-white"` becomes two literals:
520
+ * `"bg-blue-500"` and `" "` and `"text-white"`.
521
+ */
522
+ export function splitLiteralsAtWhitespace(nodes: Node[]): Node[] {
523
+ const result: Node[] = []
524
+
525
+ for (const node of nodes) {
526
+ if (isLiteralNode(node)) {
527
+ const parts = node.content.match(/(\S+|\s+)/g) || []
528
+
529
+ for (const part of parts) {
530
+ result.push(new LiteralNode({
531
+ type: "AST_LITERAL_NODE",
532
+ content: part,
533
+ errors: [],
534
+ location: node.location
535
+ }))
536
+ }
537
+ } else {
538
+ result.push(node)
539
+ }
540
+ }
541
+
542
+ return result
543
+ }
544
+
545
+ /**
546
+ * Groups split nodes into "class groups" where each group represents a single
547
+ * class name (possibly spanning multiple nodes when ERB is interpolated).
548
+ *
549
+ * For example, `text-<%= color %>-500 bg-blue-500` produces two groups:
550
+ * - [`text-`, ERB, `-500`] (interpolated class)
551
+ * - [`bg-blue-500`] (static class)
552
+ *
553
+ * The key heuristic: a hyphen at a node boundary means the nodes are part of
554
+ * the same class name (e.g., `bg-` + ERB + `-500`), while whitespace means
555
+ * a new class name starts.
556
+ */
557
+ export function groupNodesByClass(nodes: Node[]): Node[][] {
558
+ if (nodes.length === 0) return []
559
+
560
+ const groups: Node[][] = []
561
+ let currentGroup: Node[] = []
562
+
563
+ for (let i = 0; i < nodes.length; i++) {
564
+ const node = nodes[i]
565
+ const previousNode = i > 0 ? nodes[i - 1] : null
566
+
567
+ let startNewGroup = false
568
+
569
+ if (currentGroup.length === 0) {
570
+ startNewGroup = false
571
+ } else if (isLiteralNode(node)) {
572
+ if (/^\s/.test(node.content)) {
573
+ startNewGroup = true
574
+ } else if (/^-/.test(node.content)) {
575
+ startNewGroup = false
576
+ } else if (previousNode && !isLiteralNode(previousNode)) {
577
+ startNewGroup = true
578
+ } else if (currentGroup.every(member => isPureWhitespaceNode(member))) {
579
+ startNewGroup = true
580
+ }
581
+ } else {
582
+ if (previousNode && isLiteralNode(previousNode)) {
583
+ if (/\s$/.test(previousNode.content)) {
584
+ startNewGroup = true
585
+ } else if (/-$/.test(previousNode.content)) {
586
+ startNewGroup = false
587
+ } else {
588
+ startNewGroup = true
589
+ }
590
+ } else if (previousNode && !isLiteralNode(previousNode)) {
591
+ startNewGroup = false
592
+ }
593
+ }
594
+
595
+ if (startNewGroup && currentGroup.length > 0) {
596
+ groups.push(currentGroup)
597
+ currentGroup = []
598
+ }
599
+
600
+ currentGroup.push(node)
601
+ }
602
+
603
+ if (currentGroup.length > 0) {
604
+ groups.push(currentGroup)
605
+ }
606
+
607
+ return groups
608
+ }
609
+
610
+ // --- Position Utilities ---
611
+
224
612
  /**
225
613
  * Compares two positions to determine if the first comes before the second
226
614
  * Returns true if pos1 comes before pos2 in source order
@@ -316,3 +704,171 @@ export function getNodesAfterPosition<T extends Node>(nodes: T[], position: Posi
316
704
  node.location && isPositionAfter(node.location.start, position, inclusive)
317
705
  )
318
706
  }
707
+
708
+ /**
709
+ * Checks if two attributes are structurally equivalent (same name and value),
710
+ * ignoring positional data like location and range.
711
+ */
712
+ export function isEquivalentAttribute(first: HTMLAttributeNode, second: HTMLAttributeNode): boolean {
713
+ const firstName = getAttributeName(first)
714
+ const secondName = getAttributeName(second)
715
+
716
+ if (firstName !== secondName) return false
717
+
718
+ if (firstName && TOKEN_LIST_ATTRIBUTES.has(firstName)) {
719
+ const firstTokens = getTokenList(getAttributeValue(first))
720
+ const secondTokens = getTokenList(getAttributeValue(second))
721
+
722
+ if (firstTokens.length !== secondTokens.length) return false
723
+
724
+ const sortedFirst = [...firstTokens].sort()
725
+ const sortedSecond = [...secondTokens].sort()
726
+
727
+ return sortedFirst.every((token, index) => token === sortedSecond[index])
728
+ }
729
+
730
+ return getAttributeValue(first) === getAttributeValue(second)
731
+ }
732
+
733
+ /**
734
+ * Checks if two open tags are structurally equivalent (same tag name and attributes),
735
+ * ignoring positional data like location and range.
736
+ */
737
+ export function isEquivalentOpenTag(first: HTMLOpenTagNode, second: HTMLOpenTagNode): boolean {
738
+ if (first.tag_name?.value !== second.tag_name?.value) return false
739
+
740
+ const firstAttributes = getAttributes(first)
741
+ const secondAttributes = getAttributes(second)
742
+
743
+ if (firstAttributes.length !== secondAttributes.length) return false
744
+
745
+ return firstAttributes.every((attribute) =>
746
+ secondAttributes.some((other) => isEquivalentAttribute(attribute, other))
747
+ )
748
+ }
749
+
750
+ /**
751
+ * Checks if two elements have structurally equivalent open tags (same tag name and attributes),
752
+ * ignoring positional data like location and range. Does not compare body or close tag.
753
+ */
754
+ export function isEquivalentElement(first: HTMLElementNode, second: HTMLElementNode): boolean {
755
+ if (!isHTMLOpenTagNode(first.open_tag) || !isHTMLOpenTagNode(second.open_tag)) return false
756
+
757
+ return isEquivalentOpenTag(first.open_tag, second.open_tag)
758
+ }
759
+
760
+ // --- AST Mutation Utilities ---
761
+
762
+ const CHILD_ARRAY_PROPS = ["children", "body", "statements", "conditions"]
763
+ const LINKED_NODE_PROPS = ["subsequent", "else_clause"]
764
+
765
+ /**
766
+ * Finds the array containing a target node in the AST, along with its index.
767
+ * Traverses child arrays and linked node properties (e.g., `subsequent`, `else_clause`).
768
+ *
769
+ * Useful for autofix operations that need to splice nodes in/out of their parent array.
770
+ *
771
+ * @param root - The root node to search from
772
+ * @param target - The node to find
773
+ * @returns The containing array and the target's index, or null if not found
774
+ */
775
+ export function findParentArray(root: Node, target: Node): { array: Node[], index: number } | null {
776
+ const search = (node: Node): { array: Node[], index: number } | null => {
777
+ const record = node as Record<string, any>
778
+
779
+ for (const prop of CHILD_ARRAY_PROPS) {
780
+ const array = record[prop]
781
+
782
+ if (Array.isArray(array)) {
783
+ const index = array.indexOf(target)
784
+
785
+ if (index !== -1) {
786
+ return { array, index }
787
+ }
788
+ }
789
+ }
790
+
791
+ for (const prop of CHILD_ARRAY_PROPS) {
792
+ const array = record[prop]
793
+
794
+ if (Array.isArray(array)) {
795
+ for (const child of array) {
796
+ if (child && typeof child === 'object' && 'type' in child) {
797
+ const result = search(child)
798
+
799
+ if (result) {
800
+ return result
801
+ }
802
+ }
803
+ }
804
+ }
805
+ }
806
+
807
+ for (const prop of LINKED_NODE_PROPS) {
808
+ const value = record[prop]
809
+
810
+ if (value && typeof value === 'object' && 'type' in value) {
811
+ const result = search(value)
812
+
813
+ if (result) {
814
+ return result
815
+ }
816
+ }
817
+ }
818
+
819
+ return null
820
+ }
821
+
822
+ return search(root)
823
+ }
824
+
825
+ /**
826
+ * Removes a node from an array, also removing an adjacent preceding
827
+ * whitespace-only literal if present.
828
+ */
829
+ export function removeNodeFromArray(array: Node[], node: Node): void {
830
+ const index = array.indexOf(node)
831
+ if (index === -1) return
832
+
833
+ if (index > 0 && isPureWhitespaceNode(array[index - 1])) {
834
+ array.splice(index - 1, 2)
835
+ } else {
836
+ array.splice(index, 1)
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Replaces an element in an array with its body (children), effectively unwrapping it.
842
+ */
843
+ export function replaceNodeWithBody(array: Node[], element: HTMLElementNode): void {
844
+ const index = array.indexOf(element)
845
+ if (index === -1) return
846
+
847
+ array.splice(index, 1, ...element.body)
848
+ }
849
+
850
+ /**
851
+ * Creates a synthetic LiteralNode with the given content and zero location.
852
+ * Useful for inserting whitespace or newlines during AST mutations.
853
+ */
854
+ export function createLiteral(content: string): LiteralNode {
855
+ return new LiteralNode({
856
+ type: "AST_LITERAL_NODE",
857
+ content,
858
+ location: Location.zero,
859
+ errors: [],
860
+ })
861
+ }
862
+
863
+ export function createSyntheticToken(value: string, type = "TOKEN_SYNTHETIC"): Token {
864
+ return new Token(value, Range.zero, Location.zero, type)
865
+ }
866
+
867
+ export function createWhitespaceNode(): WhitespaceNode {
868
+ return new WhitespaceNode({
869
+ type: "AST_WHITESPACE_NODE",
870
+ location: Location.zero,
871
+ errors: [],
872
+ value: createSyntheticToken(" "),
873
+ })
874
+ }
package/src/backend.ts CHANGED
@@ -1,17 +1,18 @@
1
1
  import type { SerializedParseResult } from "./parse-result.js"
2
2
  import type { SerializedLexResult } from "./lex-result.js"
3
- import type { ParserOptions } from "./parser-options.js"
3
+ import type { ParseOptions } from "./parser-options.js"
4
+ import type { ExtractRubyOptions } from "./extract-ruby-options.js"
4
5
 
5
6
  interface LibHerbBackendFunctions {
6
7
  lex: (source: string) => SerializedLexResult
7
- lexFile: (path: string) => SerializedLexResult
8
8
 
9
- parse: (source: string, options?: ParserOptions) => SerializedParseResult
10
- parseFile: (path: string) => SerializedParseResult
9
+ parse: (source: string, options?: ParseOptions) => SerializedParseResult
11
10
 
12
- extractRuby: (source: string) => string
11
+ extractRuby: (source: string, options?: ExtractRubyOptions) => string
13
12
  extractHTML: (source: string) => string
14
13
 
14
+ parseRuby: (source: string) => Uint8Array | null
15
+
15
16
  version: () => string
16
17
  }
17
18
 
@@ -20,10 +21,9 @@ export type BackendPromise = () => Promise<LibHerbBackend>
20
21
  const expectedFunctions = [
21
22
  "parse",
22
23
  "lex",
23
- "parseFile",
24
- "lexFile",
25
24
  "extractRuby",
26
25
  "extractHTML",
26
+ "parseRuby",
27
27
  "version",
28
28
  ] as const
29
29
 
package/src/diagnostic.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Location, SerializedLocation } from "./location.js"
2
2
 
3
3
  export type DiagnosticSeverity = "error" | "warning" | "info" | "hint"
4
+ export type DiagnosticTag = "unnecessary" | "deprecated"
4
5
 
5
6
  /**
6
7
  * Base interface for all diagnostic information in Herb tooling.
@@ -32,6 +33,11 @@ export interface Diagnostic {
32
33
  * Optional source that generated this diagnostic (e.g., "parser", "linter", "lexer")
33
34
  */
34
35
  source?: string
36
+
37
+ /**
38
+ * Optional diagnostic tags for additional metadata (e.g., "unnecessary", "deprecated")
39
+ */
40
+ tags?: DiagnosticTag[]
35
41
  }
36
42
 
37
43
  /**
@@ -43,6 +49,7 @@ export interface SerializedDiagnostic {
43
49
  severity: DiagnosticSeverity
44
50
  code?: string
45
51
  source?: string
52
+ tags?: DiagnosticTag[]
46
53
  }
47
54
 
48
55
  export type MonacoSeverity = "error" | "warning" | "info"