From 3bc0fd305a7aa9863fcdfe91cab20aafc2b9e2e1 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Mon, 25 Aug 2025 19:22:42 +0200 Subject: [PATCH] Printer: Also fully reconstruct whitespace in `HTMLCloseTag` --- javascript/packages/core/src/ast-utils.ts | 102 +++++++++++++++++- .../packages/printer/src/identity-printer.ts | 9 ++ .../test/nodes/html-close-tag-node.test.ts | 15 ++- 3 files changed, 120 insertions(+), 6 deletions(-) diff --git a/javascript/packages/core/src/ast-utils.ts b/javascript/packages/core/src/ast-utils.ts index c0d98a5c7..7a3e0ec6d 100644 --- a/javascript/packages/core/src/ast-utils.ts +++ b/javascript/packages/core/src/ast-utils.ts @@ -1,5 +1,7 @@ import { + Node, LiteralNode, + ERBContentNode, ERBIfNode, ERBUnlessNode, ERBBlockNode, @@ -11,6 +13,7 @@ import { HTMLElementNode, HTMLOpenTagNode, HTMLCloseTagNode, + HTMLAttributeNameNode, HTMLCommentNode } from "./nodes.js" @@ -24,7 +27,8 @@ import { filterLiteralNodes } from "./node-type-guards.js" -import { Node, ERBContentNode, HTMLAttributeNameNode } from "./nodes.js" +import type { Location } from "./location.js" +import type { Position } from "./position.js" /** * Checks if a node is an ERB output node (generates content: <%= %> or <%== %>) @@ -189,3 +193,99 @@ export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseT export function isCommentNode(node: Node): boolean { return isNode(node, HTMLCommentNode) || (isERBNode(node) && !isERBControlFlowNode(node)) } + +/** + * Compares two positions to determine if the first comes before the second + * Returns true if pos1 comes before pos2 in source order + * @param inclusive - If true, returns true when positions are equal + */ +function isPositionBefore(position1: Position, position2: Position, inclusive = false): boolean { + if (position1.line < position2.line) return true + if (position1.line > position2.line) return false + + return inclusive ? position1.column <= position2.column : position1.column < position2.column +} + +/** + * Compares two positions to determine if they are equal + * Returns true if pos1 and pos2 are at the same location + */ +export function isPositionEqual(position1: Position, position2: Position): boolean { + return position1.line === position2.line && position1.column === position2.column +} + +/** + * Compares two positions to determine if the first comes after the second + * Returns true if pos1 comes after pos2 in source order + * @param inclusive - If true, returns true when positions are equal + */ +export function isPositionAfter(position1: Position, position2: Position, inclusive = false): boolean { + if (position1.line > position2.line) return true + if (position1.line < position2.line) return false + + return inclusive ? position1.column >= position2.column : position1.column > position2.column +} + +/** + * Gets nodes that appear before the specified location in source order + * Uses line and column positions to determine ordering + */ +export function getNodesBeforeLocation(nodes: T[], location: Location): T[] { + return nodes.filter(node => + node.location && isPositionBefore(node.location.end, location.start) + ) +} + +/** + * Gets nodes that appear after the specified location in source order + * Uses line and column positions to determine ordering + */ +export function getNodesAfterLocation(nodes: T[], location: Location): T[] { + return nodes.filter(node => + node.location && isPositionAfter(node.location.start, location.end) + ) +} + +/** + * Splits nodes into before and after the specified location + * Returns an object with `before` and `after` arrays + */ +export function splitNodesAroundLocation(nodes: T[], location: Location): { before: T[], after: T[] } { + return { + before: getNodesBeforeLocation(nodes, location), + after: getNodesAfterLocation(nodes, location) + } +} + +/** + * Splits nodes at a specific position + * Returns nodes that end before the position and nodes that start after the position + * More precise than splitNodesAroundLocation as it uses a single position point + * Uses the same defaults as the individual functions: before=exclusive, after=inclusive + */ +export function splitNodesAroundPosition(nodes: T[], position: Position): { before: T[], after: T[] } { + return { + before: getNodesBeforePosition(nodes, position), // uses default: inclusive = false + after: getNodesAfterPosition(nodes, position) // uses default: inclusive = true + } +} + +/** + * Gets nodes that end before the specified position + * @param inclusive - If true, includes nodes that end exactly at the position (default: false, matching half-open interval semantics) + */ +export function getNodesBeforePosition(nodes: T[], position: Position, inclusive = false): T[] { + return nodes.filter(node => + node.location && isPositionBefore(node.location.end, position, inclusive) + ) +} + +/** + * Gets nodes that start after the specified position + * @param inclusive - If true, includes nodes that start exactly at the position (default: true, matching typical boundary behavior) + */ +export function getNodesAfterPosition(nodes: T[], position: Position, inclusive = true): T[] { + return nodes.filter(node => + node.location && isPositionAfter(node.location.start, position, inclusive) + ) +} diff --git a/javascript/packages/printer/src/identity-printer.ts b/javascript/packages/printer/src/identity-printer.ts index 12310c49f..6eb2c53c5 100644 --- a/javascript/packages/printer/src/identity-printer.ts +++ b/javascript/packages/printer/src/identity-printer.ts @@ -1,4 +1,6 @@ import { Printer } from "./printer.js" +import { getNodesBeforePosition, getNodesAfterPosition } from "@herb-tools/core" + import type * as Nodes from "@herb-tools/core" /** @@ -47,7 +49,14 @@ export class IdentityPrinter extends Printer { } if (node.tag_name) { + const before = getNodesBeforePosition(node.children, node.tag_name.location.start, true) + const after = getNodesAfterPosition(node.children, node.tag_name.location.end) + + this.visitAll(before) this.write(node.tag_name.value) + this.visitAll(after) + } else { + this.visitAll(node.children) } if (node.tag_closing) { diff --git a/javascript/packages/printer/test/nodes/html-close-tag-node.test.ts b/javascript/packages/printer/test/nodes/html-close-tag-node.test.ts index f07ba1dac..7a1577075 100644 --- a/javascript/packages/printer/test/nodes/html-close-tag-node.test.ts +++ b/javascript/packages/printer/test/nodes/html-close-tag-node.test.ts @@ -1,4 +1,3 @@ -import dedent from "dedent" import { describe, test, beforeAll } from "vitest" import { Herb } from "@herb-tools/node-wasm" @@ -25,12 +24,18 @@ describe("HTMLCloseTagNode Printing", () => { }) test("can print from source", () => { - expectPrintRoundTrip(dedent``) - expectPrintRoundTrip(dedent``) + expectPrintRoundTrip(``) + expectPrintRoundTrip(``) + expectPrintRoundTrip(`
`) + expectPrintRoundTrip(`
`) + expectPrintRoundTrip(`
`) }) test("can print from invalid source", () => { - expectPrintRoundTrip(dedent``, false) - expectPrintRoundTrip(dedent``, false) + expectPrintRoundTrip(``, false) + expectPrintRoundTrip(``, false) + expectPrintRoundTrip(`
`, false) + expectPrintRoundTrip(``, false) + expectPrintRoundTrip(``, false) }) })