|
| 1 | +import { |
| 2 | + isERBCommentNode, |
| 3 | + isLiteralNode, |
| 4 | + createLiteral, |
| 5 | + HTMLCommentNode, |
| 6 | + Token, |
| 7 | + Location as HerbLocation, |
| 8 | + Range as HerbRange, |
| 9 | +} from "@herb-tools/core" |
| 10 | + |
| 11 | +import { IdentityPrinter } from "@herb-tools/printer" |
| 12 | +import { asMutable } from "@herb-tools/rewriter" |
| 13 | + |
| 14 | +import { ParserService } from "./parser_service" |
| 15 | +import { LineContextCollector } from "./line_context_collector" |
| 16 | + |
| 17 | +import type { |
| 18 | + Node, |
| 19 | + ERBContentNode, |
| 20 | +} from "@herb-tools/core" |
| 21 | + |
| 22 | +/** |
| 23 | + * Commenting strategy for a line: |
| 24 | + * - "single-erb": sole ERB tag on the line → insert # at column offset |
| 25 | + * - "all-erb": multiple ERB tags with no significant HTML → # into each |
| 26 | + * - "per-segment": control-flow ERB wrapping HTML → # each ERB, <!-- --> each HTML segment |
| 27 | + * - "whole-line": output ERB mixed with HTML → wrap entire line in <!-- --> with ERB # |
| 28 | + * - "html-only": pure HTML content → wrap in <!-- --> |
| 29 | + */ |
| 30 | +export type CommentStrategy = "single-erb" | "all-erb" | "per-segment" | "whole-line" | "html-only" |
| 31 | + |
| 32 | +interface LineSegment { |
| 33 | + text: string |
| 34 | + isERB: boolean |
| 35 | + node?: ERBContentNode |
| 36 | +} |
| 37 | + |
| 38 | +export function createSyntheticToken(type: string, value: string): Token { |
| 39 | + return new Token(value, HerbRange.zero, HerbLocation.zero, type) |
| 40 | +} |
| 41 | + |
| 42 | +export function commentERBNode(node: ERBContentNode): void { |
| 43 | + const mutable = asMutable(node) |
| 44 | + |
| 45 | + if (mutable.tag_opening) { |
| 46 | + const currentValue = mutable.tag_opening.value |
| 47 | + mutable.tag_opening = createSyntheticToken( |
| 48 | + mutable.tag_opening.type, |
| 49 | + currentValue.substring(0, 2) + "#" + currentValue.substring(2) |
| 50 | + ) |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +export function uncommentERBNode(node: ERBContentNode): void { |
| 55 | + const mutable = asMutable(node) |
| 56 | + |
| 57 | + if (mutable.tag_opening && mutable.tag_opening.value === "<%#") { |
| 58 | + const contentValue = mutable.content?.value || "" |
| 59 | + |
| 60 | + if ( |
| 61 | + contentValue.startsWith(" graphql ") || |
| 62 | + contentValue.startsWith(" %= ") || |
| 63 | + contentValue.startsWith(" == ") || |
| 64 | + contentValue.startsWith(" % ") || |
| 65 | + contentValue.startsWith(" = ") || |
| 66 | + contentValue.startsWith(" - ") |
| 67 | + ) { |
| 68 | + mutable.tag_opening = createSyntheticToken(mutable.tag_opening.type, "<%") |
| 69 | + mutable.content = createSyntheticToken(mutable.content!.type, contentValue.substring(1)) |
| 70 | + } else { |
| 71 | + mutable.tag_opening = createSyntheticToken(mutable.tag_opening.type, "<%") |
| 72 | + } |
| 73 | + } |
| 74 | +} |
| 75 | + |
| 76 | +export function determineStrategy(erbNodes: ERBContentNode[], lineText: string): CommentStrategy { |
| 77 | + if (erbNodes.length === 0) { |
| 78 | + return "html-only" |
| 79 | + } |
| 80 | + |
| 81 | + if (erbNodes.length === 1) { |
| 82 | + const node = erbNodes[0] |
| 83 | + if (!node.tag_opening || !node.tag_closing) return "html-only" |
| 84 | + |
| 85 | + const nodeStart = node.tag_opening.location.start.column |
| 86 | + const nodeEnd = node.tag_closing.location.end.column |
| 87 | + const isSoleContent = lineText.substring(0, nodeStart).trim() === "" && lineText.substring(nodeEnd).trim() === "" |
| 88 | + |
| 89 | + if (isSoleContent) { |
| 90 | + return "single-erb" |
| 91 | + } |
| 92 | + |
| 93 | + return "whole-line" |
| 94 | + } |
| 95 | + |
| 96 | + const segments = getLineSegments(lineText, erbNodes) |
| 97 | + const hasHTML = segments.some(s => !s.isERB && s.text.trim() !== "") |
| 98 | + |
| 99 | + if (!hasHTML) { |
| 100 | + return "all-erb" |
| 101 | + } |
| 102 | + |
| 103 | + const allControlTags = erbNodes.every(n => n.tag_opening?.value === "<%") |
| 104 | + |
| 105 | + if (allControlTags) { |
| 106 | + return "per-segment" |
| 107 | + } |
| 108 | + |
| 109 | + return "whole-line" |
| 110 | +} |
| 111 | + |
| 112 | +function getLineSegments(lineText: string, erbNodes: ERBContentNode[]): LineSegment[] { |
| 113 | + const segments: LineSegment[] = [] |
| 114 | + let pos = 0 |
| 115 | + |
| 116 | + const sorted = [...erbNodes].sort( |
| 117 | + (a, b) => a.tag_opening!.location.start.column - b.tag_opening!.location.start.column |
| 118 | + ) |
| 119 | + |
| 120 | + for (const node of sorted) { |
| 121 | + const nodeStart = node.tag_opening!.location.start.column |
| 122 | + const nodeEnd = node.tag_closing!.location.end.column |
| 123 | + |
| 124 | + if (nodeStart > pos) { |
| 125 | + segments.push({ text: lineText.substring(pos, nodeStart), isERB: false }) |
| 126 | + } |
| 127 | + |
| 128 | + segments.push({ text: lineText.substring(nodeStart, nodeEnd), isERB: true, node }) |
| 129 | + pos = nodeEnd |
| 130 | + } |
| 131 | + |
| 132 | + if (pos < lineText.length) { |
| 133 | + segments.push({ text: lineText.substring(pos), isERB: false }) |
| 134 | + } |
| 135 | + |
| 136 | + return segments |
| 137 | +} |
| 138 | + |
| 139 | +/** |
| 140 | + * Comment a line using AST mutation for strategies where the parser produces flat children, |
| 141 | + * and text-segment manipulation for per-segment (where the parser nests nodes). |
| 142 | + */ |
| 143 | +export function commentLineContent( |
| 144 | + content: string, |
| 145 | + erbNodes: ERBContentNode[], |
| 146 | + strategy: CommentStrategy, |
| 147 | + parserService: ParserService |
| 148 | +): string { |
| 149 | + if (strategy === "per-segment") { |
| 150 | + return commentPerSegment(content, erbNodes) |
| 151 | + } |
| 152 | + |
| 153 | + const parseResult = parserService.parseContent(content, { track_whitespace: true }) |
| 154 | + const lineCollector = new LineContextCollector() |
| 155 | + parseResult.visit(lineCollector) |
| 156 | + const lineERBNodes = lineCollector.erbNodesPerLine.get(0) || [] |
| 157 | + const doc = parseResult.value |
| 158 | + const children = asMutable(doc).children |
| 159 | + |
| 160 | + switch (strategy) { |
| 161 | + case "all-erb": |
| 162 | + for (const node of lineERBNodes) { |
| 163 | + commentERBNode(node) |
| 164 | + } |
| 165 | + break |
| 166 | + |
| 167 | + case "whole-line": { |
| 168 | + for (const node of lineERBNodes) { |
| 169 | + commentERBNode(node) |
| 170 | + } |
| 171 | + |
| 172 | + const commentNode = new HTMLCommentNode({ |
| 173 | + type: "AST_HTML_COMMENT_NODE", |
| 174 | + location: HerbLocation.zero, |
| 175 | + errors: [], |
| 176 | + comment_start: createSyntheticToken("TOKEN_HTML_COMMENT_START", "<!--"), |
| 177 | + children: [createLiteral(" "), ...(children.slice() as Node[]), createLiteral(" ")], |
| 178 | + comment_end: createSyntheticToken("TOKEN_HTML_COMMENT_END", "-->"), |
| 179 | + }) |
| 180 | + |
| 181 | + children.length = 0 |
| 182 | + children.push(commentNode) |
| 183 | + break |
| 184 | + } |
| 185 | + |
| 186 | + case "html-only": { |
| 187 | + const commentNode = new HTMLCommentNode({ |
| 188 | + type: "AST_HTML_COMMENT_NODE", |
| 189 | + location: HerbLocation.zero, |
| 190 | + errors: [], |
| 191 | + comment_start: createSyntheticToken("TOKEN_HTML_COMMENT_START", "<!--"), |
| 192 | + children: [createLiteral(" "), ...(children.slice() as Node[]), createLiteral(" ")], |
| 193 | + comment_end: createSyntheticToken("TOKEN_HTML_COMMENT_END", "-->"), |
| 194 | + }) |
| 195 | + |
| 196 | + children.length = 0 |
| 197 | + children.push(commentNode) |
| 198 | + break |
| 199 | + } |
| 200 | + } |
| 201 | + |
| 202 | + return IdentityPrinter.print(doc, { ignoreErrors: true }) |
| 203 | +} |
| 204 | + |
| 205 | +/** |
| 206 | + * Per-segment commenting uses text segments because the parser creates nested |
| 207 | + * structures (e.g., ERBIfNode) that don't allow flat child iteration. |
| 208 | + */ |
| 209 | +function commentPerSegment(content: string, erbNodes: ERBContentNode[]): string { |
| 210 | + const segments = getLineSegments(content, erbNodes) |
| 211 | + |
| 212 | + return segments.map(segment => { |
| 213 | + if (segment.isERB) { |
| 214 | + return segment.text.substring(0, 2) + "#" + segment.text.substring(2) |
| 215 | + } else if (segment.text.trim() !== "") { |
| 216 | + return `<!-- ${segment.text} -->` |
| 217 | + } |
| 218 | + |
| 219 | + return segment.text |
| 220 | + }).join("") |
| 221 | +} |
| 222 | + |
| 223 | +export function uncommentLineContent(content: string, parserService: ParserService): string { |
| 224 | + const parseResult = parserService.parseContent(content, { track_whitespace: true }) |
| 225 | + const lineCollector = new LineContextCollector() |
| 226 | + |
| 227 | + parseResult.visit(lineCollector) |
| 228 | + |
| 229 | + const lineERBNodes = lineCollector.erbNodesPerLine.get(0) || [] |
| 230 | + const document = parseResult.value |
| 231 | + const children = asMutable(document).children |
| 232 | + |
| 233 | + for (const node of lineERBNodes) { |
| 234 | + if (isERBCommentNode(node)) { |
| 235 | + uncommentERBNode(node) |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + let i = 0 |
| 240 | + |
| 241 | + while (i < children.length) { |
| 242 | + const child = children[i] |
| 243 | + |
| 244 | + if (child.type === "AST_HTML_COMMENT_NODE") { |
| 245 | + const commentNode = child as HTMLCommentNode |
| 246 | + const innerChildren = [...commentNode.children] |
| 247 | + |
| 248 | + if (innerChildren.length > 0) { |
| 249 | + const first = innerChildren[0] |
| 250 | + |
| 251 | + if (isLiteralNode(first) && first.content.startsWith(" ")) { |
| 252 | + const trimmed = first.content.substring(1) |
| 253 | + |
| 254 | + if (trimmed === "") { |
| 255 | + innerChildren.shift() |
| 256 | + } else { |
| 257 | + innerChildren[0] = createLiteral(trimmed) |
| 258 | + } |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + if (innerChildren.length > 0) { |
| 263 | + const last = innerChildren[innerChildren.length - 1] |
| 264 | + |
| 265 | + if (isLiteralNode(last) && last.content.endsWith(" ")) { |
| 266 | + const trimmed = last.content.substring(0, last.content.length - 1) |
| 267 | + |
| 268 | + if (trimmed === "") { |
| 269 | + innerChildren.pop() |
| 270 | + } else { |
| 271 | + innerChildren[innerChildren.length - 1] = createLiteral(trimmed) |
| 272 | + } |
| 273 | + } |
| 274 | + } |
| 275 | + |
| 276 | + const innerERBNodes: ERBContentNode[] = [] |
| 277 | + const innerCollector = new LineContextCollector() |
| 278 | + |
| 279 | + for (const innerChild of innerChildren) { |
| 280 | + innerCollector.visit(innerChild) |
| 281 | + } |
| 282 | + |
| 283 | + innerERBNodes.push(...(innerCollector.erbNodesPerLine.get(0) || [])) |
| 284 | + |
| 285 | + for (const erbNode of innerERBNodes) { |
| 286 | + if (isERBCommentNode(erbNode)) { |
| 287 | + uncommentERBNode(erbNode) |
| 288 | + } |
| 289 | + } |
| 290 | + |
| 291 | + children.splice(i, 1, ...innerChildren) |
| 292 | + i += innerChildren.length |
| 293 | + continue |
| 294 | + } |
| 295 | + |
| 296 | + i++ |
| 297 | + } |
| 298 | + |
| 299 | + return IdentityPrinter.print(document, { ignoreErrors: true }) |
| 300 | +} |
0 commit comments