From 65d848e976b68396b74af94a8090c7070bed842f Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Mon, 2 Mar 2026 12:52:23 +0100 Subject: [PATCH] Formatter: Extract Text Flow Engine --- .../packages/formatter/src/format-printer.ts | 831 +----------------- .../formatter/src/text-flow-analyzer.ts | 195 ++++ .../formatter/src/text-flow-engine.ts | 321 +++++++ .../formatter/src/text-flow-helpers.ts | 319 +++++++ .../formatter/test/text-flow-analyzer.test.ts | 422 +++++++++ .../formatter/test/text-flow-engine.test.ts | 420 +++++++++ .../formatter/test/text-flow-helpers.test.ts | 402 +++++++++ 7 files changed, 2110 insertions(+), 800 deletions(-) create mode 100644 javascript/packages/formatter/src/text-flow-analyzer.ts create mode 100644 javascript/packages/formatter/src/text-flow-engine.ts create mode 100644 javascript/packages/formatter/src/text-flow-helpers.ts create mode 100644 javascript/packages/formatter/test/text-flow-analyzer.test.ts create mode 100644 javascript/packages/formatter/test/text-flow-engine.test.ts create mode 100644 javascript/packages/formatter/test/text-flow-helpers.test.ts diff --git a/javascript/packages/formatter/src/format-printer.ts b/javascript/packages/formatter/src/format-printer.ts index 6bc0fbedf..15a9d5067 100644 --- a/javascript/packages/formatter/src/format-printer.ts +++ b/javascript/packages/formatter/src/format-printer.ts @@ -1,6 +1,13 @@ import dedent from "dedent" import { Printer, IdentityPrinter } from "@herb-tools/printer" +import { TextFlowEngine } from "./text-flow-engine.js" +import { isTextFlowNode } from "./text-flow-helpers.js" + +import type { ERBNode } from "@herb-tools/core" +import type { FormatOptions } from "./options.js" +import type { TextFlowDelegate } from "./text-flow-engine.js" +import type { ElementFormattingAnalysis } from "./format-helpers.js" import { getTagName, @@ -21,27 +28,19 @@ import { import { areAllNestedElementsInline, - buildLineWithWord, - countAdjacentInlineElements, - endsWithWhitespace, filterEmptyNodesForHerbDisable, filterSignificantChildren, findPreviousMeaningfulSibling, hasComplexERBControlFlow, hasMixedTextAndInlineContent, hasMultilineTextContent, - hasWhitespaceBetween, isBlockLevelNode, - isClosingPunctuation, isContentPreserving, isFrontmatter, isHerbDisableComment, isInlineElement, - isLineBreakingElement, isNonWhitespaceNode, isPureWhitespaceNode, - needsSpaceBetween, - normalizeAndSplitWords, shouldAppendToLastLine, shouldPreserveUserSpacing, } from "./format-helpers.js" @@ -53,11 +52,6 @@ import { TOKEN_LIST_ATTRIBUTES, } from "./format-helpers.js" -import type { - ContentUnitWithNode, - ElementFormattingAnalysis, -} from "./format-helpers.js" - import { ParseResult, Node, @@ -97,9 +91,6 @@ import { Token } from "@herb-tools/core" -import type { ERBNode } from "@herb-tools/core" -import type { FormatOptions } from "./options.js" - /** * ASCII whitespace pattern - use instead of \s to preserve Unicode whitespace * characters like NBSP (U+00A0) and full-width space (U+3000) @@ -126,7 +117,7 @@ function getOpenTagClosing(element: HTMLElementNode): Token | null { * Printer traverses the Herb AST using the Visitor pattern * and emits a formatted string with proper indentation, line breaks, and attribute wrapping. */ -export class FormatPrinter extends Printer { +export class FormatPrinter extends Printer implements TextFlowDelegate { /** * @deprecated integrate indentWidth into this.options and update FormatOptions to extend from @herb-tools/printer options */ @@ -135,7 +126,7 @@ export class FormatPrinter extends Printer { /** * @deprecated integrate maxLineLength into this.options and update FormatOptions to extend from @herb-tools/printer options */ - private maxLineLength: number + maxLineLength: number /** * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines) @@ -151,6 +142,7 @@ export class FormatPrinter extends Printer { private stringLineCount: number = 0 private tagGroupsCache = new Map>() private allSingleLineCache = new Map() + private textFlow: TextFlowEngine public source: string @@ -161,6 +153,7 @@ export class FormatPrinter extends Printer { this.source = source this.indentWidth = options.indentWidth this.maxLineLength = options.maxLineLength + this.textFlow = new TextFlowEngine(this) } print(input: Node | ParseResult | Token): string { @@ -277,7 +270,7 @@ export class FormatPrinter extends Printer { /** * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines) */ - private push(line: string) { + push(line: string) { this.lines.push(line) this.stringLineCount++ } @@ -285,7 +278,7 @@ export class FormatPrinter extends Printer { /** * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines) */ - private pushWithIndent(line: string) { + pushWithIndent(line: string) { const indent = line.trim() === "" ? "" : this.indent this.push(indent + line) @@ -299,7 +292,7 @@ export class FormatPrinter extends Printer { return result } - private get indent(): string { + get indent(): string { return " ".repeat(this.indentLevel * this.indentWidth) } @@ -812,13 +805,13 @@ export class FormatPrinter extends Printer { visitDocumentNode(node: DocumentNode) { const children = this.formatFrontmatter(node) - const hasTextFlow = this.isInTextFlowContext(null, children) + const hasTextFlow = this.textFlow.isInTextFlowContext(children) if (hasTextFlow) { const wasInlineMode = this.inlineMode this.inlineMode = true - this.visitTextFlowChildren(children) + this.textFlow.visitTextFlowChildren(children) this.inlineMode = wasInlineMode @@ -953,7 +946,7 @@ export class FormatPrinter extends Printer { } const analysis = this.elementFormattingAnalysis.get(element) - const hasTextFlow = this.isInTextFlowContext(null, body) + const hasTextFlow = this.textFlow.isInTextFlowContext(body) const children = filterSignificantChildren(body) if (analysis?.elementContentInline) { @@ -1092,7 +1085,7 @@ export class FormatPrinter extends Printer { this.withIndent(() => { if (hasTextFlow) { - this.visitTextFlowChildren(remainingBodyUnfiltered) + this.textFlow.visitTextFlowChildren(remainingBodyUnfiltered) } else { this.visitElementChildren(leadingHerbDisableComment ? remainingChildren : body, element) } @@ -1134,82 +1127,6 @@ export class FormatPrinter extends Printer { return false } - /** - * Check if a node is part of a text flow run (text, ERB, or inline element) - */ - private isTextFlowNode(node: Node): boolean { - if (isNode(node, ERBContentNode)) return true - if (isNode(node, HTMLTextNode) && node.content.trim() !== "") return true - if (isNode(node, HTMLElementNode) && isInlineElement(getTagName(node))) return true - - return false - } - - /** - * Check if a node is whitespace that can appear within a text flow run - */ - private isTextFlowWhitespace(node: Node): boolean { - if (isNode(node, WhitespaceNode)) return true - if (isNode(node, HTMLTextNode) && node.content.trim() === "" && !node.content.includes('\n\n')) return true - - return false - } - - /** - * Collect a run of text flow nodes starting at the given index. - * Returns the nodes in the run and the index after the last node. - */ - private collectTextFlowRun(body: Node[], startIndex: number): { nodes: Node[], endIndex: number } | null { - const nodes: Node[] = [] - let index = startIndex - let textFlowCount = 0 - - while (index < body.length) { - const child = body[index] - - if (this.isTextFlowNode(child)) { - nodes.push(child) - textFlowCount++ - index++ - } else if (this.isTextFlowWhitespace(child)) { - let hasMoreTextFlow = false - - for (let lookaheadIndex = index + 1; lookaheadIndex < body.length; lookaheadIndex++) { - if (this.isTextFlowNode(body[lookaheadIndex])) { - hasMoreTextFlow = true - break - } - - if (this.isTextFlowWhitespace(body[lookaheadIndex])) { - continue - } - - break - } - - if (hasMoreTextFlow) { - nodes.push(child) - index++ - } else { - break - } - } else { - break - } - } - - if (textFlowCount >= 2) { - const hasText = nodes.some(node => isNode(node, HTMLTextNode) && node.content.trim() !== "") - const hasAtomicContent = nodes.some(node => isNode(node, ERBContentNode) || (isNode(node, HTMLElementNode) && isInlineElement(getTagName(node)))) - - if (hasText && hasAtomicContent) { - return { nodes, endIndex: index } - } - } - - return null - } - /** * Visit element children with intelligent spacing logic * @@ -1241,8 +1158,8 @@ export class FormatPrinter extends Printer { if (!isNonWhitespaceNode(child)) continue - if (this.isTextFlowNode(child)) { - const run = this.collectTextFlowRun(body, index) + if (isTextFlowNode(child)) { + const run = this.textFlow.collectTextFlowRun(body, index) if (run) { if (lastMeaningfulNode && !hasHandledSpacing) { @@ -1253,7 +1170,7 @@ export class FormatPrinter extends Printer { } } - this.visitTextFlowChildren(run.nodes) + this.textFlow.visitTextFlowChildren(run.nodes) const lastRunNode = run.nodes[run.nodes.length - 1] const hasBlankLineInTrailing = isNode(lastRunNode, HTMLTextNode) && lastRunNode.content.includes('\n\n') @@ -1615,10 +1532,10 @@ export class FormatPrinter extends Printer { this.printERBNode(node) this.withIndent(() => { - const hasTextFlow = this.isInTextFlowContext(null, node.body) + const hasTextFlow = this.textFlow.isInTextFlowContext(node.body) if (hasTextFlow) { - this.visitTextFlowChildren(node.body) + this.textFlow.visitTextFlowChildren(node.body) } else { this.visitElementChildren(node.body, null) } @@ -1969,218 +1886,13 @@ export class FormatPrinter extends Printer { } } - /** - * Visit children in a text flow context (mixed text and inline elements) - * Handles word wrapping and keeps adjacent inline elements together - */ - private visitTextFlowChildren(children: Node[]) { - const adjacentInlineCount = countAdjacentInlineElements(children) - - if (adjacentInlineCount >= 2) { - const { processedIndices } = this.renderAdjacentInlineElements(children, adjacentInlineCount) - this.visitRemainingChildrenAsTextFlow(children, processedIndices) - - return - } - - this.buildAndWrapTextFlow(children) - } - - /** - * Wrap remaining words that don't fit on the current line - * Returns the wrapped lines with proper indentation - */ - private wrapRemainingWords(words: string[], wrapWidth: number): string[] { - const lines: string[] = [] - let line = "" - - for (const word of words) { - const testLine = line + (line ? " " : "") + word - - if (testLine.length > wrapWidth && line) { - lines.push(this.indent + line) - line = word - } else { - line = testLine - } - } - - if (line) { - lines.push(this.indent + line) - } - - return lines - } - - /** - * Try to merge text starting with punctuation to inline content - * Returns object with merged content and whether processing should stop - */ - private tryMergePunctuationText(inlineContent: string, trimmedText: string, wrapWidth: number): { mergedContent: string, shouldStop: boolean, wrappedLines: string[] } { - const combined = inlineContent + trimmedText - - if (combined.length <= wrapWidth) { - return { - mergedContent: inlineContent + trimmedText, - shouldStop: false, - wrappedLines: [] - } - } - - const match = trimmedText.match(/^[.!?:;%]+/) - - if (!match) { - return { - mergedContent: inlineContent, - shouldStop: false, - wrappedLines: [] - } - } - - const punctuation = match[0] - const restText = trimmedText.substring(punctuation.length).trim() - - if (!restText) { - return { - mergedContent: inlineContent + punctuation, - shouldStop: false, - wrappedLines: [] - } - } - - const words = restText.split(/[ \t\n\r]+/) - let toMerge = punctuation - let mergedWordCount = 0 - - for (const word of words) { - const testMerge = toMerge + ' ' + word - - if ((inlineContent + testMerge).length <= wrapWidth) { - toMerge = testMerge - mergedWordCount++ - } else { - break - } - } - - const mergedContent = inlineContent + toMerge - - if (mergedWordCount >= words.length) { - return { - mergedContent, - shouldStop: false, - wrappedLines: [] - } - } - - const remainingWords = words.slice(mergedWordCount) - const wrappedLines = this.wrapRemainingWords(remainingWords, wrapWidth) - - return { - mergedContent, - shouldStop: true, - wrappedLines - } - } - - /** - * Render adjacent inline elements together on one line - */ - private renderAdjacentInlineElements(children: Node[], count: number, startIndex = 0, alreadyProcessed?: Set): { processedIndices: Set; lastIndex: number } { - let inlineContent = "" - let processedCount = 0 - let lastProcessedIndex = -1 - const processedIndices = new Set() - - for (let index = startIndex; index < children.length && processedCount < count; index++) { - const child = children[index] - - if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) { - continue - } - if (alreadyProcessed?.has(index)) { - continue - } - - if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) { - inlineContent += this.renderInlineElementAsString(child) - processedCount++ - lastProcessedIndex = index - processedIndices.add(index) - - if (inlineContent && isLineBreakingElement(child)) { - this.pushWithIndent(inlineContent) - inlineContent = "" - } - } else if (isNode(child, ERBContentNode)) { - inlineContent += this.renderERBAsString(child) - processedCount++ - lastProcessedIndex = index - processedIndices.add(index) - } - } - - if (inlineContent && lastProcessedIndex >= 0) { - for (let index = lastProcessedIndex + 1; index < children.length; index++) { - const child = children[index] - - if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) { - continue - } - - if (alreadyProcessed?.has(index)) { - break - } - - if (isNode(child, ERBContentNode)) { - inlineContent += this.renderERBAsString(child) - processedIndices.add(index) - lastProcessedIndex = index - continue - } - - if (isNode(child, HTMLTextNode)) { - const trimmed = child.content.trim() - - if (trimmed && /^[.!?:;%]/.test(trimmed)) { - const wrapWidth = this.maxLineLength - this.indent.length - const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth) - - inlineContent = result.mergedContent - processedIndices.add(index) - lastProcessedIndex = index - - if (result.shouldStop) { - if (inlineContent) { - this.pushWithIndent(inlineContent) - } - - result.wrappedLines.forEach(line => this.push(line)) - - return { processedIndices, lastIndex: lastProcessedIndex } - } - } - } - - break - } - } - - if (inlineContent) { - this.pushWithIndent(inlineContent) - } - - return { - processedIndices, - lastIndex: lastProcessedIndex >= 0 ? lastProcessedIndex : startIndex + count - 1 - } - } + // --- TextFlowDelegate implementation --- /** * Render an inline element as a string */ - private renderInlineElementAsString(element: HTMLElementNode): string { + renderInlineElementAsString(element: HTMLElementNode): string { const tagName = getTagName(element) const tagClosing = getOpenTagClosing(element) @@ -2205,7 +1917,7 @@ export class FormatPrinter extends Printer { /** * Render an ERB node as a string */ - private renderERBAsString(node: ERBContentNode): string { + renderERBAsString(node: ERBContentNode): string { return this.capture(() => { this.inlineMode = true this.visit(node) @@ -2213,496 +1925,15 @@ export class FormatPrinter extends Printer { } /** - * Visit remaining children after processing adjacent inline elements. - * Detects and renders subsequent groups of adjacent inline elements. - */ - private visitRemainingChildren(children: Node[], processedIndices: Set): void { - let index = 0 - - while (index < children.length) { - const child = children[index] - - if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) { - index++ - continue - } - - if (processedIndices.has(index)) { - index++ - continue - } - - const adjacentCount = countAdjacentInlineElements(children, index, processedIndices) - - if (adjacentCount >= 2) { - const { processedIndices: newProcessedIndices, lastIndex } = - this.renderAdjacentInlineElements(children, adjacentCount, index, processedIndices) - - newProcessedIndices.forEach(i => processedIndices.add(i)) - index = lastIndex + 1 - } else { - this.visit(child) - index++ - } - } - } - - /** - * Visit remaining children as text flow after processing adjacent inline elements. - * Detects subsequent groups of adjacent inline elements and renders them as a group, - * while passing non-group children through text flow wrapping. - */ - private visitRemainingChildrenAsTextFlow(children: Node[], processedIndices: Set): void { - let index = 0 - let textFlowBuffer: Node[] = [] - - const flushTextFlow = () => { - if (textFlowBuffer.length > 0) { - this.buildAndWrapTextFlow(textFlowBuffer) - textFlowBuffer = [] - } - } - - while (index < children.length) { - const child = children[index] - - if (processedIndices.has(index)) { - index++ - continue - } - - if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) { - textFlowBuffer.push(child) - index++ - continue - } - - const adjacentCount = countAdjacentInlineElements(children, index, processedIndices) - - if (adjacentCount >= 2) { - flushTextFlow() - - const { processedIndices: newProcessedIndices, lastIndex } = - this.renderAdjacentInlineElements(children, adjacentCount, index, processedIndices) - - newProcessedIndices.forEach(i => processedIndices.add(i)) - index = lastIndex + 1 - } else { - textFlowBuffer.push(child) - index++ - } - } - - flushTextFlow() - } - - /** - * Build words array from text/inline/ERB and wrap them - */ - private buildAndWrapTextFlow(children: Node[]): void { - const unitsWithNodes: ContentUnitWithNode[] = this.buildContentUnitsWithNodes(children) - const words: Array<{ word: string, isHerbDisable: boolean }> = [] - - for (const { unit, node } of unitsWithNodes) { - if (unit.breaksFlow) { - this.flushWords(words) - - if (node) { - this.visit(node) - } - } else if (unit.isAtomic) { - words.push({ word: unit.content, isHerbDisable: unit.isHerbDisable || false }) - } else { - const text = unit.content.replace(ASCII_WHITESPACE, ' ') - const hasLeadingSpace = text.startsWith(' ') - const hasTrailingSpace = text.endsWith(' ') - const trimmedText = text.trim() - - if (trimmedText) { - if (hasLeadingSpace && words.length > 0) { - const lastWord = words[words.length - 1] - - if (!lastWord.word.endsWith(' ')) { - lastWord.word += ' ' - } - } - - const textWords = trimmedText.split(' ').map(w => ({ word: w, isHerbDisable: false })) - words.push(...textWords) - - if (hasTrailingSpace && words.length > 0) { - const lastWord = words[words.length - 1] - - if (!isClosingPunctuation(lastWord.word)) { - lastWord.word += ' ' - } - } - } else if (text === ' ' && words.length > 0) { - const lastWord = words[words.length - 1] - - if (!lastWord.word.endsWith(' ')) { - lastWord.word += ' ' - } - } - } - } - - // Trim trailing space from last word before final flush - trailing spaces are - // informational for spacing with subsequent words but shouldn't inflate - // effective length when it's the final word (it gets trimmed from output anyway) - if (words.length > 0) { - words[words.length - 1].word = words[words.length - 1].word.trimEnd() - } - - this.flushWords(words) - } - - /** - * Try to merge text that follows an atomic unit (ERB/inline) with no whitespace - * Returns true if merge was performed - */ - private tryMergeTextAfterAtomic(result: ContentUnitWithNode[], textNode: HTMLTextNode): boolean { - if (result.length === 0) return false - - const lastUnit = result[result.length - 1] - - if (!lastUnit.unit.isAtomic || (lastUnit.unit.type !== 'erb' && lastUnit.unit.type !== 'inline')) { - return false - } - - const words = normalizeAndSplitWords(textNode.content) - if (words.length === 0 || !words[0]) return false - - const firstWord = words[0] - const firstChar = firstWord[0] - - if (' \t\n\r'.includes(firstChar)) { - return false - } - - lastUnit.unit.content += firstWord - - if (words.length > 1) { - let remainingText = words.slice(1).join(' ') - - if (endsWithWhitespace(textNode.content)) { - remainingText += ' ' - } - - result.push({ - unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false }, - node: textNode - }) - } else if (endsWithWhitespace(textNode.content)) { - result.push({ - unit: { content: ' ', type: 'text', isAtomic: false, breaksFlow: false }, - node: textNode - }) - } - - return true - } - - /** - * Try to merge an atomic unit (ERB/inline) with preceding text that has no whitespace - * Returns true if merge was performed - */ - private tryMergeAtomicAfterText(result: ContentUnitWithNode[], children: Node[], lastProcessedIndex: number, atomicContent: string, atomicType: 'erb' | 'inline', atomicNode: Node): boolean { - if (result.length === 0) return false - - const lastUnit = result[result.length - 1] - - if (lastUnit.unit.type !== 'text' || lastUnit.unit.isAtomic) return false - - const words = normalizeAndSplitWords(lastUnit.unit.content) - const lastWord = words[words.length - 1] - - if (!lastWord) return false - - result.pop() - - if (words.length > 1) { - const remainingText = words.slice(0, -1).join(' ') - - result.push({ - unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false }, - node: children[lastProcessedIndex] - }) - } - - result.push({ - unit: { content: lastWord + atomicContent, type: atomicType, isAtomic: true, breaksFlow: false }, - node: atomicNode - }) - - return true - } - - /** - * Check if there's whitespace between current node and last processed node - */ - private hasWhitespaceBeforeNode(children: Node[], lastProcessedIndex: number, currentIndex: number, currentNode: Node): boolean { - if (hasWhitespaceBetween(children, lastProcessedIndex, currentIndex)) { - return true - } - - if (isNode(currentNode, HTMLTextNode) && /^[ \t\n\r]/.test(currentNode.content)) { - return true - } - - return false - } - - /** - * Check if last unit in result ends with whitespace - */ - private lastUnitEndsWithWhitespace(result: ContentUnitWithNode[]): boolean { - if (result.length === 0) return false - - const lastUnit = result[result.length - 1] - - return lastUnit.unit.type === 'text' && endsWithWhitespace(lastUnit.unit.content) - } - - /** - * Process a text node and add it to results (with potential merging) - */ - private processTextNode(result: ContentUnitWithNode[], children: Node[], child: HTMLTextNode, index: number, lastProcessedIndex: number): void { - const isAtomic = child.content === ' ' - - if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) { - const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child) - const lastUnit = result[result.length - 1] - const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline') - - if (lastIsAtomic && !hasWhitespace && this.tryMergeTextAfterAtomic(result, child)) { - return - } - } - - result.push({ - unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false }, - node: child - }) - } - - /** - * Process an inline element and add it to results (with potential merging) - */ - private processInlineElement(result: ContentUnitWithNode[], children: Node[], child: HTMLElementNode, index: number, lastProcessedIndex: number): boolean { - const tagName = getTagName(child) - const childrenToRender = this.getFilteredChildren(child.body) - const inlineContent = this.tryRenderInlineFull(child, tagName, filterNodes(getOpenTagChildren(child), HTMLAttributeNode), childrenToRender) - - if (inlineContent === null) { - result.push({ - unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true }, - node: child - }) - - return false - } - - if (lastProcessedIndex >= 0) { - const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result) - - if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, inlineContent, 'inline', child)) { - return true - } - } - - result.push({ - unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false }, - node: child - }) - - return false - } - - /** - * Process an ERB content node and add it to results (with potential merging) - */ - private processERBContentNode(result: ContentUnitWithNode[], children: Node[], child: ERBContentNode, index: number, lastProcessedIndex: number): boolean { - const erbContent = this.renderERBAsString(child) - const isHerbDisable = isHerbDisableComment(child) - - if (lastProcessedIndex >= 0) { - const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result) - - if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, erbContent, 'erb', child)) { - return true - } - - if (hasWhitespace && result.length > 0) { - const lastUnit = result[result.length - 1] - const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb') - - if (lastIsAtomic && !this.lastUnitEndsWithWhitespace(result)) { - result.push({ - unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false }, - node: null - }) - } - } - } - - result.push({ - unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable }, - node: child - }) - - return false - } - - /** - * Convert AST nodes to content units with node references - */ - private buildContentUnitsWithNodes(children: Node[]): ContentUnitWithNode[] { - const result: ContentUnitWithNode[] = [] - let lastProcessedIndex = -1 - - for (let i = 0; i < children.length; i++) { - const child = children[i] - - if (isNode(child, WhitespaceNode)) continue - - if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) { - if (lastProcessedIndex >= 0) { - const hasNonWhitespaceAfter = children.slice(i + 1).some(node => - !isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node) - ) - - if (hasNonWhitespaceAfter) { - const previousNode = children[lastProcessedIndex] - - if (!isLineBreakingElement(previousNode)) { - result.push({ - unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false }, - node: child - }) - } - } - } - - continue - } - - if (isNode(child, HTMLTextNode)) { - this.processTextNode(result, children, child, i, lastProcessedIndex) - - lastProcessedIndex = i - } else if (isNode(child, HTMLElementNode)) { - const tagName = getTagName(child) - - if (isInlineElement(tagName)) { - const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex) - - if (merged) { - lastProcessedIndex = i - - continue - } - } else { - result.push({ - unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true }, - node: child - }) - } - - lastProcessedIndex = i - } else if (isNode(child, ERBContentNode)) { - const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex) - - if (merged) { - lastProcessedIndex = i - - continue - } - - lastProcessedIndex = i - } else { - result.push({ - unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true }, - node: child - }) - - lastProcessedIndex = i - } - } - - return result - } - - /** - * Flush accumulated words to output with wrapping - */ - private flushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void { - if (words.length > 0) { - this.wrapAndPushWords(words) - words.length = 0 - } - } - - /** - * Wrap words to fit within line length and push to output - * Handles punctuation spacing intelligently - * Excludes herb:disable comments from line length calculations + * Try to render an inline element, returning the full inline string or null if it can't be inlined. */ - private wrapAndPushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void { - const wrapWidth = this.maxLineLength - this.indent.length - const lines: string[] = [] - let currentLine = "" - let effectiveLength = 0 - - for (const { word, isHerbDisable } of words) { - const nextLine = buildLineWithWord(currentLine, word) - - let nextEffectiveLength = effectiveLength - - if (!isHerbDisable) { - const spaceBefore = currentLine && needsSpaceBetween(currentLine, word) ? 1 : 0 - nextEffectiveLength = effectiveLength + spaceBefore + word.length - } - - if (currentLine && !isClosingPunctuation(word) && nextEffectiveLength > wrapWidth) { - lines.push(this.indent + currentLine.trimEnd()) - - currentLine = word - effectiveLength = isHerbDisable ? 0 : word.length - } else { - currentLine = nextLine - effectiveLength = nextEffectiveLength - } - } - - if (currentLine) { - lines.push(this.indent + currentLine.trimEnd()) - } + tryRenderInlineElement(element: HTMLElementNode): string | null { + const tagName = getTagName(element) + const childrenToRender = this.getFilteredChildren(element.body) - lines.forEach(line => this.push(line)) + return this.tryRenderInlineFull(element, tagName, filterNodes(getOpenTagChildren(element), HTMLAttributeNode), childrenToRender) } - private isInTextFlowContext(_parent: Node | null, children: Node[]): boolean { - const hasTextContent = children.some(child => isNode(child, HTMLTextNode) &&child.content.trim() !== "") - const nonTextChildren = children.filter(child => !isNode(child, HTMLTextNode)) - - if (!hasTextContent) return false - if (nonTextChildren.length === 0) return false - - const allInline = nonTextChildren.every(child => { - if (isNode(child, ERBContentNode)) return true - - if (isNode(child, HTMLElementNode)) { - return isInlineElement(getTagName(child)) - } - - return false - }) - - if (!allInline) return false - - return true - } private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string { const parts = attributes.map(attribute => this.renderAttribute(attribute)) diff --git a/javascript/packages/formatter/src/text-flow-analyzer.ts b/javascript/packages/formatter/src/text-flow-analyzer.ts new file mode 100644 index 000000000..55c062672 --- /dev/null +++ b/javascript/packages/formatter/src/text-flow-analyzer.ts @@ -0,0 +1,195 @@ +import { isNode, getTagName } from "@herb-tools/core" +import { Node, HTMLTextNode, HTMLElementNode, ERBContentNode, WhitespaceNode } from "@herb-tools/core" + +import type { ContentUnitWithNode } from "./format-helpers.js" + +import { + hasWhitespaceBetween, + isHerbDisableComment, + isInlineElement, + isLineBreakingElement, + isPureWhitespaceNode, +} from "./format-helpers.js" + +import { + hasWhitespaceBeforeNode as hasWhitespaceBeforeNodeHelper, + lastUnitEndsWithWhitespace as lastUnitEndsWithWhitespaceHelper, + tryMergeAtomicAfterText as tryMergeAtomicAfterTextHelper, + tryMergeTextAfterAtomic as tryMergeTextAfterAtomicHelper, +} from "./text-flow-helpers.js" + +/** + * Interface that the delegate must implement to provide + * rendering capabilities to the TextFlowAnalyzer. + */ +export interface TextFlowAnalyzerDelegate { + tryRenderInlineElement(element: HTMLElementNode): string | null + renderERBAsString(node: ERBContentNode): string +} + +/** + * TextFlowAnalyzer converts AST nodes into the ContentUnitWithNode[] + * intermediate representation used by the TextFlowEngine for rendering. + */ +export class TextFlowAnalyzer { + private delegate: TextFlowAnalyzerDelegate + + constructor(delegate: TextFlowAnalyzerDelegate) { + this.delegate = delegate + } + + buildContentUnits(children: Node[]): ContentUnitWithNode[] { + const result: ContentUnitWithNode[] = [] + let lastProcessedIndex = -1 + + for (let i = 0; i < children.length; i++) { + const child = children[i] + + if (isNode(child, WhitespaceNode)) continue + + if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) { + if (lastProcessedIndex >= 0) { + const hasNonWhitespaceAfter = children.slice(i + 1).some(node => + !isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node) + ) + + if (hasNonWhitespaceAfter) { + const previousNode = children[lastProcessedIndex] + + if (!isLineBreakingElement(previousNode)) { + result.push({ + unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false }, + node: child + }) + } + } + } + + continue + } + + if (isNode(child, HTMLTextNode)) { + this.processTextNode(result, children, child, i, lastProcessedIndex) + + lastProcessedIndex = i + } else if (isNode(child, HTMLElementNode)) { + const tagName = getTagName(child) + + if (isInlineElement(tagName)) { + const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex) + + if (merged) { + lastProcessedIndex = i + + continue + } + } else { + result.push({ + unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true }, + node: child + }) + } + + lastProcessedIndex = i + } else if (isNode(child, ERBContentNode)) { + const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex) + + if (merged) { + lastProcessedIndex = i + + continue + } + + lastProcessedIndex = i + } else { + result.push({ + unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true }, + node: child + }) + + lastProcessedIndex = i + } + } + + return result + } + + private processTextNode(result: ContentUnitWithNode[], children: Node[], child: HTMLTextNode, index: number, lastProcessedIndex: number): void { + const isAtomic = child.content === ' ' + + if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) { + const hasWhitespace = hasWhitespaceBeforeNodeHelper(children, lastProcessedIndex, index, child) + const lastUnit = result[result.length - 1] + const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline') + + if (lastIsAtomic && !hasWhitespace && tryMergeTextAfterAtomicHelper(result, child)) { + return + } + } + + result.push({ + unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false }, + node: child + }) + } + + private processInlineElement(result: ContentUnitWithNode[], children: Node[], child: HTMLElementNode, index: number, lastProcessedIndex: number): boolean { + const inlineContent = this.delegate.tryRenderInlineElement(child) + + if (inlineContent === null) { + result.push({ + unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true }, + node: child + }) + + return false + } + + if (lastProcessedIndex >= 0) { + const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || lastUnitEndsWithWhitespaceHelper(result) + + if (!hasWhitespace && tryMergeAtomicAfterTextHelper(result, children, lastProcessedIndex, inlineContent, 'inline', child)) { + return true + } + } + + result.push({ + unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false }, + node: child + }) + + return false + } + + private processERBContentNode(result: ContentUnitWithNode[], children: Node[], child: ERBContentNode, index: number, lastProcessedIndex: number): boolean { + const erbContent = this.delegate.renderERBAsString(child) + const herbDisable = isHerbDisableComment(child) + + if (lastProcessedIndex >= 0) { + const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || lastUnitEndsWithWhitespaceHelper(result) + + if (!hasWhitespace && tryMergeAtomicAfterTextHelper(result, children, lastProcessedIndex, erbContent, 'erb', child)) { + return true + } + + if (hasWhitespace && result.length > 0) { + const lastUnit = result[result.length - 1] + const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb') + + if (lastIsAtomic && !lastUnitEndsWithWhitespaceHelper(result)) { + result.push({ + unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false }, + node: null + }) + } + } + } + + result.push({ + unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable: herbDisable }, + node: child + }) + + return false + } +} diff --git a/javascript/packages/formatter/src/text-flow-engine.ts b/javascript/packages/formatter/src/text-flow-engine.ts new file mode 100644 index 000000000..0ea76e98f --- /dev/null +++ b/javascript/packages/formatter/src/text-flow-engine.ts @@ -0,0 +1,321 @@ +import { isNode, getTagName } from "@herb-tools/core" +import { Node, HTMLTextNode, HTMLElementNode, ERBContentNode, WhitespaceNode } from "@herb-tools/core" + +import type { ContentUnitWithNode } from "./format-helpers.js" + +import { + buildLineWithWord, + countAdjacentInlineElements, + isClosingPunctuation, + isInlineElement, + isLineBreakingElement, + isPureWhitespaceNode, + needsSpaceBetween, +} from "./format-helpers.js" + +import { + collectTextFlowRun as collectTextFlowRunHelper, + isInTextFlowContext as isInTextFlowContextHelper, + isTextFlowNode as isTextFlowNodeHelper, + tryMergePunctuationText as tryMergePunctuationTextHelper, +} from "./text-flow-helpers.js" + +import { TextFlowAnalyzer } from "./text-flow-analyzer.js" +import type { TextFlowAnalyzerDelegate } from "./text-flow-analyzer.js" + +/** + * ASCII whitespace pattern - use instead of \s to preserve Unicode whitespace + * characters like NBSP (U+00A0) and full-width space (U+3000) + */ +const ASCII_WHITESPACE = /[ \t\n\r]+/g + +/** + * Interface that the FormatPrinter implements to provide + * rendering capabilities to the TextFlowEngine. + */ +export interface TextFlowDelegate extends TextFlowAnalyzerDelegate { + readonly indent: string + readonly maxLineLength: number + + push(line: string): void + pushWithIndent(line: string): void + renderInlineElementAsString(element: HTMLElementNode): string + visit(node: Node): void +} + +/** + * TextFlowEngine handles the formatting of mixed text + inline elements + ERB content. + * + * It orchestrates analysis (via TextFlowAnalyzer) and rendering phases: + * groups adjacent inline elements, and wraps words to fit within line length constraints. + */ +export class TextFlowEngine { + private analyzer: TextFlowAnalyzer + + constructor(private delegate: TextFlowDelegate) { + this.analyzer = new TextFlowAnalyzer(delegate) + } + + visitTextFlowChildren(children: Node[]): void { + const adjacentInlineCount = countAdjacentInlineElements(children) + + if (adjacentInlineCount >= 2) { + const { processedIndices } = this.renderAdjacentInlineElements(children, adjacentInlineCount) + this.visitRemainingChildrenAsTextFlow(children, processedIndices) + + return + } + + this.buildAndWrapTextFlow(children) + } + + isInTextFlowContext(children: Node[]): boolean { + return isInTextFlowContextHelper(children) + } + + collectTextFlowRun(body: Node[], startIndex: number): { nodes: Node[], endIndex: number } | null { + return collectTextFlowRunHelper(body, startIndex) + } + + isTextFlowNode(node: Node): boolean { + return isTextFlowNodeHelper(node) + } + + private renderAdjacentInlineElements(children: Node[], count: number, startIndex = 0, alreadyProcessed?: Set): { processedIndices: Set; lastIndex: number } { + let inlineContent = "" + let processedCount = 0 + let lastProcessedIndex = -1 + const processedIndices = new Set() + + for (let index = startIndex; index < children.length && processedCount < count; index++) { + const child = children[index] + + if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) { + continue + } + + if (alreadyProcessed?.has(index)) { + continue + } + + if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) { + inlineContent += this.delegate.renderInlineElementAsString(child) + processedCount++ + lastProcessedIndex = index + processedIndices.add(index) + + if (inlineContent && isLineBreakingElement(child)) { + this.delegate.pushWithIndent(inlineContent) + inlineContent = "" + } + } else if (isNode(child, ERBContentNode)) { + inlineContent += this.delegate.renderERBAsString(child) + processedCount++ + lastProcessedIndex = index + processedIndices.add(index) + } + } + + if (inlineContent && lastProcessedIndex >= 0) { + for (let index = lastProcessedIndex + 1; index < children.length; index++) { + const child = children[index] + + if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) { + continue + } + + if (alreadyProcessed?.has(index)) { + break + } + + if (isNode(child, ERBContentNode)) { + inlineContent += this.delegate.renderERBAsString(child) + processedIndices.add(index) + lastProcessedIndex = index + continue + } + + if (isNode(child, HTMLTextNode)) { + const trimmed = child.content.trim() + + if (trimmed && /^[.!?:;%]/.test(trimmed)) { + const wrapWidth = this.delegate.maxLineLength - this.delegate.indent.length + const result = tryMergePunctuationTextHelper(inlineContent, trimmed, wrapWidth, this.delegate.indent) + + inlineContent = result.mergedContent + processedIndices.add(index) + lastProcessedIndex = index + + if (result.shouldStop) { + if (inlineContent) { + this.delegate.pushWithIndent(inlineContent) + } + + result.wrappedLines.forEach(line => this.delegate.push(line)) + + return { processedIndices, lastIndex: lastProcessedIndex } + } + } + } + + break + } + } + + if (inlineContent) { + this.delegate.pushWithIndent(inlineContent) + } + + return { + processedIndices, + lastIndex: lastProcessedIndex >= 0 ? lastProcessedIndex : startIndex + count - 1 + } + } + + private visitRemainingChildrenAsTextFlow(children: Node[], processedIndices: Set): void { + let index = 0 + let textFlowBuffer: Node[] = [] + + const flushTextFlow = () => { + if (textFlowBuffer.length > 0) { + this.buildAndWrapTextFlow(textFlowBuffer) + textFlowBuffer = [] + } + } + + while (index < children.length) { + const child = children[index] + + if (processedIndices.has(index)) { + index++ + continue + } + + if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) { + textFlowBuffer.push(child) + index++ + continue + } + + const adjacentCount = countAdjacentInlineElements(children, index, processedIndices) + + if (adjacentCount >= 2) { + flushTextFlow() + + const { processedIndices: newProcessedIndices, lastIndex } = + this.renderAdjacentInlineElements(children, adjacentCount, index, processedIndices) + + newProcessedIndices.forEach(i => processedIndices.add(i)) + index = lastIndex + 1 + } else { + textFlowBuffer.push(child) + index++ + } + } + + flushTextFlow() + } + + private buildAndWrapTextFlow(children: Node[]): void { + const unitsWithNodes: ContentUnitWithNode[] = this.analyzer.buildContentUnits(children) + const words: Array<{ word: string, isHerbDisable: boolean }> = [] + + if (process.env.DEBUG_FLOW) { + console.log('UNITS: ' + JSON.stringify(unitsWithNodes.map(u => ({ t: u.unit.type, c: u.unit.content.substring(0, 60), a: u.unit.isAtomic, b: u.unit.breaksFlow })))) + } + + for (const { unit, node } of unitsWithNodes) { + if (unit.breaksFlow) { + this.flushWords(words) + + if (node) { + this.delegate.visit(node) + } + } else if (unit.isAtomic) { + words.push({ word: unit.content, isHerbDisable: unit.isHerbDisable || false }) + } else { + const text = unit.content.replace(ASCII_WHITESPACE, ' ') + const hasLeadingSpace = text.startsWith(' ') + const hasTrailingSpace = text.endsWith(' ') + const trimmedText = text.trim() + + if (trimmedText) { + if (hasLeadingSpace && words.length > 0) { + const lastWord = words[words.length - 1] + + if (!lastWord.word.endsWith(' ')) { + lastWord.word += ' ' + } + } + + const textWords = trimmedText.split(' ').map(w => ({ word: w, isHerbDisable: false })) + words.push(...textWords) + + if (hasTrailingSpace && words.length > 0) { + const lastWord = words[words.length - 1] + + if (!isClosingPunctuation(lastWord.word)) { + lastWord.word += ' ' + } + } + } else if (text === ' ' && words.length > 0) { + const lastWord = words[words.length - 1] + + if (!lastWord.word.endsWith(' ')) { + lastWord.word += ' ' + } + } + } + } + + // Trim trailing space from last word before final flush - trailing spaces are + // informational for spacing with subsequent words but shouldn't inflate + // effective length when it's the final word (it gets trimmed from output anyway) + if (words.length > 0) { + words[words.length - 1].word = words[words.length - 1].word.trimEnd() + } + + this.flushWords(words) + } + + private flushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void { + if (words.length > 0) { + this.wrapAndPushWords(words) + words.length = 0 + } + } + + private wrapAndPushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void { + const wrapWidth = this.delegate.maxLineLength - this.delegate.indent.length + const lines: string[] = [] + let currentLine = "" + let effectiveLength = 0 + + for (const { word, isHerbDisable } of words) { + const nextLine = buildLineWithWord(currentLine, word) + + let nextEffectiveLength = effectiveLength + + if (!isHerbDisable) { + const spaceBefore = currentLine && needsSpaceBetween(currentLine, word) ? 1 : 0 + nextEffectiveLength = effectiveLength + spaceBefore + word.length + } + + if (currentLine && !isClosingPunctuation(word) && nextEffectiveLength > wrapWidth) { + lines.push(this.delegate.indent + currentLine.trimEnd()) + + currentLine = word + effectiveLength = isHerbDisable ? 0 : word.length + } else { + currentLine = nextLine + effectiveLength = nextEffectiveLength + } + } + + if (currentLine) { + lines.push(this.delegate.indent + currentLine.trimEnd()) + } + + lines.forEach(line => this.delegate.push(line)) + } +} diff --git a/javascript/packages/formatter/src/text-flow-helpers.ts b/javascript/packages/formatter/src/text-flow-helpers.ts new file mode 100644 index 000000000..59ba7f5c8 --- /dev/null +++ b/javascript/packages/formatter/src/text-flow-helpers.ts @@ -0,0 +1,319 @@ +import { isNode, getTagName } from "@herb-tools/core" +import { Node, HTMLTextNode, HTMLElementNode, ERBContentNode, WhitespaceNode } from "@herb-tools/core" + +import type { ContentUnitWithNode } from "./format-helpers.js" + +import { + endsWithWhitespace, + hasWhitespaceBetween, + isInlineElement, + normalizeAndSplitWords, +} from "./format-helpers.js" + + +/** + * Check if a node participates in text flow + */ +export function isTextFlowNode(node: Node): boolean { + if (isNode(node, ERBContentNode)) return true + if (isNode(node, HTMLTextNode) && node.content.trim() !== "") return true + if (isNode(node, HTMLElementNode) && isInlineElement(getTagName(node))) return true + + return false +} + +/** + * Check if a node is whitespace that can appear within a text flow run + */ +export function isTextFlowWhitespace(node: Node): boolean { + if (isNode(node, WhitespaceNode)) return true + if (isNode(node, HTMLTextNode) && node.content.trim() === "" && !node.content.includes('\n\n')) return true + + return false +} + +/** + * Collect a run of text flow nodes starting at the given index. + * Returns the nodes in the run and the index after the last node. + * Returns null if the run doesn't qualify (needs 2+ text flow nodes with both text and atomic content). + */ +export function collectTextFlowRun(body: Node[], startIndex: number): { nodes: Node[], endIndex: number } | null { + const nodes: Node[] = [] + let index = startIndex + let textFlowCount = 0 + + while (index < body.length) { + const child = body[index] + + if (isTextFlowNode(child)) { + nodes.push(child) + textFlowCount++ + index++ + } else if (isTextFlowWhitespace(child)) { + let hasMoreTextFlow = false + + for (let lookaheadIndex = index + 1; lookaheadIndex < body.length; lookaheadIndex++) { + if (isTextFlowNode(body[lookaheadIndex])) { + hasMoreTextFlow = true + break + } + + if (isTextFlowWhitespace(body[lookaheadIndex])) { + continue + } + + break + } + + if (hasMoreTextFlow) { + nodes.push(child) + index++ + } else { + break + } + } else { + break + } + } + + if (textFlowCount >= 2) { + const hasText = nodes.some(node => isNode(node, HTMLTextNode) && node.content.trim() !== "") + const hasAtomicContent = nodes.some(node => isNode(node, ERBContentNode) || (isNode(node, HTMLElementNode) && isInlineElement(getTagName(node)))) + + if (hasText && hasAtomicContent) { + return { nodes, endIndex: index } + } + } + + return null +} + +/** + * Check if children represent a text flow context + * (has text content mixed with inline elements or ERB) + */ +export function isInTextFlowContext(children: Node[]): boolean { + const hasTextContent = children.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "") + const nonTextChildren = children.filter(child => !isNode(child, HTMLTextNode)) + + if (!hasTextContent) return false + if (nonTextChildren.length === 0) return false + + const allInline = nonTextChildren.every(child => { + if (isNode(child, ERBContentNode)) return true + + if (isNode(child, HTMLElementNode)) { + return isInlineElement(getTagName(child)) + } + + return false + }) + + if (!allInline) return false + + return true +} + +/** + * Try to merge text that follows an atomic unit (ERB/inline) with no whitespace. + * Merges the first word of the text into the preceding atomic unit. + * Returns true if merge was performed. + */ +export function tryMergeTextAfterAtomic(result: ContentUnitWithNode[], textNode: HTMLTextNode): boolean { + if (result.length === 0) return false + + const lastUnit = result[result.length - 1] + + if (!lastUnit.unit.isAtomic || (lastUnit.unit.type !== 'erb' && lastUnit.unit.type !== 'inline')) { + return false + } + + const words = normalizeAndSplitWords(textNode.content) + if (words.length === 0 || !words[0]) return false + + const firstWord = words[0] + const firstChar = firstWord[0] + + if (' \t\n\r'.includes(firstChar)) { + return false + } + + lastUnit.unit.content += firstWord + + if (words.length > 1) { + let remainingText = words.slice(1).join(' ') + + if (endsWithWhitespace(textNode.content)) { + remainingText += ' ' + } + + result.push({ + unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false }, + node: textNode + }) + } else if (endsWithWhitespace(textNode.content)) { + result.push({ + unit: { content: ' ', type: 'text', isAtomic: false, breaksFlow: false }, + node: textNode + }) + } + + return true +} + +/** + * Try to merge an atomic unit (ERB/inline) with preceding text that has no whitespace. + * Splits preceding text, merges last word with atomic content. + * Returns true if merge was performed. + */ +export function tryMergeAtomicAfterText(result: ContentUnitWithNode[], children: Node[], lastProcessedIndex: number, atomicContent: string, atomicType: 'erb' | 'inline', atomicNode: Node): boolean { + if (result.length === 0) return false + + const lastUnit = result[result.length - 1] + if (lastUnit.unit.type !== 'text' || lastUnit.unit.isAtomic) return false + + const words = normalizeAndSplitWords(lastUnit.unit.content) + const lastWord = words[words.length - 1] + if (!lastWord) return false + + result.pop() + + if (words.length > 1) { + const remainingText = words.slice(0, -1).join(' ') + + result.push({ + unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false }, + node: children[lastProcessedIndex] + }) + } + + result.push({ + unit: { content: lastWord + atomicContent, type: atomicType, isAtomic: true, breaksFlow: false }, + node: atomicNode + }) + + return true +} + +/** + * Check if there's whitespace between current node and last processed node + */ +export function hasWhitespaceBeforeNode(children: Node[], lastProcessedIndex: number, currentIndex: number, currentNode: Node): boolean { + if (hasWhitespaceBetween(children, lastProcessedIndex, currentIndex)) { + return true + } + + if (isNode(currentNode, HTMLTextNode) && /^[ \t\n\r]/.test(currentNode.content)) { + return true + } + + return false +} + +/** + * Check if last unit in result ends with whitespace + */ +export function lastUnitEndsWithWhitespace(result: ContentUnitWithNode[]): boolean { + if (result.length === 0) return false + + const lastUnit = result[result.length - 1] + + return lastUnit.unit.type === 'text' && endsWithWhitespace(lastUnit.unit.content) +} + +/** + * Wrap remaining words that don't fit on the current line. + * Returns the wrapped lines with proper indentation. + */ +export function wrapRemainingWords(words: string[], wrapWidth: number, indent: string): string[] { + const lines: string[] = [] + let line = "" + + for (const word of words) { + const testLine = line + (line ? " " : "") + word + + if (testLine.length > wrapWidth && line) { + lines.push(indent + line) + line = word + } else { + line = testLine + } + } + + if (line) { + lines.push(indent + line) + } + + return lines +} + +/** + * Try to merge text starting with punctuation to inline content. + * Returns object with merged content and whether processing should stop. + */ +export function tryMergePunctuationText(inlineContent: string, trimmedText: string, wrapWidth: number, indent: string): { mergedContent: string, shouldStop: boolean, wrappedLines: string[] } { + const combined = inlineContent + trimmedText + + if (combined.length <= wrapWidth) { + return { + mergedContent: inlineContent + trimmedText, + shouldStop: false, + wrappedLines: [] + } + } + + const match = trimmedText.match(/^[.!?:;%]+/) + + if (!match) { + return { + mergedContent: inlineContent, + shouldStop: false, + wrappedLines: [] + } + } + + const punctuation = match[0] + const restText = trimmedText.substring(punctuation.length).trim() + + if (!restText) { + return { + mergedContent: inlineContent + punctuation, + shouldStop: false, + wrappedLines: [] + } + } + + const words = restText.split(/[ \t\n\r]+/) + let toMerge = punctuation + let mergedWordCount = 0 + + for (const word of words) { + const testMerge = toMerge + ' ' + word + + if ((inlineContent + testMerge).length <= wrapWidth) { + toMerge = testMerge + mergedWordCount++ + } else { + break + } + } + + const mergedContent = inlineContent + toMerge + + if (mergedWordCount >= words.length) { + return { + mergedContent, + shouldStop: false, + wrappedLines: [] + } + } + + const remainingWords = words.slice(mergedWordCount) + const wrappedLines = wrapRemainingWords(remainingWords, wrapWidth, indent) + + return { + mergedContent, + shouldStop: true, + wrappedLines + } +} diff --git a/javascript/packages/formatter/test/text-flow-analyzer.test.ts b/javascript/packages/formatter/test/text-flow-analyzer.test.ts new file mode 100644 index 000000000..5b181a0c2 --- /dev/null +++ b/javascript/packages/formatter/test/text-flow-analyzer.test.ts @@ -0,0 +1,422 @@ +import { describe, test, expect, beforeAll } from "vitest" +import { Herb } from "@herb-tools/node-wasm" +import { isNode, getTagName } from "@herb-tools/core" +import { HTMLTextNode, HTMLElementNode, ERBContentNode } from "@herb-tools/core" + +import { TextFlowAnalyzer } from "../src/text-flow-analyzer.js" +import type { TextFlowAnalyzerDelegate } from "../src/text-flow-analyzer.js" + +function createMockAnalyzerDelegate(): TextFlowAnalyzerDelegate { + return { + tryRenderInlineElement(element: HTMLElementNode): string | null { + const tagName = getTagName(element) + const bodyText = element.body + .filter(child => isNode(child, HTMLTextNode)) + .map(child => (child as HTMLTextNode).content.trim()) + .join("") + + return `<${tagName}>${bodyText}` + }, + + renderERBAsString(node: ERBContentNode): string { + const opening = node.tag_opening?.value ?? "<%" + const content = node.content?.value ?? "" + const closing = node.tag_closing?.value ?? "%>" + + return `${opening}${content}${closing}` + }, + } +} + +function parseBody(source: string) { + const result = Herb.parse(source) + const children = result.value.children + const element = children[0] + + if (isNode(element, HTMLElementNode)) { + return element.body + } + + return children +} + +describe("TextFlowAnalyzer", () => { + beforeAll(async () => { + await Herb.load() + }) + + describe("processTextNode", () => { + test("produces a text unit for plain text", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

Hello world

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(1) + expect(units[0].unit).toEqual({ + type: "text", content: "Hello world", isAtomic: false, breaksFlow: false, + }) + }) + + test("single space text becomes atomic", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

<%= a %> <%= b %>

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(3) + expect(units[0].unit).toEqual({ + type: "erb", content: "<%= a %>", isAtomic: true, breaksFlow: false, isHerbDisable: false, + }) + expect(units[1].unit).toEqual({ + type: "text", content: " ", isAtomic: true, breaksFlow: false, + }) + expect(units[2].unit).toEqual({ + type: "erb", content: "<%= b %>", isAtomic: true, breaksFlow: false, isHerbDisable: false, + }) + }) + + test("merges text immediately after atomic ERB unit (no whitespace between)", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

<%= name %>suffix

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(1) + expect(units[0].unit.type).toBe("erb") + expect(units[0].unit.content).toBe("<%= name %>suffix") + expect(units[0].unit.isAtomic).toBe(true) + }) + + test("does not merge text after atomic when whitespace separates them", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

<%= name %> suffix

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(2) + expect(units[0].unit.type).toBe("erb") + expect(units[0].unit.content).toBe("<%= name %>") + expect(units[1].unit.type).toBe("text") + expect(units[1].unit.content).toBe(" suffix") + }) + + test("preserves node reference on text unit", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

Hello

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(1) + expect(units[0].unit.content).toBe("Hello") + expect(units[0].node).not.toBeNull() + expect(isNode(units[0].node!, HTMLTextNode)).toBe(true) + }) + }) + + describe("processInlineElement", () => { + test("produces an atomic inline unit for a simple inline element", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

hello

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(1) + expect(units[0].unit).toEqual({ + type: "inline", content: "hello", isAtomic: true, breaksFlow: false, + }) + }) + + test("produces a breaksFlow block unit when delegate returns null", () => { + const delegate: TextFlowAnalyzerDelegate = { + tryRenderInlineElement() { return null }, + renderERBAsString() { return "<%= erb %>" }, + } + + const analyzer = new TextFlowAnalyzer(delegate) + const body = parseBody("

Text world

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(2) + expect(units[0].unit.type).toBe("text") + expect(units[1].unit).toEqual({ + type: "block", content: "", isAtomic: false, breaksFlow: true, + }) + }) + + test("merges inline element with preceding text when no whitespace", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

wordx

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(1) + expect(units[0].unit.type).toBe("inline") + expect(units[0].unit.content).toBe("wordx") + expect(units[0].unit.isAtomic).toBe(true) + }) + + test("does not merge inline element when whitespace separates it from preceding text", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

word x

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(2) + expect(units[0].unit.type).toBe("text") + expect(units[0].unit.content).toBe("word ") + expect(units[1].unit.type).toBe("inline") + expect(units[1].unit.content).toBe("x") + }) + + test("preserves node reference to the HTMLElementNode", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

bold

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(1) + expect(units[0].unit.content).toBe("bold") + expect(isNode(units[0].node!, HTMLElementNode)).toBe(true) + }) + }) + + describe("processERBContentNode", () => { + test("produces an atomic ERB unit", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

<%= name %>

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(1) + expect(units[0].unit).toEqual({ + type: "erb", content: "<%= name %>", isAtomic: true, breaksFlow: false, isHerbDisable: false, + }) + }) + + test("marks herb:disable ERB comments with isHerbDisable", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

text <%# herb:disable SomeRule %>

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(2) + expect(units[0].unit.type).toBe("text") + expect(units[1].unit.type).toBe("erb") + expect(units[1].unit.isHerbDisable).toBe(true) + }) + + test("merges ERB with preceding text when no whitespace", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

word<%= x %>

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(1) + expect(units[0].unit.type).toBe("erb") + expect(units[0].unit.content).toBe("word<%= x %>") + expect(units[0].unit.isAtomic).toBe(true) + }) + + test("inserts a space unit between atomic units when whitespace separates them", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

a <%= x %>

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(3) + expect(units[0].unit).toEqual({ + type: "inline", content: "a", isAtomic: true, breaksFlow: false, + }) + expect(units[1].unit).toEqual({ + type: "text", content: " ", isAtomic: true, breaksFlow: false, + }) + expect(units[2].unit).toEqual({ + type: "erb", content: "<%= x %>", isAtomic: true, breaksFlow: false, isHerbDisable: false, + }) + }) + + test("preserves node reference to the ERBContentNode", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

<%= name %>

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(1) + expect(units[0].unit.content).toBe("<%= name %>") + expect(isNode(units[0].node!, ERBContentNode)).toBe(true) + }) + }) + + describe("block elements", () => { + test("non-inline HTML element produces breaksFlow block unit", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("
text
inner
more
") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(3) + expect(units[0].unit).toEqual({ + type: "text", content: "text ", isAtomic: false, breaksFlow: false, + }) + expect(units[1].unit).toEqual({ + type: "block", content: "", isAtomic: false, breaksFlow: true, + }) + expect(units[2].unit).toEqual({ + type: "text", content: " more", isAtomic: false, breaksFlow: false, + }) + }) + + test("block unit preserves node reference", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("
text
inner
") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(2) + expect(units[1].unit.type).toBe("block") + expect(isNode(units[1].node!, HTMLElementNode)).toBe(true) + }) + }) + + describe("whitespace handling", () => { + test("skips WhitespaceNode entirely", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

\n

") + const units = analyzer.buildContentUnits(body) + + const blockUnits = units.filter(unit => unit.unit.type === "block") + expect(blockUnits).toHaveLength(0) + }) + + test("pure whitespace between content nodes becomes a space unit", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

a\nb

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(3) + expect(units[0].unit.content).toBe("a") + expect(units[1].unit).toEqual({ + type: "text", content: " ", isAtomic: true, breaksFlow: false, + }) + expect(units[2].unit.content).toBe("b") + }) + + test("no trailing space unit when whitespace has no following content", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

a

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(1) + expect(units[0].unit.type).toBe("inline") + expect(units[0].unit.content).toBe("a") + }) + }) + + describe("mixed sequences", () => { + test("text + inline + text produces correct sequence", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

before mid after

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(3) + expect(units[0].unit).toEqual({ + type: "text", content: "before ", isAtomic: false, breaksFlow: false, + }) + expect(units[1].unit).toEqual({ + type: "inline", content: "mid", isAtomic: true, breaksFlow: false, + }) + expect(units[2].unit).toEqual({ + type: "text", content: " after", isAtomic: false, breaksFlow: false, + }) + }) + + test("text + ERB + text produces correct sequence", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

before <%= x %> after

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(3) + expect(units[0].unit).toEqual({ + type: "text", content: "before ", isAtomic: false, breaksFlow: false, + }) + expect(units[1].unit).toEqual({ + type: "erb", content: "<%= x %>", isAtomic: true, breaksFlow: false, isHerbDisable: false, + }) + expect(units[2].unit).toEqual({ + type: "text", content: " after", isAtomic: false, breaksFlow: false, + }) + }) + + test("inline + ERB produces two atomic units with space between", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

a <%= x %>

") + const units = analyzer.buildContentUnits(body) + const atomicUnits = units.filter(unit => unit.unit.isAtomic && (unit.unit.type === "inline" || unit.unit.type === "erb")) + + expect(atomicUnits).toHaveLength(2) + expect(atomicUnits[0].unit.content).toBe("a") + expect(atomicUnits[1].unit.content).toBe("<%= x %>") + }) + + test("text + block + text produces breaksFlow in the middle", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("
before
block
after
") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(3) + expect(units[0].unit.type).toBe("text") + expect(units[1].unit.type).toBe("block") + expect(units[1].unit.breaksFlow).toBe(true) + expect(units[2].unit.type).toBe("text") + }) + + test("empty input produces empty result", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + expect(analyzer.buildContentUnits([])).toEqual([]) + }) + + test("multiple ERB nodes produce multiple ERB units", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

<%= a %> <%= b %> <%= c %>

") + const units = analyzer.buildContentUnits(body) + const erbUnits = units.filter(unit => unit.unit.type === "erb") + + expect(erbUnits).toHaveLength(3) + expect(erbUnits[0].unit.content).toBe("<%= a %>") + expect(erbUnits[1].unit.content).toBe("<%= b %>") + expect(erbUnits[2].unit.content).toBe("<%= c %>") + }) + + test("two adjacent inline elements produce two separate inline units", () => { + const analyzer = new TextFlowAnalyzer(createMockAnalyzerDelegate()) + const body = parseBody("

ab

") + const units = analyzer.buildContentUnits(body) + + expect(units).toHaveLength(2) + expect(units[0].unit).toEqual({ + type: "inline", content: "a", isAtomic: true, breaksFlow: false, + }) + expect(units[1].unit).toEqual({ + type: "inline", content: "b", isAtomic: true, breaksFlow: false, + }) + }) + }) + + describe("side effects", () => { + test("analyzer delegate has no push/visit methods — only serialization", () => { + const delegate = createMockAnalyzerDelegate() + + expect(typeof delegate.tryRenderInlineElement).toBe("function") + expect(typeof delegate.renderERBAsString).toBe("function") + expect((delegate as any).push).toBeUndefined() + expect((delegate as any).pushWithIndent).toBeUndefined() + expect((delegate as any).visit).toBeUndefined() + }) + + test("building content units only returns data, no output", () => { + const calls: string[] = [] + const delegate: TextFlowAnalyzerDelegate = { + tryRenderInlineElement(element: HTMLElementNode): string | null { + calls.push("tryRenderInlineElement") + return `<${getTagName(element)}>x` + }, + renderERBAsString(): string { + calls.push("renderERBAsString") + return "<%= erb %>" + }, + } + const analyzer = new TextFlowAnalyzer(delegate) + const body = parseBody("

Hello world and <%= name %>

") + + const units = analyzer.buildContentUnits(body) + + expect(calls.every(call => call === "tryRenderInlineElement" || call === "renderERBAsString")).toBe(true) + expect(units.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/javascript/packages/formatter/test/text-flow-engine.test.ts b/javascript/packages/formatter/test/text-flow-engine.test.ts new file mode 100644 index 000000000..5a0415f10 --- /dev/null +++ b/javascript/packages/formatter/test/text-flow-engine.test.ts @@ -0,0 +1,420 @@ +import { describe, test, expect, beforeAll } from "vitest" +import { Herb } from "@herb-tools/node-wasm" +import { isNode, getTagName } from "@herb-tools/core" +import { Node, HTMLTextNode, HTMLElementNode, ERBContentNode } from "@herb-tools/core" + +import { TextFlowEngine } from "../src/text-flow-engine.js" +import type { TextFlowDelegate } from "../src/text-flow-engine.js" + +function createMockDelegate(options: { indent?: string, maxLineLength?: number } = {}): TextFlowDelegate & { lines: string[], visitedNodes: Node[] } { + const lines: string[] = [] + const visitedNodes: Node[] = [] + const indent = options.indent ?? " " + const maxLineLength = options.maxLineLength ?? 80 + + return { + lines, + visitedNodes, + + get indent() { return indent }, + get maxLineLength() { return maxLineLength }, + + push(line: string) { + lines.push(line) + }, + + pushWithIndent(line: string) { + const indentPrefix = line.trim() === "" ? "" : indent + lines.push(indentPrefix + line) + }, + + renderInlineElementAsString(element: HTMLElementNode): string { + const tagName = getTagName(element) + const bodyText = element.body + .filter(c => isNode(c, HTMLTextNode)) + .map(c => (c as HTMLTextNode).content.trim()) + .join("") + + return `<${tagName}>${bodyText}` + }, + + renderERBAsString(node: ERBContentNode): string { + const opening = node.tag_opening?.value ?? "<%" + const content = node.content?.value ?? "" + const closing = node.tag_closing?.value ?? "%>" + + return `${opening}${content}${closing}` + }, + + tryRenderInlineElement(element: HTMLElementNode): string | null { + const tagName = getTagName(element) + const bodyText = element.body + .filter(c => isNode(c, HTMLTextNode)) + .map(c => (c as HTMLTextNode).content.trim()) + .join("") + + return `<${tagName}>${bodyText}` + }, + + visit(node: Node) { + visitedNodes.push(node) + } + } +} + +function parseBody(source: string) { + const result = Herb.parse(source) + const children = result.value.children + const element = children[0] + + if (isNode(element, HTMLElementNode)) { + return element.body + } + + return children +} + +describe("TextFlowEngine", () => { + beforeAll(async () => { + await Herb.load() + }) + + describe("isInTextFlowContext", () => { + test("detects text mixed with inline elements", () => { + const engine = new TextFlowEngine(createMockDelegate()) + const body = parseBody("

Hello world

") + + expect(engine.isInTextFlowContext(body)).toBe(true) + }) + + test("detects text mixed with ERB", () => { + const engine = new TextFlowEngine(createMockDelegate()) + const body = parseBody("

Hello <%= name %>

") + + expect(engine.isInTextFlowContext(body)).toBe(true) + }) + + test("rejects text-only content", () => { + const engine = new TextFlowEngine(createMockDelegate()) + const body = parseBody("

Hello world

") + + expect(engine.isInTextFlowContext(body)).toBe(false) + }) + + test("rejects content with block elements", () => { + const engine = new TextFlowEngine(createMockDelegate()) + const body = parseBody("
Hello
world
") + + expect(engine.isInTextFlowContext(body)).toBe(false) + }) + }) + + describe("isTextFlowNode", () => { + test("identifies text nodes", () => { + const engine = new TextFlowEngine(createMockDelegate()) + const body = parseBody("

hello

") + const textNode = body.find(c => isNode(c, HTMLTextNode) && c.content.trim() !== "") + + expect(textNode).toBeDefined() + expect(engine.isTextFlowNode(textNode!)).toBe(true) + }) + + test("identifies inline elements", () => { + const engine = new TextFlowEngine(createMockDelegate()) + const body = parseBody("

text

") + const emNode = body.find(c => isNode(c, HTMLElementNode)) + + expect(emNode).toBeDefined() + expect(engine.isTextFlowNode(emNode!)).toBe(true) + }) + + test("identifies ERB nodes", () => { + const engine = new TextFlowEngine(createMockDelegate()) + const children = Herb.parse("<%= foo %>").value.children + const erbNode = children.find(c => isNode(c, ERBContentNode)) + + expect(erbNode).toBeDefined() + expect(engine.isTextFlowNode(erbNode!)).toBe(true) + }) + }) + + describe("collectTextFlowRun", () => { + test("collects text + inline element runs", () => { + const engine = new TextFlowEngine(createMockDelegate()) + const body = parseBody("

Hello world more

") + const run = engine.collectTextFlowRun(body, 0) + + expect(run).not.toBeNull() + expect(run!.nodes.length).toBeGreaterThanOrEqual(2) + }) + + test("returns null for pure text", () => { + const engine = new TextFlowEngine(createMockDelegate()) + const body = parseBody("

Hello world

") + const run = engine.collectTextFlowRun(body, 0) + + expect(run).toBeNull() + }) + }) + + describe("word wrapping", () => { + test("wraps long text at maxLineLength boundary", () => { + const delegate = createMockDelegate({ indent: " ", maxLineLength: 30 }) + const engine = new TextFlowEngine(delegate) + const body = parseBody("

This is a long text with emphasis included

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([ + " This is a long text with", + " emphasis included", + ]) + }) + + test("keeps short content on a single line", () => { + const delegate = createMockDelegate({ maxLineLength: 80 }) + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Hi there

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" Hi there"]) + }) + + test("prepends indent to each wrapped line", () => { + const delegate = createMockDelegate({ indent: " ", maxLineLength: 30 }) + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Some words here with inline and more words

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([ + " Some words here with", + " inline and more", + " words", + ]) + }) + + test("subtracts indent width from available wrap width", () => { + const delegate = createMockDelegate({ indent: " ", maxLineLength: 40 }) + const engine = new TextFlowEngine(delegate) + const body = parseBody("

word1 word2 word3 word4 word5

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([ + " word1 word2 word3", + " word4 word5", + ]) + }) + + test("handles empty body gracefully", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + + engine.visitTextFlowChildren([]) + + expect(delegate.lines).toEqual([]) + }) + + test("trims trailing space from last word before output", () => { + const delegate = createMockDelegate({ maxLineLength: 80 }) + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Hello world

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" Hello world"]) + }) + }) + + describe("block element flushing", () => { + test("flushes words and visits block node when breaksFlow unit encountered", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + const body = parseBody("
before
block
after
") + + engine.visitTextFlowChildren(body) + + expect(delegate.visitedNodes).toHaveLength(1) + expect(isNode(delegate.visitedNodes[0], HTMLElementNode)).toBe(true) + expect(delegate.lines).toEqual([" before", " after"]) + }) + + test("does not visit non-block inline content", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Hello world

") + + engine.visitTextFlowChildren(body) + + expect(delegate.visitedNodes).toHaveLength(0) + }) + }) + + describe("adjacent inline elements", () => { + test("renders 2+ adjacent inline elements together via pushWithIndent", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + const body = parseBody("

onetwo

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" onetwo"]) + }) + + test("renders mixed inline + ERB adjacent elements together", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + const body = parseBody("

one<%= x %>

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" one<%= x %>"]) + }) + + test("processes remaining children as text flow after adjacent inline group", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + const body = parseBody("

ab and some text here

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([ + " ab", + " and some text here", + ]) + }) + + test("single inline element uses text flow path instead of adjacent grouping", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Hello world end

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" Hello world end"]) + }) + + test("punctuation text following adjacent inline elements is merged", () => { + const delegate = createMockDelegate({ maxLineLength: 80 }) + const engine = new TextFlowEngine(delegate) + const body = parseBody("

ab.

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" ab."]) + }) + }) + + describe("text flow rendering", () => { + test("produces output for text + inline content", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Hello world

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" Hello world"]) + }) + + test("produces output for text + ERB content", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Hello <%= name %>

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" Hello <%= name %>"]) + }) + + test("handles multiple inline elements in text flow", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Text one and two end

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" Text one and two end"]) + }) + + test("handles ERB mixed with inline elements", () => { + const delegate = createMockDelegate() + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Hello <%= name %> and world

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" Hello <%= name %> and world"]) + }) + + test("atomic units are kept together (not broken mid-tag)", () => { + const delegate = createMockDelegate({ indent: " ", maxLineLength: 30 }) + const engine = new TextFlowEngine(delegate) + const body = parseBody("

text longword

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([" text longword"]) + }) + + test("closing punctuation is not separated from preceding content", () => { + const delegate = createMockDelegate({ indent: " ", maxLineLength: 30 }) + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Some long text that should wrap nicely.

") + + engine.visitTextFlowChildren(body) + + expect(delegate.lines).toEqual([ + " Some long text that should", + " wrap nicely.", + ]) + }) + }) + + describe("delegate interface", () => { + test("TextFlowDelegate extends TextFlowAnalyzerDelegate", () => { + const delegate = createMockDelegate() + + expect(typeof delegate.tryRenderInlineElement).toBe("function") + expect(typeof delegate.renderERBAsString).toBe("function") + + expect(typeof delegate.push).toBe("function") + expect(typeof delegate.pushWithIndent).toBe("function") + expect(typeof delegate.renderInlineElementAsString).toBe("function") + expect(typeof delegate.visit).toBe("function") + expect(typeof delegate.indent).toBe("string") + expect(typeof delegate.maxLineLength).toBe("number") + }) + + test("all output goes through delegate push/pushWithIndent", () => { + const pushCalls: string[] = [] + const pushWithIndentCalls: string[] = [] + + const delegate: TextFlowDelegate & { lines: string[], visitedNodes: Node[] } = { + lines: [], + visitedNodes: [], + get indent() { return " " }, + get maxLineLength() { return 80 }, + push(line: string) { pushCalls.push(line); this.lines.push(line) }, + pushWithIndent(line: string) { pushWithIndentCalls.push(line); this.lines.push(" " + line) }, + renderInlineElementAsString(element: HTMLElementNode) { + return `<${getTagName(element)}>x` + }, + renderERBAsString() { return "<%= x %>" }, + tryRenderInlineElement(element: HTMLElementNode) { + return `<${getTagName(element)}>x` + }, + visit(node: Node) { this.visitedNodes.push(node) }, + } + + const engine = new TextFlowEngine(delegate) + const body = parseBody("

Hello world

") + + engine.visitTextFlowChildren(body) + + expect(pushCalls).toEqual([" Hello x"]) + expect(pushWithIndentCalls).toEqual([]) + }) + }) +}) diff --git a/javascript/packages/formatter/test/text-flow-helpers.test.ts b/javascript/packages/formatter/test/text-flow-helpers.test.ts new file mode 100644 index 000000000..061278ba7 --- /dev/null +++ b/javascript/packages/formatter/test/text-flow-helpers.test.ts @@ -0,0 +1,402 @@ +import { describe, test, expect, beforeAll } from "vitest" +import { Herb } from "@herb-tools/node-wasm" +import { isNode } from "@herb-tools/core" +import { HTMLTextNode, HTMLElementNode, ERBContentNode, WhitespaceNode } from "@herb-tools/core" + +import type { ContentUnitWithNode } from "../src/format-helpers.js" + +import { + isTextFlowNode, + isTextFlowWhitespace, + collectTextFlowRun, + isInTextFlowContext, + tryMergeTextAfterAtomic, + tryMergeAtomicAfterText, + lastUnitEndsWithWhitespace, + wrapRemainingWords, + tryMergePunctuationText, +} from "../src/text-flow-helpers.js" + +function parseChildren(source: string) { + const result = Herb.parse(source) + return result.value.children +} + +function parseBody(source: string) { + const children = parseChildren(source) + const element = children[0] + + if (isNode(element, HTMLElementNode)) { + return element.body + } + + return children +} + +describe("text-flow-helpers", () => { + beforeAll(async () => { + await Herb.load() + }) + + describe("isTextFlowNode", () => { + test("returns true for non-empty text nodes", () => { + const children = parseChildren("hello world") + const textNode = children.find(c => isNode(c, HTMLTextNode) && c.content.trim() !== "") + + expect(textNode).toBeDefined() + expect(isTextFlowNode(textNode!)).toBe(true) + }) + + test("returns false for whitespace-only text nodes", () => { + const body = parseBody("
") + const wsNode = body.find(c => isNode(c, HTMLTextNode) && c.content.trim() === "") + + if (wsNode) { + expect(isTextFlowNode(wsNode)).toBe(false) + } + }) + + test("returns true for ERB content nodes", () => { + const children = parseChildren("<%= foo %>") + const erbNode = children.find(c => isNode(c, ERBContentNode)) + + expect(erbNode).toBeDefined() + expect(isTextFlowNode(erbNode!)).toBe(true) + }) + + test("returns true for inline HTML elements", () => { + const body = parseBody("

text

") + const emNode = body.find(c => isNode(c, HTMLElementNode)) + + expect(emNode).toBeDefined() + expect(isTextFlowNode(emNode!)).toBe(true) + }) + + test("returns false for block HTML elements", () => { + const body = parseBody("
text
") + const innerDiv = body.find(c => isNode(c, HTMLElementNode)) + + expect(innerDiv).toBeDefined() + expect(isTextFlowNode(innerDiv!)).toBe(false) + }) + }) + + describe("isTextFlowWhitespace", () => { + test("returns true for WhitespaceNode", () => { + const body = parseBody("

a b

") + const wsNode = body.find(c => isNode(c, WhitespaceNode)) + + if (wsNode) { + expect(isTextFlowWhitespace(wsNode)).toBe(true) + } + }) + + test("returns true for single-newline whitespace text nodes", () => { + const body = parseBody("

\na

") + const wsTextNode = body.find(c => isNode(c, HTMLTextNode) && c.content.trim() === "" && !c.content.includes('\n\n')) + + if (wsTextNode) { + expect(isTextFlowWhitespace(wsTextNode)).toBe(true) + } + }) + + test("returns false for double-newline text nodes", () => { + const body = parseBody("

\n\na

") + const doubleNewlineNode = body.find(c => isNode(c, HTMLTextNode) && c.content.includes('\n\n')) + + if (doubleNewlineNode) { + expect(isTextFlowWhitespace(doubleNewlineNode)).toBe(false) + } + }) + }) + + describe("isInTextFlowContext", () => { + test("returns true for text mixed with inline elements", () => { + const body = parseBody("

Hello world

") + expect(isInTextFlowContext(body)).toBe(true) + }) + + test("returns true for text mixed with ERB", () => { + const body = parseBody("

Hello <%= name %>

") + expect(isInTextFlowContext(body)).toBe(true) + }) + + test("returns false for text-only content", () => { + const body = parseBody("

Hello world

") + expect(isInTextFlowContext(body)).toBe(false) + }) + + test("returns false for block elements mixed with text", () => { + const body = parseBody("
Hello
world
") + expect(isInTextFlowContext(body)).toBe(false) + }) + + test("returns false for no text content", () => { + const body = parseBody("
only inline
") + expect(isInTextFlowContext(body)).toBe(false) + }) + }) + + describe("collectTextFlowRun", () => { + test("collects a run of text + inline elements", () => { + const body = parseBody("

Hello world text

") + const run = collectTextFlowRun(body, 0) + + expect(run).not.toBeNull() + expect(run!.nodes.length).toBeGreaterThanOrEqual(2) + expect(run!.endIndex).toBeGreaterThan(0) + }) + + test("collects a run of text + ERB nodes", () => { + const body = parseBody("

Hello <%= name %> text

") + const run = collectTextFlowRun(body, 0) + + expect(run).not.toBeNull() + expect(run!.nodes.length).toBeGreaterThanOrEqual(2) + }) + + test("returns null for text-only content", () => { + const body = parseBody("

Hello world

") + const run = collectTextFlowRun(body, 0) + + expect(run).toBeNull() + }) + + test("returns null for a single text node", () => { + const body = parseBody("

hello

") + const run = collectTextFlowRun(body, 0) + + expect(run).toBeNull() + }) + + test("includes whitespace between flow nodes", () => { + const body = parseBody("

Hello world more

") + const run = collectTextFlowRun(body, 0) + + expect(run).not.toBeNull() + expect(run!.nodes.length).toBeGreaterThanOrEqual(3) + }) + }) + + + describe("tryMergeTextAfterAtomic", () => { + test("merges first word of text into preceding atomic unit", () => { + const children = parseChildren("hello") + const textNode = children.find(c => isNode(c, HTMLTextNode)) as any + + const result: ContentUnitWithNode[] = [{ + unit: { content: '<%= tag %>', type: 'erb', isAtomic: true, breaksFlow: false }, + node: null + }] + + const fakeTextNode = { ...textNode, content: "text more" } + const merged = tryMergeTextAfterAtomic(result, fakeTextNode) + + expect(merged).toBe(true) + expect(result[0].unit.content).toBe('<%= tag %>text') + expect(result.length).toBe(2) + expect(result[1].unit.content).toBe('more') + }) + + test("still merges when raw content has leading space (normalizeAndSplitWords trims)", () => { + const children = parseChildren("hello") + const textNode = children.find(c => isNode(c, HTMLTextNode)) as any + + const result: ContentUnitWithNode[] = [{ + unit: { content: '<%= tag %>', type: 'erb', isAtomic: true, breaksFlow: false }, + node: null + }] + + const fakeTextNode = { ...textNode, content: " text" } + const merged = tryMergeTextAfterAtomic(result, fakeTextNode) + + expect(merged).toBe(true) + expect(result[0].unit.content).toBe('<%= tag %>text') + }) + + test("does not merge when last unit is not atomic", () => { + const children = parseChildren("hello") + const textNode = children.find(c => isNode(c, HTMLTextNode)) as any + + const result: ContentUnitWithNode[] = [{ + unit: { content: 'some text', type: 'text', isAtomic: false, breaksFlow: false }, + node: null + }] + + const fakeTextNode = { ...textNode, content: "more" } + const merged = tryMergeTextAfterAtomic(result, fakeTextNode) + + expect(merged).toBe(false) + }) + + test("returns false for empty result array", () => { + const children = parseChildren("hello") + const textNode = children.find(c => isNode(c, HTMLTextNode)) as any + + const result: ContentUnitWithNode[] = [] + const merged = tryMergeTextAfterAtomic(result, textNode) + + expect(merged).toBe(false) + }) + }) + + describe("tryMergeAtomicAfterText", () => { + test("merges atomic content with last word of preceding text", () => { + const children = parseChildren("hello world") + const textNode = children[0] + + const result: ContentUnitWithNode[] = [{ + unit: { content: 'hello world', type: 'text', isAtomic: false, breaksFlow: false }, + node: textNode + }] + + const merged = tryMergeAtomicAfterText(result, children, 0, '<%= tag %>', 'erb', textNode) + + expect(merged).toBe(true) + expect(result.length).toBe(2) + expect(result[0].unit.content).toBe('hello') + expect(result[0].unit.type).toBe('text') + expect(result[1].unit.content).toBe('world<%= tag %>') + expect(result[1].unit.type).toBe('erb') + expect(result[1].unit.isAtomic).toBe(true) + }) + + test("does not merge when last unit is atomic", () => { + const children = parseChildren("hello") + + const result: ContentUnitWithNode[] = [{ + unit: { content: 'text', type: 'inline', isAtomic: true, breaksFlow: false }, + node: null + }] + + const merged = tryMergeAtomicAfterText(result, children, 0, '<%= tag %>', 'erb', children[0]) + + expect(merged).toBe(false) + }) + + test("returns false for empty result array", () => { + const children = parseChildren("hello") + const result: ContentUnitWithNode[] = [] + const merged = tryMergeAtomicAfterText(result, children, 0, '<%= tag %>', 'erb', children[0]) + + expect(merged).toBe(false) + }) + }) + + describe("lastUnitEndsWithWhitespace", () => { + test("returns true when last text unit ends with space", () => { + const result: ContentUnitWithNode[] = [{ + unit: { content: 'hello ', type: 'text', isAtomic: false, breaksFlow: false }, + node: null + }] + + expect(lastUnitEndsWithWhitespace(result)).toBe(true) + }) + + test("returns false when last text unit does not end with space", () => { + const result: ContentUnitWithNode[] = [{ + unit: { content: 'hello', type: 'text', isAtomic: false, breaksFlow: false }, + node: null + }] + + expect(lastUnitEndsWithWhitespace(result)).toBe(false) + }) + + test("returns false when last unit is not text type", () => { + const result: ContentUnitWithNode[] = [{ + unit: { content: '<%= foo %> ', type: 'erb', isAtomic: true, breaksFlow: false }, + node: null + }] + + expect(lastUnitEndsWithWhitespace(result)).toBe(false) + }) + + test("returns false for empty array", () => { + expect(lastUnitEndsWithWhitespace([])).toBe(false) + }) + }) + + describe("wrapRemainingWords", () => { + test("wraps words within width", () => { + const lines = wrapRemainingWords(["hello", "world"], 80, " ") + expect(lines).toEqual([" hello world"]) + }) + + test("wraps to multiple lines when exceeding width", () => { + const lines = wrapRemainingWords(["aaaa", "bbbb", "cccc"], 10, " ") + expect(lines).toEqual([" aaaa bbbb", " cccc"]) + }) + + test("handles single word", () => { + const lines = wrapRemainingWords(["hello"], 80, " ") + expect(lines).toEqual([" hello"]) + }) + + test("handles empty input", () => { + const lines = wrapRemainingWords([], 80, " ") + expect(lines).toEqual([]) + }) + + test("each word on its own line when very narrow", () => { + const lines = wrapRemainingWords(["hello", "world", "test"], 5, "") + expect(lines).toEqual(["hello", "world", "test"]) + }) + + test("respects indent in width calculation", () => { + const lines = wrapRemainingWords(["aaaa", "bbbb", "cccc"], 10, " ") + expect(lines).toEqual([" aaaa bbbb", " cccc"]) + }) + }) + + describe("tryMergePunctuationText", () => { + test("merges punctuation when combined fits within width", () => { + const result = tryMergePunctuationText("<%= tag %>", ". More text", 80, " ") + + expect(result.mergedContent).toBe("<%= tag %>. More text") + expect(result.shouldStop).toBe(false) + expect(result.wrappedLines).toEqual([]) + }) + + test("attaches punctuation even when rest overflows", () => { + const result = tryMergePunctuationText("<%= tag %>", ". overflow", 15, " ") + + expect(result.mergedContent).toContain("<%= tag %>.") + expect(result.shouldStop).toBe(true) + }) + + test("does not merge non-punctuation text that overflows", () => { + const result = tryMergePunctuationText("<%= tag %>", "not punctuation", 15, " ") + + expect(result.mergedContent).toBe("<%= tag %>") + expect(result.shouldStop).toBe(false) + }) + + test("handles punctuation-only text", () => { + const result = tryMergePunctuationText("<%= tag %>", ".", 80, " ") + + expect(result.mergedContent).toBe("<%= tag %>.") + expect(result.shouldStop).toBe(false) + expect(result.wrappedLines).toEqual([]) + }) + + test("wraps remaining words after punctuation", () => { + const result = tryMergePunctuationText( + "a".repeat(70), + ". word1 word2 word3", + 80, + " " + ) + + expect(result.mergedContent).toContain(".") + expect(result.shouldStop).toBe(true) + expect(result.wrappedLines.length).toBeGreaterThan(0) + }) + + test("handles multiple punctuation characters", () => { + const result = tryMergePunctuationText("<%= tag %>", "!? More text", 80, " ") + + expect(result.mergedContent).toBe("<%= tag %>!? More text") + expect(result.shouldStop).toBe(false) + }) + }) +})