@@ -28,14 +28,16 @@ import {
2828 isHTMLCommentNode ,
2929 isHTMLElementNode ,
3030 isHTMLOpenTagNode ,
31+ isHTMLTextNode ,
32+ isWhitespaceNode ,
3133 isHTMLAttributeNameNode ,
3234 isHTMLAttributeValueNode ,
3335 areAllOfType ,
3436 filterLiteralNodes ,
3537 filterHTMLAttributeNodes
3638} from "./node-type-guards.js"
3739
38- import type { Location } from "./location.js"
40+ import { Location } from "./location.js"
3941import type { Position } from "./position.js"
4042
4143export type ERBOutputNode = ERBNode & {
@@ -497,10 +499,14 @@ export function forEachAttribute(node: HTMLElementNode | HTMLOpenTagNode, callba
497499// --- Class Name Grouping Utilities ---
498500
499501/**
500- * Checks if a node is a whitespace-only literal (no visible content)
502+ * Checks if a node is a whitespace-only literal or text node (no visible content)
501503 */
502- export function isWhitespaceLiteral ( node : Node ) : boolean {
503- return isLiteralNode ( node ) && ! node . content . trim ( )
504+ export function isPureWhitespaceNode ( node : Node ) : boolean {
505+ if ( isWhitespaceNode ( node ) ) return true
506+ if ( isLiteralNode ( node ) ) return ! node . content . trim ( )
507+ if ( isHTMLTextNode ( node ) ) return ! ( node . content ?? "" ) . trim ( )
508+
509+ return false
504510}
505511
506512/**
@@ -566,7 +572,7 @@ export function groupNodesByClass(nodes: Node[]): Node[][] {
566572 startNewGroup = false
567573 } else if ( previousNode && ! isLiteralNode ( previousNode ) ) {
568574 startNewGroup = true
569- } else if ( currentGroup . every ( member => isWhitespaceLiteral ( member ) ) ) {
575+ } else if ( currentGroup . every ( member => isPureWhitespaceNode ( member ) ) ) {
570576 startNewGroup = true
571577 }
572578 } else {
@@ -747,3 +753,106 @@ export function isEquivalentElement(first: HTMLElementNode, second: HTMLElementN
747753
748754 return isEquivalentOpenTag ( first . open_tag , second . open_tag )
749755}
756+
757+ // --- AST Mutation Utilities ---
758+
759+ const CHILD_ARRAY_PROPS = [ "children" , "body" , "statements" , "conditions" ]
760+ const LINKED_NODE_PROPS = [ "subsequent" , "else_clause" ]
761+
762+ /**
763+ * Finds the array containing a target node in the AST, along with its index.
764+ * Traverses child arrays and linked node properties (e.g., `subsequent`, `else_clause`).
765+ *
766+ * Useful for autofix operations that need to splice nodes in/out of their parent array.
767+ *
768+ * @param root - The root node to search from
769+ * @param target - The node to find
770+ * @returns The containing array and the target's index, or null if not found
771+ */
772+ export function findParentArray ( root : Node , target : Node ) : { array : Node [ ] , index : number } | null {
773+ const search = ( node : Node ) : { array : Node [ ] , index : number } | null => {
774+ const record = node as Record < string , any >
775+
776+ for ( const prop of CHILD_ARRAY_PROPS ) {
777+ const array = record [ prop ]
778+
779+ if ( Array . isArray ( array ) ) {
780+ const index = array . indexOf ( target )
781+
782+ if ( index !== - 1 ) {
783+ return { array, index }
784+ }
785+ }
786+ }
787+
788+ for ( const prop of CHILD_ARRAY_PROPS ) {
789+ const array = record [ prop ]
790+
791+ if ( Array . isArray ( array ) ) {
792+ for ( const child of array ) {
793+ if ( child && typeof child === 'object' && 'type' in child ) {
794+ const result = search ( child )
795+
796+ if ( result ) {
797+ return result
798+ }
799+ }
800+ }
801+ }
802+ }
803+
804+ for ( const prop of LINKED_NODE_PROPS ) {
805+ const value = record [ prop ]
806+
807+ if ( value && typeof value === 'object' && 'type' in value ) {
808+ const result = search ( value )
809+
810+ if ( result ) {
811+ return result
812+ }
813+ }
814+ }
815+
816+ return null
817+ }
818+
819+ return search ( root )
820+ }
821+
822+ /**
823+ * Removes a node from an array, also removing an adjacent preceding
824+ * whitespace-only literal if present.
825+ */
826+ export function removeNodeFromArray ( array : Node [ ] , node : Node ) : void {
827+ const index = array . indexOf ( node )
828+ if ( index === - 1 ) return
829+
830+ if ( index > 0 && isPureWhitespaceNode ( array [ index - 1 ] ) ) {
831+ array . splice ( index - 1 , 2 )
832+ } else {
833+ array . splice ( index , 1 )
834+ }
835+ }
836+
837+ /**
838+ * Replaces an element in an array with its body (children), effectively unwrapping it.
839+ */
840+ export function replaceNodeWithBody ( array : Node [ ] , element : HTMLElementNode ) : void {
841+ const index = array . indexOf ( element )
842+ if ( index === - 1 ) return
843+
844+ array . splice ( index , 1 , ...element . body )
845+ }
846+
847+ /**
848+ * Creates a synthetic LiteralNode with the given content and zero location.
849+ * Useful for inserting whitespace or newlines during AST mutations.
850+ */
851+ export function createLiteral ( content : string ) : LiteralNode {
852+ return new LiteralNode ( {
853+ type : "AST_LITERAL_NODE" ,
854+ content,
855+ location : Location . zero ,
856+ errors : [ ] ,
857+ } )
858+ }
0 commit comments