From 01a41307f7f8c7c9c12aa782035b6efa8419f26f Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 28 Mar 2026 12:48:05 -0500 Subject: [PATCH] Harden ignore ranges by moving them to the lexer/parser --- src/ignore-markers.ts | 34 ++++ src/lexer/ignore-ranges.ts | 145 ++++++++++++++ src/lexer/index.ts | 1 + src/lexer/lexer.ts | 180 +++++++++++++++++- src/lexer/types.ts | 23 +++ src/parser.ts | 34 +++- src/print/blade-syntax.ts | 1 + src/print/children.ts | 159 +++++++++------- src/print/directive.ts | 160 +++++++++++----- src/print/index.ts | 18 +- src/print/utils.ts | 92 ++++----- src/tree/tree-builder.ts | 4 + src/tree/types.ts | 4 + tests/directives/format-ignore.test.ts | 114 ++++++++++- tests/helpers.ts | 12 ++ tests/html/ignore-range.test.ts | 178 +++++++++++++++++ tests/lexer/ignore-range.test.ts | 117 ++++++++++++ tests/parser/ignore-range.test.ts | 72 +++++++ tests/support/ignore-range.ts | 84 ++++++++ .../formatter-cases.test.ts.snap | 48 +++-- .../conformance/support/suite-runner.ts | 2 + .../filament-fixtures.test.ts.snap | 4 +- .../properties/fragment-wrapper-depth.test.ts | 121 ++++++++++-- .../malformed-recovery-cases.test.ts | 18 +- .../properties/malformed-tail.test.ts | 13 +- tests/validation/support/fixture-suite.ts | 9 + 26 files changed, 1431 insertions(+), 216 deletions(-) create mode 100644 src/ignore-markers.ts create mode 100644 src/lexer/ignore-ranges.ts create mode 100644 tests/html/ignore-range.test.ts create mode 100644 tests/lexer/ignore-range.test.ts create mode 100644 tests/parser/ignore-range.test.ts create mode 100644 tests/support/ignore-range.ts diff --git a/src/ignore-markers.ts b/src/ignore-markers.ts new file mode 100644 index 0000000..505fd1a --- /dev/null +++ b/src/ignore-markers.ts @@ -0,0 +1,34 @@ +export type IgnoreCommentKind = "ignore" | "ignore-start" | "ignore-end"; +export type IgnoreCommentWrapper = "blade" | "html"; + +export function getIgnoreCommentKindFromPayload(payload: string): IgnoreCommentKind | null { + switch (payload.trim().toLowerCase()) { + case "format-ignore": + case "prettier-ignore": + return "ignore"; + case "format-ignore-start": + case "prettier-ignore-start": + return "ignore-start"; + case "format-ignore-end": + case "prettier-ignore-end": + return "ignore-end"; + default: + return null; + } +} + +export function getIgnoreCommentKindFromCommentText( + text: string, + wrapper: IgnoreCommentWrapper, +): IgnoreCommentKind | null { + const payload = + wrapper === "blade" + ? (text.match(/^\{\{--\s*([\s\S]*?)\s*--\}\}$/s)?.[1] ?? null) + : (text.match(/^$/s)?.[1] ?? null); + + if (payload === null) { + return null; + } + + return getIgnoreCommentKindFromPayload(payload); +} diff --git a/src/lexer/ignore-ranges.ts b/src/lexer/ignore-ranges.ts new file mode 100644 index 0000000..5fa4a9a --- /dev/null +++ b/src/lexer/ignore-ranges.ts @@ -0,0 +1,145 @@ +import { getIgnoreCommentKindFromCommentText } from "../ignore-markers.js"; +import { Directives } from "./directives.js"; +import { Lexer, type LexerRawBlockConfig } from "./lexer.js"; +import { State, type IgnoreRangeRegion, type IgnoreRangeResumeState } from "./types.js"; + +function isTagIgnoreState(state: State): boolean { + switch (state) { + case State.TagName: + case State.BeforeAttrName: + case State.AttrName: + case State.AfterAttrName: + case State.BeforeAttrValue: + case State.AttrValueUnquoted: + return true; + default: + return false; + } +} + +function isIgnoreMarkerAllowed(wrapper: "blade" | "html", originState: State): boolean { + if (originState === State.AttrValueQuoted) { + return false; + } + + if (wrapper === "html") { + return originState !== State.RawText; + } + + return true; +} + +class IgnoreRangeCollectorImpl { + private ranges: IgnoreRangeRegion[] = []; + private depth = 0; + private outerStart = -1; + + constructor(private readonly source: string) {} + + handleBladeComment( + start: number, + end: number, + originState: State, + tagStart: number | null, + resume: IgnoreRangeResumeState, + ): void { + this.handleComment("blade", start, end, originState, tagStart, resume); + } + + handleHtmlComment( + start: number, + end: number, + originState: State, + tagStart: number | null, + resume: IgnoreRangeResumeState, + ): void { + this.handleComment("html", start, end, originState, tagStart, resume); + } + + finish(resume: IgnoreRangeResumeState, eof: number): IgnoreRangeRegion[] { + if (this.depth > 0 && this.outerStart >= 0) { + this.ranges.push({ + start: this.outerStart, + end: eof, + resume, + }); + this.depth = 0; + this.outerStart = -1; + } + + return this.ranges; + } + + private handleComment( + wrapper: "blade" | "html", + start: number, + end: number, + originState: State, + tagStart: number | null, + resume: IgnoreRangeResumeState, + ): void { + if (!isIgnoreMarkerAllowed(wrapper, originState)) { + return; + } + + const kind = getIgnoreCommentKindFromCommentText(this.source.slice(start, end), wrapper); + if (!kind) { + return; + } + + if (kind === "ignore-start") { + if (this.depth === 0) { + this.outerStart = + isTagIgnoreState(originState) && tagStart !== null && tagStart >= 0 ? tagStart : start; + } + this.depth++; + return; + } + + if (this.depth === 0) { + return; + } + + this.depth--; + if (this.depth !== 0) { + return; + } + + this.ranges.push({ + start: this.outerStart >= 0 ? this.outerStart : start, + end, + resume, + }); + this.outerStart = -1; + } +} + +export function collectIgnoreRanges( + source: string, + directives?: Directives, + rawBlockConfig?: LexerRawBlockConfig, +): IgnoreRangeRegion[] { + const collector = new IgnoreRangeCollectorImpl(source); + new Lexer(source, directives, { + ...rawBlockConfig, + ignoreRangeCollector: collector, + }).tokenize(); + + return collector.finish( + { + state: State.Data, + returnState: State.Data, + rawtextTagName: "", + currentTagName: "", + isClosingTag: false, + continuedTagName: false, + inXmlDeclaration: false, + verbatim: false, + verbatimReturnState: null, + phpBlock: false, + phpTag: false, + attrPhpDirectiveDepth: 0, + }, + source.length, + ); +} diff --git a/src/lexer/index.ts b/src/lexer/index.ts index b6e3f26..6cd158d 100644 --- a/src/lexer/index.ts +++ b/src/lexer/index.ts @@ -7,6 +7,7 @@ export { tokenContent, reconstructFromTokens, } from "./lexer.js"; +export { collectIgnoreRanges } from "./ignore-ranges.js"; export { Directives, getDirectivePhpWrapperKind, diff --git a/src/lexer/lexer.ts b/src/lexer/lexer.ts index 6324b1c..06ce397 100644 --- a/src/lexer/lexer.ts +++ b/src/lexer/lexer.ts @@ -1,4 +1,10 @@ -import { TokenType, State, type Token } from "./types.js"; +import { + TokenType, + State, + type IgnoreRangeRegion, + type IgnoreRangeResumeState, + type Token, +} from "./types.js"; import { Directives } from "./directives.js"; import { ErrorReason, type LexerError } from "./errors.js"; import { isFrontendEventStyleAtName } from "../frontend-attribute-names.js"; @@ -23,6 +29,26 @@ export interface LexerResult { export interface LexerRawBlockConfig { verbatimStartDirectives?: readonly string[]; verbatimEndDirectives?: readonly string[]; + ignoreRanges?: readonly IgnoreRangeRegion[]; + ignoreRangeCollector?: IgnoreRangeCollector | null; +} + +interface IgnoreRangeCollector { + handleBladeComment( + start: number, + end: number, + originState: State, + tagStart: number | null, + resume: IgnoreRangeResumeState, + ): void; + handleHtmlComment( + start: number, + end: number, + originState: State, + tagStart: number | null, + resume: IgnoreRangeResumeState, + ): void; + finish(resume: IgnoreRangeResumeState, eof: number): IgnoreRangeRegion[]; } export class Lexer { @@ -40,6 +66,14 @@ export class Lexer { private attrPhpDirectiveDepth = 0; private rawtextTagName = ""; private currentTagName = ""; + private currentTagStart = -1; + private ignoreRanges: readonly IgnoreRangeRegion[]; + private nextIgnoreRangeIndex = 0; + private ignoreRangeCollector: IgnoreRangeCollector | null; + private pendingHtmlCommentOriginState: State | null = null; + private pendingHtmlCommentTagStart: number | null = null; + private pendingBladeCommentOriginState: State | null = null; + private pendingBladeCommentTagStart: number | null = null; private isAtAttributeCandidate(nameLower: string, afterNamePos: number): boolean { if (afterNamePos >= this.len) { @@ -82,6 +116,8 @@ export class Lexer { this.src = source; this.len = source.length; this._directives = directives ?? Directives.acceptAll(); + this.ignoreRanges = rawBlockConfig?.ignoreRanges ?? []; + this.ignoreRangeCollector = rawBlockConfig?.ignoreRangeCollector ?? null; for (const directive of rawBlockConfig?.verbatimStartDirectives ?? []) { const normalized = normalizeDirectiveName(directive); @@ -100,6 +136,10 @@ export class Lexer { tokenize(): LexerResult { while (this.pos < this.len) { + if (this.tryEmitIgnoreRange()) { + continue; + } + switch (this.state) { case State.Data: this.scanData(); @@ -152,6 +192,8 @@ export class Lexer { this.emit(TokenType.SyntheticClose, this.pos, this.pos); } + this.ignoreRangeCollector?.finish(this.snapshotIgnoreRangeResumeState(), this.len); + return { tokens: this.tokens, errors: this.errors }; } @@ -159,6 +201,96 @@ export class Lexer { this.tokens.push({ type, start, end }); } + private snapshotIgnoreRangeResumeState(): IgnoreRangeResumeState { + return { + state: this.state, + returnState: this.returnState, + rawtextTagName: this.rawtextTagName, + currentTagName: this.currentTagName, + isClosingTag: this.isClosingTag, + continuedTagName: this.continuedTagName, + inXmlDeclaration: this.inXmlDeclaration, + verbatim: this.verbatim, + verbatimReturnState: this.verbatimReturnState, + phpBlock: this.phpBlock, + phpTag: this.phpTag, + attrPhpDirectiveDepth: this.attrPhpDirectiveDepth, + }; + } + + private restoreIgnoreRangeResumeState(resume: IgnoreRangeResumeState): void { + this.state = resume.state; + this.returnState = resume.returnState; + this.rawtextTagName = resume.rawtextTagName; + this.currentTagName = resume.currentTagName; + this.isClosingTag = resume.isClosingTag; + this.continuedTagName = resume.continuedTagName; + this.inXmlDeclaration = resume.inXmlDeclaration; + this.verbatim = resume.verbatim; + this.verbatimReturnState = resume.verbatimReturnState; + this.phpBlock = resume.phpBlock; + this.phpTag = resume.phpTag; + this.attrPhpDirectiveDepth = resume.attrPhpDirectiveDepth; + } + + private tryEmitIgnoreRange(): boolean { + const range = this.ignoreRanges[this.nextIgnoreRangeIndex]; + if (!range || this.pos !== range.start) { + return false; + } + + this.emit(TokenType.IgnoreRange, range.start, range.end); + this.pos = range.end; + this.restoreIgnoreRangeResumeState(range.resume); + this.nextIgnoreRangeIndex++; + return true; + } + + private nextIgnoreRangeStart(): number | null { + const range = this.ignoreRanges[this.nextIgnoreRangeIndex]; + return range ? range.start : null; + } + + private recordBladeComment( + start: number, + end: number, + originState: State, + tagStart: number | null, + ): void { + this.ignoreRangeCollector?.handleBladeComment( + start, + end, + originState, + tagStart, + this.snapshotIgnoreRangeResumeState(), + ); + } + + private recordHtmlComment( + start: number, + end: number, + originState: State, + tagStart: number | null, + ): void { + this.ignoreRangeCollector?.handleHtmlComment( + start, + end, + originState, + tagStart, + this.snapshotIgnoreRangeResumeState(), + ); + } + + private beginBladeCommentCapture(originState: State, tagStart: number | null): void { + this.pendingBladeCommentOriginState = originState; + this.pendingBladeCommentTagStart = tagStart; + } + + private beginHtmlCommentCapture(originState: State, tagStart: number | null): void { + this.pendingHtmlCommentOriginState = originState; + this.pendingHtmlCommentTagStart = tagStart; + } + private logError(reason: ErrorReason, offset: number): void { this.errors.push({ reason, offset }); } @@ -451,6 +583,20 @@ export class Lexer { const start = this.pos; while (this.pos < this.len) { + const nextIgnoreRangeStart = this.nextIgnoreRangeStart(); + if (nextIgnoreRangeStart !== null && this.pos === nextIgnoreRangeStart) { + if (start < this.pos) { + if (this.phpBlock) { + this.emit(TokenType.PhpBlock, start, this.pos); + } else if (this.phpTag) { + this.emit(TokenType.PhpContent, start, this.pos); + } else { + this.emit(TokenType.Text, start, this.pos); + } + } + return; + } + const byte = this.src[this.pos]; if (this.phpBlock) { @@ -541,6 +687,7 @@ export class Lexer { } if (next2 === "-" && next3 === "-") { + this.beginBladeCommentCapture(State.Data, null); this.scanBladeCommentStart(); return; } @@ -814,6 +961,11 @@ export class Lexer { private scanComment(): void { const start = this.pos; + const originState = this.pendingHtmlCommentOriginState ?? State.Data; + const tagStart = this.pendingHtmlCommentTagStart; + this.pendingHtmlCommentOriginState = null; + this.pendingHtmlCommentTagStart = null; + this.emit(TokenType.CommentStart, start, start + 4); this.pos += 4; @@ -829,6 +981,7 @@ export class Lexer { this.logError(ErrorReason.UnexpectedEof, this.len); this.pos = this.len; this.state = State.Data; + this.recordHtmlComment(start, this.pos, originState, tagStart); return; } @@ -839,6 +992,7 @@ export class Lexer { this.emit(TokenType.CommentEnd, closePos, closePos + 3); this.pos = closePos + 3; this.state = State.Data; + this.recordHtmlComment(start, this.pos, originState, tagStart); } private tryScanBogusComment(): boolean { @@ -903,6 +1057,11 @@ export class Lexer { private scanBladeCommentContent(): void { const start = this.pos; + const commentStart = start - 4; + const originState = this.pendingBladeCommentOriginState ?? this.returnState; + const tagStart = this.pendingBladeCommentTagStart; + this.pendingBladeCommentOriginState = null; + this.pendingBladeCommentTagStart = null; while (this.pos < this.len) { const closePos = this.src.indexOf("--}}", this.pos); @@ -916,6 +1075,7 @@ export class Lexer { this.logError(ErrorReason.UnexpectedEof, this.len); this.state = this.returnState; this.returnState = State.Data; + this.recordBladeComment(commentStart, this.pos, originState, tagStart); return; } @@ -926,11 +1086,13 @@ export class Lexer { this.pos = closePos + 4; this.state = this.returnState; this.returnState = State.Data; + this.recordBladeComment(commentStart, this.pos, originState, tagStart); return; } this.state = this.returnState; this.returnState = State.Data; + this.recordBladeComment(commentStart, this.pos, originState, tagStart); } private tryScanConditionalComment(): boolean { @@ -1299,6 +1461,7 @@ export class Lexer { private scanTagOpen(): void { const start = this.pos; this.currentTagName = ""; + this.currentTagStart = start; this.emit(TokenType.LessThan, start, start + 1); this.pos++; @@ -1381,6 +1544,7 @@ export class Lexer { if (this.peekAhead(2) === "-" && this.peekAhead(3) === "-") { this.returnState = State.TagName; this.continuedTagName = true; + this.beginBladeCommentCapture(State.TagName, this.currentTagStart); this.scanBladeCommentStart(); this.scanBladeCommentContent(); return; @@ -1555,6 +1719,7 @@ export class Lexer { this.src[this.pos + 2] === "-" && this.src[this.pos + 3] === "-" ) { + this.beginHtmlCommentCapture(State.BeforeAttrName, this.currentTagStart); this.emit(TokenType.SyntheticClose, this.pos, this.pos); this.state = State.Data; return; @@ -1586,6 +1751,7 @@ export class Lexer { if (this.peekAhead(1) === "{") { if (this.peekAhead(2) === "-" && this.peekAhead(3) === "-") { this.returnState = State.BeforeAttrName; + this.beginBladeCommentCapture(State.BeforeAttrName, this.currentTagStart); this.scanBladeCommentStart(); this.scanBladeCommentContent(); return; @@ -1689,6 +1855,7 @@ export class Lexer { if (this.peekAhead(2) === "-" && this.peekAhead(3) === "-") { const savedState = this.state; this.returnState = State.Data; + this.beginBladeCommentCapture(State.AttrName, this.currentTagStart); this.scanBladeCommentStart(); this.scanBladeCommentContent(); this.state = savedState; @@ -2022,6 +2189,7 @@ export class Lexer { this.emit(TokenType.AttributeValue, valueStart, savedPos); } this.returnState = State.AttrValueQuoted; + this.beginBladeCommentCapture(State.AttrValueQuoted, null); this.scanBladeCommentStart(); this.scanBladeCommentContent(); valueStart = this.pos; @@ -2106,6 +2274,7 @@ export class Lexer { this.emit(TokenType.AttributeValue, valueStart, this.pos); } this.returnState = State.AttrValueUnquoted; + this.beginBladeCommentCapture(State.AttrValueUnquoted, this.currentTagStart); this.scanBladeCommentStart(); this.scanBladeCommentContent(); valueStart = this.pos; @@ -2924,6 +3093,14 @@ export class Lexer { const tagNameLen = tagName.length; while (this.pos < this.len) { + const nextIgnoreRangeStart = this.nextIgnoreRangeStart(); + if (nextIgnoreRangeStart !== null && this.pos === nextIgnoreRangeStart) { + if (start < this.pos) { + this.emit(TokenType.Text, start, this.pos); + } + return; + } + const byte = this.src[this.pos]; // Check for Blade constructs @@ -2938,6 +3115,7 @@ export class Lexer { } this.returnState = State.RawText; if (next2 === "-" && next3 === "-") { + this.beginBladeCommentCapture(State.RawText, null); this.scanBladeCommentStart(); return; } diff --git a/src/lexer/types.ts b/src/lexer/types.ts index 758fc5e..0f34f37 100644 --- a/src/lexer/types.ts +++ b/src/lexer/types.ts @@ -50,6 +50,7 @@ export const enum TokenType { JsxAttributeValue = 48, JsxShorthandAttribute = 49, TsxGenericType = 50, + IgnoreRange = 51, } export const TOKEN_LABELS: Record = { @@ -104,6 +105,7 @@ export const TOKEN_LABELS: Record = { [48]: "JsxAttributeValue", [49]: "JsxShorthandAttribute", [50]: "TsxGenericType", + [51]: "IgnoreRange", }; export function tokenLabel(type: number): string { @@ -116,6 +118,27 @@ export interface Token { end: number; } +export interface IgnoreRangeResumeState { + state: State; + returnState: State; + rawtextTagName: string; + currentTagName: string; + isClosingTag: boolean; + continuedTagName: boolean; + inXmlDeclaration: boolean; + verbatim: boolean; + verbatimReturnState: State | null; + phpBlock: boolean; + phpTag: boolean; + attrPhpDirectiveDepth: number; +} + +export interface IgnoreRangeRegion { + start: number; + end: number; + resume: IgnoreRangeResumeState; +} + export const enum State { Data = 0, TagOpen = 1, diff --git a/src/parser.ts b/src/parser.ts index 34b2283..1dc3bfa 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,11 +1,12 @@ import type { Parser } from "prettier"; import { tokenize } from "./lexer/lexer.js"; import { Directives as LexerDirectives } from "./lexer/directives.js"; +import { collectIgnoreRanges } from "./lexer/ignore-ranges.js"; import { buildTree } from "./tree/tree-builder.js"; import { Directives as TreeDirectives } from "./tree/directives.js"; import type { WrappedNode } from "./types.js"; import { NodeKind, NONE, type BuildResult, type FlatNode } from "./tree/types.js"; -import { TokenType } from "./lexer/types.js"; +import { TokenType, type IgnoreRangeRegion } from "./lexer/types.js"; import { hasPragma } from "./pragma.js"; import { resolveBladeSyntaxProfile } from "./plugins/runtime.js"; import { markFrontMatter, parseFrontMatter, type FrontMatter } from "./front-matter.js"; @@ -21,6 +22,12 @@ const INTERNAL_KINDS = new Set([ NodeKind.AttributeWhitespace, ]); +const IGNORE_RANGES_OPTION = "__bladeIgnoreRanges"; + +type ParserOptionsWithIgnoreRanges = Record & { + [IGNORE_RANGES_OPTION]?: IgnoreRangeRegion[]; +}; + function buildTokenLineNumbers( tokens: BuildResult["tokens"], lineOffsets: number[], @@ -252,6 +259,7 @@ function shouldMaterializeRawText(kind: number): boolean { case NodeKind.Comment: case NodeKind.BogusComment: case NodeKind.BladeComment: + case NodeKind.IgnoreRange: case NodeKind.Doctype: case NodeKind.ElementName: case NodeKind.ClosingElementName: @@ -337,15 +345,24 @@ function parse(text: string, options?: unknown): WrappedNode { const syntaxProfile = resolveBladeSyntaxProfile(options); const lexerDirectives = LexerDirectives.acceptAll(); + const parserOptions = (options ?? {}) as ParserOptionsWithIgnoreRanges; + const ignoreRanges = + parserOptions[IGNORE_RANGES_OPTION] ?? + collectIgnoreRanges(content, lexerDirectives, { + verbatimStartDirectives: syntaxProfile.verbatimStartDirectives, + verbatimEndDirectives: syntaxProfile.verbatimEndDirectives, + }); const { tokens } = tokenize(content, lexerDirectives, { verbatimStartDirectives: syntaxProfile.verbatimStartDirectives, verbatimEndDirectives: syntaxProfile.verbatimEndDirectives, + ignoreRanges, }); const directives = TreeDirectives.withDefaults(syntaxProfile.treeDirectives); directives.train(tokens, content); const result = buildTree(tokens, content, directives); + result.ignoreRanges = ignoreRanges; const root = wrapTree(result); if (frontMatter) { @@ -355,8 +372,23 @@ function parse(text: string, options?: unknown): WrappedNode { return root; } +function preprocess(text: string, options?: unknown): string { + const { content } = parseFrontMatter(text); + const syntaxProfile = resolveBladeSyntaxProfile(options); + const lexerDirectives = LexerDirectives.acceptAll(); + const parserOptions = (options ?? {}) as ParserOptionsWithIgnoreRanges; + + parserOptions[IGNORE_RANGES_OPTION] = collectIgnoreRanges(content, lexerDirectives, { + verbatimStartDirectives: syntaxProfile.verbatimStartDirectives, + verbatimEndDirectives: syntaxProfile.verbatimEndDirectives, + }); + + return text; +} + export const bladeParser: Parser = { parse, + preprocess, hasPragma, astFormat: "blade-ast", locStart: (node: WrappedNode) => node.start, diff --git a/src/print/blade-syntax.ts b/src/print/blade-syntax.ts index 9d49898..b868bb9 100644 --- a/src/print/blade-syntax.ts +++ b/src/print/blade-syntax.ts @@ -134,6 +134,7 @@ export function isBladeConstructChild( case NodeKind.PhpTag: case NodeKind.PhpBlock: case NodeKind.DirectiveBlock: + case NodeKind.IgnoreRange: return true; case NodeKind.Directive: return isDirectiveNodeBladeLike(node, context); diff --git a/src/print/children.ts b/src/print/children.ts index 31e90ae..87deccb 100644 --- a/src/print/children.ts +++ b/src/print/children.ts @@ -4,12 +4,14 @@ import type { WrappedNode } from "../types.js"; import { NodeKind } from "../tree/types.js"; import { isTextLikeNode, isEchoLike } from "../node-predicates.js"; import { + getChildPrintSegments, hasPrettierIgnore, getPrettierIgnoreMode, - getIgnoreCommentKind, forceBreakChildren, forceNextEmptyLine, + getPrintableSubtreeEnd, preferHardlineAsLeadingSpaces, + type ChildPrintSegment, } from "./utils.js"; import { needsToBorrowNextOpeningTagStartMarker, @@ -23,19 +25,7 @@ import { import { htmlTrimEnd, htmlTrimStart, replaceEndOfLine } from "./doc-utils.js"; import { ifBreakChain } from "./if-break-chain.js"; -const { breakParent, group, hardline, softline, line, dedentToRoot } = doc.builders; - -/** - * Get the end location for a node, accounting for unclosed elements - * whose end is determined by their last child. - * Ported from Prettier's print/children.js getEndLocation. - */ -function getEndLocation(node: WrappedNode): number { - if (node.kind === NodeKind.Element && !node.hasClosingTag && node.children.length > 0) { - return Math.max(node.end, getEndLocation(node.children[node.children.length - 1])); - } - return node.end; -} +const { breakParent, group, hardline, softline, line } = doc.builders; function getSourceBetween(prev: WrappedNode, next: WrappedNode): string { if (prev.source !== next.source) { @@ -45,24 +35,8 @@ function getSourceBetween(prev: WrappedNode, next: WrappedNode): string { return prev.source.slice(prev.end, next.start); } -function shouldKeepInlineIgnoreBoundary(prev: WrappedNode, next: WrappedNode): boolean { - const sourceBetween = getSourceBetween(prev, next); - const prevIgnoreMode = getPrettierIgnoreMode(prev); - const nextIgnoreMode = getPrettierIgnoreMode(next); - - if (prevIgnoreMode === "range" && nextIgnoreMode === "range") { - return true; - } - - if (/[\r\n]/u.test(sourceBetween)) { - return false; - } - - if (getIgnoreCommentKind(prev) === "ignore-start" && nextIgnoreMode === "range") { - return true; - } - - return prevIgnoreMode === "range" && getIgnoreCommentKind(next) === "ignore-end"; +function isIgnoreRangeNode(node: WrappedNode): boolean { + return node.kind === NodeKind.IgnoreRange; } /** @@ -77,8 +51,8 @@ function printChild( const child = childPath.node; const ignoreMode = getPrettierIgnoreMode(child); - if (hasPrettierIgnore(child) && ignoreMode) { - const endLocation = getEndLocation(child); + if (hasPrettierIgnore(child) && ignoreMode === "single") { + const endLocation = getPrintableSubtreeEnd(child); let preservedText = htmlTrimEnd( child.source.slice( child.start + @@ -101,9 +75,7 @@ function printChild( return [ printOpeningTagPrefix(child, options), - ignoreMode === "range" - ? dedentToRoot(replaceEndOfLine(preservedText)) - : replaceEndOfLine(preservedText), + replaceEndOfLine(preservedText), printClosingTagSuffix(child, options), ]; } @@ -111,13 +83,63 @@ function printChild( return print(childPath); } +function getSourceBetweenSegments(prev: ChildPrintSegment, next: ChildPrintSegment): string { + if (prev.first.source !== next.first.source) { + return ""; + } + + return prev.first.source.slice(prev.sourceEnd, next.sourceStart); +} + +function hasEmptyLineBetweenSegments(prev: ChildPrintSegment, next: ChildPrintSegment): boolean { + if (!isIgnoreRangeNode(prev.last) && !isIgnoreRangeNode(next.first)) { + return forceNextEmptyLine(prev.last); + } + + const sourceBetween = getSourceBetweenSegments(prev, next); + + return /(?:\r\n|\r|\n)[^\S\r\n]*(?:\r\n|\r|\n)/u.test(sourceBetween); +} + +function printBetweenSegments(prev: ChildPrintSegment, next: ChildPrintSegment): Doc { + if (isIgnoreRangeNode(prev.last) || isIgnoreRangeNode(next.first)) { + const sourceBetween = getSourceBetweenSegments(prev, next); + if (sourceBetween.length === 0) { + return ""; + } + + if (/(?:\r\n|\r|\n)[^\S\r\n]*(?:\r\n|\r|\n)/u.test(sourceBetween)) { + return [hardline, hardline]; + } + + if (/[\r\n]/u.test(sourceBetween)) { + return hardline; + } + + return sourceBetween; + } + + return printBetweenLine(prev.last, next.first); +} + /** * Determine line break between two adjacent content nodes. * Ported from Prettier's print/children.js printBetweenLine. */ function printBetweenLine(prev: WrappedNode, next: WrappedNode): Doc { - if (shouldKeepInlineIgnoreBoundary(prev, next)) { - return getSourceBetween(prev, next); + const sourceBetween = getSourceBetween(prev, next); + + if (isIgnoreRangeNode(prev) || isIgnoreRangeNode(next)) { + if (sourceBetween.length === 0) { + return ""; + } + if (/(?:\r\n|\r|\n)[^\S\r\n]*(?:\r\n|\r|\n)/u.test(sourceBetween)) { + return [hardline, hardline]; + } + if (/[\r\n]/u.test(sourceBetween)) { + return hardline; + } + return sourceBetween; } // Escaped blade prefixes (e.g. @@, @{{, @{!!) must stay attached to @@ -189,43 +211,50 @@ export function printChildren( options: Options, ): Doc[] { const node = path.node; + const segments = getChildPrintSegments(node.children); + const printedChildren = path.map( + (childPath) => printChild(childPath, options, print), + "children", + ); // Force-break mode: certain elements (ul, ol, table, etc.) always break. if (forceBreakChildren(node)) { return [ breakParent, - ...path.map((childPath) => { - const childNode = childPath.node; - const prevBetweenLine = !childNode.prev ? "" : printBetweenLine(childNode.prev, childNode); + ...segments.map((segment, segmentIndex) => { + const prevSegment = segmentIndex > 0 ? segments[segmentIndex - 1] : null; + const prevBetweenLine = !prevSegment ? "" : printBetweenSegments(prevSegment, segment); return [ !prevBetweenLine ? "" - : [prevBetweenLine, forceNextEmptyLine(childNode.prev!) ? hardline : ""], - printChild(childPath, options, print), + : [prevBetweenLine, hasEmptyLineBetweenSegments(prevSegment!, segment) ? hardline : ""], + printedChildren[segment.startIndex], ]; - }, "children"), + }), ]; } // Normal mode: use group IDs for proper inline element formatting. - const needsGroupIds = node.children.some((child) => !isTextLikeNode(child)); - const groupIds = needsGroupIds ? node.children.map(() => Symbol("")) : []; + const needsGroupIds = segments.some((segment) => !isTextLikeNode(segment.first)); + const groupIds = needsGroupIds ? segments.map(() => Symbol("")) : []; - return path.map((childPath, childIndex) => { - const childNode = childPath.node; + return segments.map((segment, childIndex) => { + const childNode = segment.first; + const segmentDoc = printedChildren[segment.startIndex]; // Text-like nodes: simpler handling - no group wrapping needed. if (isTextLikeNode(childNode)) { - if (childNode.prev && isTextLikeNode(childNode.prev)) { - const prevBetweenLine = printBetweenLine(childNode.prev, childNode); + const prevSegment = childIndex > 0 ? segments[childIndex - 1] : null; + if (prevSegment && isTextLikeNode(prevSegment.last)) { + const prevBetweenLine = printBetweenSegments(prevSegment, segment); if (prevBetweenLine) { - if (forceNextEmptyLine(childNode.prev)) { - return [hardline, hardline, printChild(childPath, options, print)]; + if (hasEmptyLineBetweenSegments(prevSegment, segment)) { + return [hardline, hardline, segmentDoc]; } - return [prevBetweenLine, printChild(childPath, options, print)]; + return [prevBetweenLine, segmentDoc]; } } - return printChild(childPath, options, print); + return segmentDoc; } // Non-text nodes: wrap in groups with leading/trailing parts. @@ -234,16 +263,18 @@ export function printChildren( const trailingParts: Doc[] = []; const nextParts: Doc[] = []; - const prevBetweenLine = childNode.prev ? printBetweenLine(childNode.prev, childNode) : ""; + const prevSegment = childIndex > 0 ? segments[childIndex - 1] : null; + const prevBetweenLine = prevSegment ? printBetweenSegments(prevSegment, segment) : ""; - const nextBetweenLine = childNode.next ? printBetweenLine(childNode, childNode.next) : ""; + const nextSegment = childIndex + 1 < segments.length ? segments[childIndex + 1] : null; + const nextBetweenLine = nextSegment ? printBetweenSegments(segment, nextSegment) : ""; if (prevBetweenLine) { - if (forceNextEmptyLine(childNode.prev!)) { + if (hasEmptyLineBetweenSegments(prevSegment!, segment)) { prevParts.push(hardline, hardline); } else if (prevBetweenLine === hardline) { prevParts.push(hardline); - } else if (isTextLikeNode(childNode.prev!)) { + } else if (prevSegment && isTextLikeNode(prevSegment.last)) { leadingParts.push(prevBetweenLine); } else { leadingParts.push(ifBreakChain(softline, [groupIds[childIndex - 1]])); @@ -251,12 +282,12 @@ export function printChildren( } if (nextBetweenLine) { - if (forceNextEmptyLine(childNode)) { - if (isTextLikeNode(childNode.next!)) { + if (nextSegment && hasEmptyLineBetweenSegments(segment, nextSegment)) { + if (isTextLikeNode(nextSegment.first)) { nextParts.push(hardline, hardline); } } else if (nextBetweenLine === hardline) { - if (isTextLikeNode(childNode.next!)) { + if (nextSegment && isTextLikeNode(nextSegment.first)) { nextParts.push(hardline); } } else { @@ -268,11 +299,11 @@ export function printChildren( ...prevParts, group([ ...leadingParts, - group([printChild(childPath, options, print), ...trailingParts], { + group([segmentDoc, ...trailingParts], { id: groupIds[childIndex], }), ]), ...nextParts, ]; - }, "children"); + }); } diff --git a/src/print/directive.ts b/src/print/directive.ts index ac1470f..8b7f65c 100644 --- a/src/print/directive.ts +++ b/src/print/directive.ts @@ -15,9 +15,11 @@ import { isEchoLike, isTextLikeNode } from "../node-predicates.js"; import { fullText, preferHardlineAsLeadingSpaces, - getIgnoreCommentKind, + getChildPrintSegments, + getPrintableSubtreeEnd, getPrettierIgnoreMode, hasPrettierIgnore, + type ChildPrintSegment, } from "./utils.js"; import { htmlTrimEnd, htmlTrimStart, replaceEndOfLine } from "./doc-utils.js"; import { @@ -29,7 +31,7 @@ import { printClosingTagEndMarker, } from "./tag.js"; -const { indent, hardline, dedentToRoot } = doc.builders; +const { indent, hardline } = doc.builders; type BranchNodeKind = NodeKind.Directive | NodeKind.PhpTag; @@ -46,12 +48,8 @@ const BODY_BLANK_LINE_LAYOUT_DIRECTIVES = new Set([ "each", ]); -function getEndLocation(node: WrappedNode): number { - if (node.kind === NodeKind.Element && !node.hasClosingTag && node.children.length > 0) { - return Math.max(node.end, getEndLocation(node.children[node.children.length - 1])); - } - - return node.end; +function isIgnoreRangeNode(node: WrappedNode): boolean { + return node.kind === NodeKind.IgnoreRange; } function printIgnoredDirectiveBodyChild( @@ -61,11 +59,11 @@ function printIgnoredDirectiveBodyChild( const child = childPath.node; const ignoreMode = getPrettierIgnoreMode(child); - if (!(hasPrettierIgnore(child) && ignoreMode)) { + if (!(hasPrettierIgnore(child) && ignoreMode === "single")) { return null; } - const endLocation = getEndLocation(child); + const endLocation = getPrintableSubtreeEnd(child); let preservedText = htmlTrimEnd( child.source.slice( child.start + @@ -85,13 +83,40 @@ function printIgnoredDirectiveBodyChild( return [ printOpeningTagPrefix(child, options), - ignoreMode === "range" - ? dedentToRoot(replaceEndOfLine(preservedText)) - : replaceEndOfLine(preservedText), + replaceEndOfLine(preservedText), printClosingTagSuffix(child, options), ]; } +function getSourceBetweenSegments(prev: ChildPrintSegment, next: ChildPrintSegment): string { + if (prev.first.source !== next.first.source) { + return ""; + } + + return getSourceBetweenBounds(prev.first.source, prev.sourceEnd, next.sourceStart); +} + +function printBetweenSegments(prev: ChildPrintSegment, next: ChildPrintSegment): Doc { + if (isIgnoreRangeNode(prev.last) || isIgnoreRangeNode(next.first)) { + const sourceBetween = getSourceBetweenSegments(prev, next); + if (sourceBetween.length === 0) { + return ""; + } + + if (!/[\r\n]/u.test(sourceBetween)) { + return sourceBetween; + } + + if (hasBlankLineBetween(sourceBetween) && shouldPreserveBodyBlankLine(prev.last, next.first)) { + return [hardline, hardline]; + } + + return hardline; + } + + return printBetweenLine(prev.last, next.first); +} + export function printDirective(node: WrappedNode, options: Options): Doc { return renderDirectiveTokens(node, options); } @@ -325,35 +350,54 @@ function printDirectiveBody( ): Doc[] { const docs: Doc[] = []; const branch = branchPath.node; - - branchPath.each((childPath, i) => { + const segments = getChildPrintSegments(branch.children); + const renderedChildren = branchPath.map((childPath) => { const child = childPath.node; + if (isBranchNode(child) && child.children.length > 0) { + return { + branchDoc: + child.kind === NodeKind.Directive + ? renderDirectiveTokens(child, options) + : print(childPath), + nestedDocs: printDirectiveBody(childPath, print, options), + }; + } + + return { + branchDoc: printDirectiveBodyChild(childPath, print, options), + nestedDocs: [], + }; + }, "children"); + + for (const [segmentIndex, segment] of segments.entries()) { + const child = segment.first; if (docs.length > 0) { - const prev = branch.children[i - 1]; + const prev = segments[segmentIndex - 1]; if (prev) { - docs.push(printBetweenLine(prev, child)); + docs.push(printBetweenSegments(prev, segment)); } } + const rendered = renderedChildren[segment.startIndex]; + if (!rendered) { + continue; + } + // Directive children with their own body (e.g. @case/@default inside // @switch): print the directive marker, then recurse into its body // indented - mirroring how printDirectiveBlockMultiline handles its // direct Directive children. if (isBranchNode(child) && child.children.length > 0) { - docs.push( - child.kind === NodeKind.Directive - ? renderDirectiveTokens(child, options) - : print(childPath), - ); - const nestedDocs = printDirectiveBody(childPath, print, options); + docs.push(rendered.branchDoc); + const nestedDocs = rendered.nestedDocs; if (nestedDocs.length > 0) { docs.push(indent([hardline, nestedDocs])); } } else { - docs.push(printDirectiveBodyChild(childPath, print, options)); + docs.push(rendered.branchDoc); } - }, "children"); + } return docs; } @@ -364,19 +408,40 @@ function printDirectiveBodyInline( options: Options, ): Doc[] { const docs: Doc[] = []; - let prev: WrappedNode | null = null; + const segments = getChildPrintSegments(branchPath.node.children); + const printedChildren = branchPath.map( + (childPath) => printDirectiveBodyChild(childPath, print, options), + "children", + ); - branchPath.each((childPath) => { - const child = childPath.node; + for (const [segmentIndex, segment] of segments.entries()) { + const prev = segmentIndex > 0 ? segments[segmentIndex - 1] : null; if (prev !== null) { - const between = getSourceBetween(prev, child); - if (/\s/.test(between)) { + const between = + isIgnoreRangeNode(prev.last) || isIgnoreRangeNode(segment.first) + ? getSourceBetweenSegments(prev, segment) + : getSourceBetween(prev.last, segment.first); + + if (isIgnoreRangeNode(prev.last) || isIgnoreRangeNode(segment.first)) { + if (between.length > 0) { + docs.push( + hasBlankLineBetween(between) + ? [hardline, hardline] + : /[\r\n]/u.test(between) + ? hardline + : between, + ); + } + } else if (/\s/.test(between)) { docs.push(" "); } } - docs.push(printDirectiveBodyChild(childPath, print, options)); - prev = child; - }, "children"); + + const printed = printedChildren[segment.startIndex]; + if (printed !== "") { + docs.push(printed); + } + } return docs; } @@ -676,27 +741,18 @@ function printBetweenLine(prev: WrappedNode, next: WrappedNode): Doc { } const sourceBetween = getSourceBetween(prev, next); - const prevIgnoreMode = getPrettierIgnoreMode(prev); - const nextIgnoreMode = getPrettierIgnoreMode(next); const hasLineBreakBetweenNodes = /[\r\n]/.test(sourceBetween) || next.startLine > prev.endLine; - if (prevIgnoreMode === "range" && nextIgnoreMode === "range") { - return sourceBetween; - } - - if ( - getIgnoreCommentKind(prev) === "ignore-start" && - nextIgnoreMode === "range" && - !hasLineBreakBetweenNodes - ) { - return sourceBetween; - } - - if ( - prevIgnoreMode === "range" && - getIgnoreCommentKind(next) === "ignore-end" && - !hasLineBreakBetweenNodes - ) { + if (isIgnoreRangeNode(prev) || isIgnoreRangeNode(next)) { + if (sourceBetween.length === 0) { + return ""; + } + if (hasBlankLineBetween(sourceBetween)) { + return [hardline, hardline]; + } + if (hasLineBreakBetweenNodes) { + return hardline; + } return sourceBetween; } diff --git a/src/print/index.ts b/src/print/index.ts index 10fecd7..5c19ca3 100644 --- a/src/print/index.ts +++ b/src/print/index.ts @@ -47,7 +47,7 @@ function genericPrint( case NodeKind.Root: { const children = printChildren(path, print, options); if (children.length === 0) return ""; - return [group(children), hardline]; + return shouldPreserveIgnoreRangeEof(node) ? group(children) : [group(children), hardline]; } case NodeKind.Element: @@ -57,6 +57,9 @@ function genericPrint( case NodeKind.Text: return printText(node, options); + case NodeKind.IgnoreRange: + return printIgnoreRange(node); + case NodeKind.Echo: case NodeKind.RawEcho: case NodeKind.TripleEcho: @@ -144,6 +147,10 @@ function printRawBlockNode(node: WrappedNode): string { return trimUnterminatedNodeAtEof(node); } +function printIgnoreRange(node: WrappedNode): string { + return fullText(node); +} + function printRawDelimitedNode(node: WrappedNode): string { return trimUnterminatedNodeAtEof(node); } @@ -208,3 +215,12 @@ function trimTrailingWhitespaceAtEof(node: WrappedNode): string { } return text.replace(/\s+$/u, ""); } + +function shouldPreserveIgnoreRangeEof(node: WrappedNode): boolean { + if (node.kind !== NodeKind.Root || node.children.length === 0) { + return false; + } + + const lastChild = node.children[node.children.length - 1]; + return lastChild.kind === NodeKind.IgnoreRange && lastChild.end === lastChild.source.length; +} diff --git a/src/print/utils.ts b/src/print/utils.ts index f723749..8c8a1be 100644 --- a/src/print/utils.ts +++ b/src/print/utils.ts @@ -4,6 +4,7 @@ import { NodeKind } from "../tree/types.js"; import { TokenType } from "../lexer/types.js"; import { HTML_ELEMENT_ATTRIBUTES } from "../html-data.js"; import { hasFrontMatterMark } from "../front-matter.js"; +import { getIgnoreCommentKindFromCommentText, type IgnoreCommentKind } from "../ignore-markers.js"; import { parentContainsBladeSyntax } from "./blade-syntax.js"; import { isEchoLike, isScriptLikeTag, isVueNonHtmlBlock } from "../node-predicates.js"; @@ -317,8 +318,18 @@ export function forceNextEmptyLine(node: WrappedNode): boolean { return !!node.next && node.endLine + 1 < node.next.startLine; } -type IgnoreCommentKind = "ignore" | "ignore-start" | "ignore-end"; -type IgnoreApplyMode = "single" | "range"; +type IgnoreApplyMode = "single"; + +interface ChildPrintSegmentBase { + startIndex: number; + endIndex: number; + first: WrappedNode; + last: WrappedNode; + sourceStart: number; + sourceEnd: number; +} + +export interface ChildPrintSegment extends ChildPrintSegmentBase {} const ignoredChildrenCache = new WeakMap>(); @@ -341,32 +352,18 @@ function getIgnoredChildren(parent: WrappedNode): Map(); - let ignoreDepth = 0; let ignoreNextCount = 0; for (const child of parent.children) { const ignoreKind = getIgnoreCommentKind(child); - const isRangeEnd = ignoreKind === "ignore-end"; - if ((ignoreDepth > 0 || ignoreNextCount > 0) && !isRangeEnd) { - ignored.set(child, ignoreDepth > 0 ? "range" : "single"); - if (ignoreNextCount > 0) { - ignoreNextCount--; - } + if (ignoreNextCount > 0 && ignoreKind !== "ignore") { + ignored.set(child, "single"); + ignoreNextCount--; } if (ignoreKind === "ignore") { ignoreNextCount = Math.max(ignoreNextCount, 1); - continue; - } - - if (ignoreKind === "ignore-start") { - ignoreDepth++; - continue; - } - - if (ignoreKind === "ignore-end" && ignoreDepth > 0) { - ignoreDepth--; } } @@ -375,34 +372,43 @@ function getIgnoredChildren(parent: WrappedNode): Map$/s); - value = match?.[1] ?? null; - } else if (node.kind === NodeKind.BladeComment) { - const text = fullText(node); - const match = text.match(/^\{\{--\s*([\s\S]*?)\s*--\}\}$/s); - value = match?.[1] ?? null; + return getIgnoreCommentKindFromCommentText(fullText(node), "html"); + } + if (node.kind === NodeKind.BladeComment) { + return getIgnoreCommentKindFromCommentText(fullText(node), "blade"); } + return null; +} - if (!value) return null; - - const normalized = value.trim().toLowerCase(); - switch (normalized) { - case "prettier-ignore": - case "format-ignore": - return "ignore"; - case "prettier-ignore-start": - case "format-ignore-start": - return "ignore-start"; - case "prettier-ignore-end": - case "format-ignore-end": - return "ignore-end"; - default: - return null; +export function getPrintableSubtreeEnd(node: WrappedNode): number { + let end = node.end; + + const subtreeChildren = + node.attrs.length === 0 + ? node.children + : node.children.length === 0 + ? node.attrs + : [...node.attrs, ...node.children].sort((left, right) => + left.start !== right.start ? left.start - right.start : left.end - right.end, + ); + + for (const child of subtreeChildren) { + end = Math.max(end, getPrintableSubtreeEnd(child)); } + + return end; +} + +export function getChildPrintSegments(children: readonly WrappedNode[]): ChildPrintSegment[] { + return children.map((child, index) => ({ + startIndex: index, + endIndex: index, + first: child, + last: child, + sourceStart: child.start, + sourceEnd: getPrintableSubtreeEnd(child), + })); } export function getAttributeName(node: WrappedNode): string { diff --git a/src/tree/tree-builder.ts b/src/tree/tree-builder.ts index a38a85d..d181f7b 100644 --- a/src/tree/tree-builder.ts +++ b/src/tree/tree-builder.ts @@ -197,6 +197,10 @@ export class TreeBuilder { case TokenType.DoctypeStart: this.createBlockNode(this.pos, TokenType.DoctypeEnd, NodeKind.Doctype); break; + case TokenType.IgnoreRange: + this.addChild(createFlatNode(NodeKind.IgnoreRange, 0, this.pos, 1)); + this.pos++; + break; case TokenType.PhpBlockStart: this.createBlockNode(this.pos, TokenType.PhpBlockEnd, NodeKind.PhpBlock); break; diff --git a/src/tree/types.ts b/src/tree/types.ts index 3d46696..5de6bfe 100644 --- a/src/tree/types.ts +++ b/src/tree/types.ts @@ -1,3 +1,4 @@ +import type { IgnoreRangeRegion } from "../lexer/types.js"; import type { Directives } from "./directives.js"; export const enum NodeKind { @@ -30,6 +31,7 @@ export const enum NodeKind { AttributeName = 26, AttributeValue = 27, ProcessingInstruction = 28, + IgnoreRange = 29, } export const NODE_KIND_LABELS: Record = { @@ -62,6 +64,7 @@ export const NODE_KIND_LABELS: Record = { [NodeKind.AttributeName]: "AttributeName", [NodeKind.AttributeValue]: "AttributeValue", [NodeKind.ProcessingInstruction]: "ProcessingInstruction", + [NodeKind.IgnoreRange]: "IgnoreRange", }; export function nodeKindLabel(kind: number): string { @@ -122,6 +125,7 @@ export interface BuildResult { source: string; tokens: readonly { type: number; start: number; end: number }[]; directives?: Directives; + ignoreRanges?: readonly IgnoreRangeRegion[]; } export interface DirectiveFrame { diff --git a/tests/directives/format-ignore.test.ts b/tests/directives/format-ignore.test.ts index bd1732f..9851082 100644 --- a/tests/directives/format-ignore.test.ts +++ b/tests/directives/format-ignore.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from "vitest"; -import { formatEqual, hasLoneLf } from "../helpers.js"; +import { + expectIgnoreRangesUnchanged, + formatEqual, + hasLoneLf, +} from "../helpers.js"; + +async function formatRangeEqual(input: string, expected: string, options = {}) { + const output = await formatEqual(input, expected, options); + expectIgnoreRangesUnchanged(input, output, "directives/format-ignore", options); + return output; +} describe("directives/format-ignore", () => { it("treats Blade format-ignore-start/end as range ignore markers", async () => { @@ -171,11 +181,11 @@ describe("directives/format-ignore", () => { const expected = `@fields ('section1_words') {{-- format-ignore-start --}} - + @sub('item') * - {{-- format-ignore-end --}} + {{-- format-ignore-end --}} @endfields `; @@ -195,11 +205,11 @@ describe("directives/format-ignore", () => { const expected = `@fields ('section1_words') {{-- prettier-ignore-start --}} - + @sub('item') * - {{-- prettier-ignore-end --}} + {{-- prettier-ignore-end --}} @endfields `; @@ -309,6 +319,50 @@ describe("directives/format-ignore", () => { await formatEqual(input, expected); }); + it(`preserves spaces around echo tags inside ${ignoreLabel} ranges`, async () => { + const input = `{{-- ${ignoreLabel}-start --}} +Dear {{$user->first_name}}, +Roster on {{$date->format('d-m-Y')}} +Distance to station: {{ $distance }} +{{-- ${ignoreLabel}-end --}} +`; + + await formatRangeEqual(input, input); + }); + + it(`preserves blank lines before directives inside ${ignoreLabel} ranges`, async () => { + const input = `{{-- ${ignoreLabel}-start --}} +The following ecrew messages were sent to you. + +@foreach($messageData as $ecrewMessage) +===== +@endforeach +{{-- ${ignoreLabel}-end --}} +`; + + await formatRangeEqual(input, input); + }); + + it(`preserves the closing marker line break inside ${ignoreLabel} ranges`, async () => { + const input = `{{-- ${ignoreLabel}-start --}} +This action WILL NOT accept any changes for you. +{{-- ${ignoreLabel}-end --}} +`; + + await formatRangeEqual(input, input); + }); + + it(`preserves emoji and html spacing inside ${ignoreLabel} ranges`, async () => { + const input = `{{-- ${ignoreLabel}-start --}} +🚨 Stick Time Monitor Alert 🚨 + +Date: {{ $flightplan->date->format('Y-m-d') }} +{{-- ${ignoreLabel}-end --}} +`; + + await formatRangeEqual(input, input); + }); + it(`preserves multiple ${ignoreLabel} ranges in one directive body`, async () => { const input = `@section('content') {{-- ${ignoreLabel}-start --}}@csrf('a')*{{-- ${ignoreLabel}-end --}} @@ -402,10 +456,10 @@ describe("directives/format-ignore", () => { const expected = `@switch ($state) @case ('ready') {{-- ${ignoreLabel}-start --}} - @csrf('item') +@csrf('item') {{ $slot }} * - {{-- ${ignoreLabel}-end --}} + {{-- ${ignoreLabel}-end --}} @break @endswitch `; @@ -425,13 +479,53 @@ describe("directives/format-ignore", () => { const expected = `@fields ('section1_words')\r\n` + ` {{-- ${ignoreLabel}-start --}}\r\n` + - ` @csrf('item')\r\n` + + ` @csrf('item')\r\n` + ` {{ $slot }}\r\n` + - ` {{-- ${ignoreLabel}-end --}}\r\n` + + ` {{-- ${ignoreLabel}-end --}}\r\n` + `@endfields\r\n`; - const output = await formatEqual(input, expected, { endOfLine: "crlf" }); + const output = await formatRangeEqual(input, expected, { endOfLine: "crlf" }); expect(hasLoneLf(output)).toBe(false); }); + + it(`preserves matched ${ignoreLabel} ranges at eof with trailing spaces`, async () => { + const input = `
+ outside
+{{-- ${ignoreLabel}-start --}} +tail +still raw +{{-- ${ignoreLabel}-end --}} +`; + + const expected = `
outside
+{{-- ${ignoreLabel}-start --}} +tail +still raw +{{-- ${ignoreLabel}-end --}} +`; + + await formatRangeEqual(input, expected); + }); + + it(`preserves unmatched ${ignoreLabel} ranges to eof with trailing blank lines`, async () => { + const input = `
+ outside
+{{-- ${ignoreLabel}-start --}} +alpha + + + +`; + + const expected = `
outside
+{{-- ${ignoreLabel}-start --}} +alpha + + + +`; + + await formatRangeEqual(input, expected); + }); } }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 93f88ea..acfa360 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,6 +1,7 @@ import { expect } from "vitest"; import * as prettier from "prettier"; import plugin from "../src/index.js"; +import { expectIgnoreRangesUnchanged } from "./support/ignore-range.js"; const DEFAULT_IDEMPOTENT_PASSES = 2; const TEST_DEFAULT_OPTIONS: prettier.Options = { @@ -56,6 +57,7 @@ export async function formatWithPasses( }; let current = await prettier.format(code, opts); + expectIgnoreRangesUnchanged(code, current, "formatWithPasses pass 1", opts); if (!assertIdempotent) { return current; } @@ -63,6 +65,7 @@ export async function formatWithPasses( for (let i = 2; i <= passes; i++) { const next = await prettier.format(current, opts); expect(next, `formatter is not idempotent on pass ${i}`).toBe(current); + expectIgnoreRangesUnchanged(code, next, `formatWithPasses pass ${i}`, opts); current = next; } @@ -121,11 +124,13 @@ export async function formatEqualToPrettierHtml(input: string, options: prettier const htmlOpts = { parser: "html" as const, ...toHtmlReferenceOptions(options) }; const bladeOutput = await prettier.format(input, bladeOpts); + expectIgnoreRangesUnchanged(input, bladeOutput, "blade/html parity pass 1", bladeOpts); const htmlOutput = await prettier.format(input, htmlOpts); expect(bladeOutput).toBe(htmlOutput); const secondPass = await prettier.format(bladeOutput, bladeOpts); expect(secondPass, "blade formatter is not idempotent").toBe(bladeOutput); + expectIgnoreRangesUnchanged(input, secondPass, "blade/html parity pass 2", bladeOpts); return bladeOutput; } @@ -144,11 +149,13 @@ export async function formatEqual( }; const first = await prettier.format(input, opts); expect(first).toBe(expected); + expectIgnoreRangesUnchanged(input, first, "formatEqual pass 1", opts); let prev = first; for (let i = 2; i <= passes; i++) { const next = await prettier.format(prev, opts); expect(next, `not idempotent on pass ${i}`).toBe(prev); + expectIgnoreRangesUnchanged(input, next, `formatEqual pass ${i}`, opts); prev = next; } @@ -165,3 +172,8 @@ export { nodeText, indexOf, } from "./debug.js"; +export { + collectIgnoreRangeSlices, + expectIgnoreRangesUnchanged, + expectIgnoreRangeSlicesUnchanged, +} from "./support/ignore-range.js"; diff --git a/tests/html/ignore-range.test.ts b/tests/html/ignore-range.test.ts new file mode 100644 index 0000000..458774e --- /dev/null +++ b/tests/html/ignore-range.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "vitest"; +import { + expectIgnoreRangesUnchanged, + formatEqual, +} from "../helpers.js"; + +async function formatRangeEqual(input: string, expected: string, options = {}) { + const output = await formatEqual(input, expected, options); + expectIgnoreRangesUnchanged(input, output, "html/ignore-range", options); + return output; +} + +describe("html/ignore-range", () => { + for (const ignoreLabel of ["format-ignore", "prettier-ignore"] as const) { + it(`preserves HTML comment ${ignoreLabel} ranges`, async () => { + const input = ` +
+ +
+`; + + const expected = ` +
+ +
+`; + + await formatRangeEqual(input, expected); + }); + + it(`pairs Blade start with HTML end for ${ignoreLabel} ranges`, async () => { + const input = `{{-- ${ignoreLabel}-start --}} +
+ +
+`; + + const expected = `{{-- ${ignoreLabel}-start --}} +
+ +
+`; + + await formatRangeEqual(input, expected); + }); + + it(`pairs HTML start with Blade end for ${ignoreLabel} ranges`, async () => { + const input = ` +
+{{-- ${ignoreLabel}-end --}} +
+`; + + const expected = ` +
+{{-- ${ignoreLabel}-end --}} +
+`; + + await formatRangeEqual(input, expected); + }); + + it(`preserves unmatched HTML ${ignoreLabel} ranges to eof`, async () => { + const input = `
+ text A
+ +
+ text B
+`; + + const expected = `
text A
+ +
+ text B
+`; + + await formatRangeEqual(input, expected); + }); + + it(`preserves Blade ${ignoreLabel} ranges inside raw-text elements`, async () => { + const input = ` +
+`; + + const expected = ` +
+`; + + await formatRangeEqual(input, expected); + }); + + it(`preserves malformed HTML-start ${ignoreLabel} ranges inside opening tags`, async () => { + const input = `@if($x) +
class="x" >a
+@endif +`; + + const expected = `@if ($x) +
class="x" >a
+@endif +`; + + await formatRangeEqual(input, expected); + }); + + it(`preserves nested mixed-wrapper ${ignoreLabel} ranges`, async () => { + const input = ` +
+{{-- ${ignoreLabel}-start --}} + + +

+{{-- ${ignoreLabel}-end --}} +
+`; + + const expected = ` +
+{{-- ${ignoreLabel}-start --}} + + +

+{{-- ${ignoreLabel}-end --}} +
+`; + + await formatRangeEqual(input, expected); + }); + } + + it("does not activate Blade range markers inside quoted attribute values", async () => { + const input = `
+`; + + const expected = `
+ +
+`; + + await formatEqual(input, expected); + }); + + it("does not activate HTML range markers inside quoted attribute values", async () => { + const input = `
+`; + + const expected = `
+ +
+`; + + await formatEqual(input, expected); + }); + + it("does not activate HTML range markers inside raw-text elements", async () => { + const input = ` +
+`; + + const expected = ` +
+`; + + await formatEqual(input, expected); + }); +}); diff --git a/tests/lexer/ignore-range.test.ts b/tests/lexer/ignore-range.test.ts new file mode 100644 index 0000000..a6911cb --- /dev/null +++ b/tests/lexer/ignore-range.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { reconstructFromTokens, tokenize } from "../../src/lexer/lexer.js"; +import { collectIgnoreRanges } from "../../src/lexer/index.js"; +import { State, TokenType } from "../../src/lexer/types.js"; + +describe("lexer/ignore-range", () => { + it("collects sorted, non-overlapping outermost ranges", () => { + const source = `{{-- format-ignore-start --}}A{{-- prettier-ignore-start --}}B{{-- prettier-ignore-end --}}C{{-- format-ignore-end --}}xy`; + const firstSlice = + "{{-- format-ignore-start --}}A{{-- prettier-ignore-start --}}B{{-- prettier-ignore-end --}}C{{-- format-ignore-end --}}"; + const secondSlice = "y"; + const ranges = collectIgnoreRanges(source); + + expect(ranges).toHaveLength(2); + expect(source.slice(ranges[0].start, ranges[0].end)).toBe(firstSlice); + expect(source.slice(ranges[1].start, ranges[1].end)).toBe(secondSlice); + expect(ranges[0].end).toBeLessThan(ranges[1].start); + }); + + it.each([ + { + label: "Blade start to HTML end", + source: "{{-- format-ignore-start --}}x", + }, + { + label: "HTML start to Blade end", + source: "x{{-- format-ignore-end --}}", + }, + ])("pairs mixed wrapper markers for $label", ({ source }) => { + const ranges = collectIgnoreRanges(source); + + expect(ranges).toHaveLength(1); + expect(source.slice(ranges[0].start, ranges[0].end)).toBe(source); + }); + + it("preserves unmatched ranges through eof", () => { + const source = `
alpha
+{{-- format-ignore-start --}} +tail +`; + const ranges = collectIgnoreRanges(source); + + expect(ranges).toHaveLength(1); + expect(ranges[0].end).toBe(source.length); + expect(source.slice(ranges[0].start, ranges[0].end)).toBe( + `{{-- format-ignore-start --}} +tail +`, + ); + }); + + it("does not activate range markers inside quoted attribute values", () => { + expect( + collectIgnoreRanges( + `
`, + ), + ).toHaveLength(0); + expect( + collectIgnoreRanges( + `
`, + ), + ).toHaveLength(0); + }); + + it("allows Blade markers in raw-text states and blocks HTML markers there", () => { + const bladeSource = ``; + const htmlSource = ``; + + const bladeRanges = collectIgnoreRanges(bladeSource); + + expect(bladeRanges).toHaveLength(1); + expect(bladeRanges[0].resume.state).toBe(State.RawText); + expect(collectIgnoreRanges(htmlSource)).toHaveLength(0); + }); + + it.each([ + { + label: "HTML markers inside malformed opening tags", + source: `
class="x" >a
`, + }, + { + label: "Blade markers inside malformed opening tags", + source: `
a
{{-- format-ignore-end --}}`, + }, + ])("snaps ignored ranges back to the tag-open boundary for $label", ({ source }) => { + const ranges = collectIgnoreRanges(source); + + expect(ranges).toHaveLength(1); + expect(ranges[0].start).toBe(source.indexOf(" { + const source = `{{-- format-ignore-start --}}{{-- format-ignore-end --}}tail
`; + const ignoreRanges = collectIgnoreRanges(source); + const { tokens } = tokenize(source, undefined, { ignoreRanges }); + const ignoreIndex = tokens.findIndex((token) => token.type === TokenType.IgnoreRange); + + expect(reconstructFromTokens(tokens, source)).toBe(source); + expect(ignoreIndex).toBeGreaterThanOrEqual(0); + expect(source.slice(tokens[ignoreIndex].start, tokens[ignoreIndex].end)).toBe( + "{{-- format-ignore-start --}}{{-- format-ignore-end --}}", + ); + expect(tokens[ignoreIndex + 1].type).toBe(TokenType.Text); + expect(source.slice(tokens[ignoreIndex + 1].start, tokens[ignoreIndex + 1].end)).toBe( + "tail", + ); + expect(tokens[ignoreIndex + 2].type).toBe(TokenType.LessThan); + }); +}); diff --git a/tests/parser/ignore-range.test.ts b/tests/parser/ignore-range.test.ts new file mode 100644 index 0000000..cb05266 --- /dev/null +++ b/tests/parser/ignore-range.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { bladeParser } from "../../src/parser.js"; +import type { WrappedNode } from "../../src/types.js"; +import { NodeKind } from "../../src/tree/types.js"; + +function findNodesByKind(root: WrappedNode, kind: NodeKind): WrappedNode[] { + const matches: WrappedNode[] = []; + + function visit(node: WrappedNode): void { + if (node.kind === kind) { + matches.push(node); + } + + for (const attr of node.attrs) { + visit(attr); + } + + for (const child of node.children) { + visit(child); + } + } + + visit(root); + return matches; +} + +describe("parser/ignore-range", () => { + it("materializes root-level ignore ranges as opaque leaf nodes", () => { + const source = `{{-- format-ignore-start --}} +
+{{-- format-ignore-end --}} +
+`; + const root = bladeParser.parse(source, {}) as WrappedNode; + const ignoreRanges = findNodesByKind(root, NodeKind.IgnoreRange); + + expect(ignoreRanges).toHaveLength(1); + expect(ignoreRanges[0].rawText).toBe(`{{-- format-ignore-start --}} +
+{{-- format-ignore-end --}}`); + expect(ignoreRanges[0].children).toHaveLength(0); + expect(ignoreRanges[0].attrs).toHaveLength(0); + expect(root.children[0].kind).toBe(NodeKind.IgnoreRange); + expect(root.children[1].kind).toBe(NodeKind.Text); + expect(root.children[2].kind).toBe(NodeKind.Element); + }); + + it("keeps directive-body ignore ranges opaque and resumes normal siblings after them", () => { + const source = + "@if($x){{-- format-ignore-start --}}{{-- format-ignore-end --}}
@endif"; + const root = bladeParser.parse(source, {}) as WrappedNode; + const block = findNodesByKind(root, NodeKind.DirectiveBlock)[0]; + const openingDirective = block.children[0]; + const ignoreRanges = findNodesByKind(openingDirective, NodeKind.IgnoreRange); + + expect(ignoreRanges).toHaveLength(1); + expect(ignoreRanges[0].children).toHaveLength(0); + expect(ignoreRanges[0].rawText).toBe( + "{{-- format-ignore-start --}}{{-- format-ignore-end --}}", + ); + + const childKinds = openingDirective.children.map((child) => child.kind); + expect(childKinds).toEqual([ + NodeKind.IgnoreRange, + NodeKind.Element, + ]); + expect(block.children.map((child) => child.kind)).toEqual([ + NodeKind.Directive, + NodeKind.Directive, + ]); + }); +}); diff --git a/tests/support/ignore-range.ts b/tests/support/ignore-range.ts new file mode 100644 index 0000000..d63ffea --- /dev/null +++ b/tests/support/ignore-range.ts @@ -0,0 +1,84 @@ +import { expect } from "vitest"; +import { collectIgnoreRanges } from "../../src/lexer/index.js"; +import { Directives } from "../../src/lexer/directives.js"; +import { getIgnoreCommentKindFromCommentText } from "../../src/ignore-markers.js"; +import { resolveBladeSyntaxProfile } from "../../src/plugins/runtime.js"; + +function normalizeEndOfLine(text: string): string { + return text.replace(/\r\n?/g, "\n"); +} + +function getIgnoreRanges(source: string, options?: unknown) { + const syntaxProfile = resolveBladeSyntaxProfile(options); + return collectIgnoreRanges(source, Directives.acceptAll(), { + verbatimStartDirectives: syntaxProfile.verbatimStartDirectives, + verbatimEndDirectives: syntaxProfile.verbatimEndDirectives, + }); +} + +function getIgnoreRangeMarkerKind(comment: string): "start" | "end" | null { + const wrapper = comment.startsWith("{{--") ? "blade" : "html"; + const kind = getIgnoreCommentKindFromCommentText(comment, wrapper); + + if (kind === "ignore-start") { + return "start"; + } + if (kind === "ignore-end") { + return "end"; + } + + return null; +} + +function extractIgnoreRangeMarkerCounts(source: string): { start: number; end: number } { + const counts = { start: 0, end: 0 }; + + for (const match of source.matchAll(/\{\{--[\s\S]*?--\}\}|/g)) { + const kind = getIgnoreRangeMarkerKind(match[0]); + if (kind === "start") { + counts.start++; + } else if (kind === "end") { + counts.end++; + } + } + + return counts; +} + +export function containsIgnoreRanges(source: string, options?: unknown): boolean { + return getIgnoreRanges(source, options).length > 0; +} + +export function collectIgnoreRangeSlices(source: string, options?: unknown): string[] { + return getIgnoreRanges(source, options).map((range) => source.slice(range.start, range.end)); +} + +export function expectIgnoreRangeSlicesUnchanged( + input: string, + output: string, + context = "ignore-range contract", + options?: unknown, +): void { + expect( + collectIgnoreRangeSlices(output, options).map(normalizeEndOfLine), + `${context}: changed ignored range slices`, + ).toEqual(collectIgnoreRangeSlices(input, options).map(normalizeEndOfLine)); +} + +export function expectIgnoreRangesUnchanged( + input: string, + output: string, + context = "ignore-range contract", + options?: unknown, +): void { + if (!containsIgnoreRanges(input, options)) { + return; + } + + expect( + extractIgnoreRangeMarkerCounts(output), + `${context}: changed ignore marker counts`, + ).toEqual(extractIgnoreRangeMarkerCounts(input)); + + expectIgnoreRangeSlicesUnchanged(input, output, context, options); +} diff --git a/tests/validation/conformance/__snapshots__/formatter-cases.test.ts.snap b/tests/validation/conformance/__snapshots__/formatter-cases.test.ts.snap index a533a2a..819ab61 100644 --- a/tests/validation/conformance/__snapshots__/formatter-cases.test.ts.snap +++ b/tests/validation/conformance/__snapshots__/formatter-cases.test.ts.snap @@ -1963,7 +1963,6 @@ exports[`validation/conformance/formatter-cases > formats ignore__001 1`] = `

{{ $one }}: {{ $two }}

-

{{ $one }}: {{ $two }} @@ -1990,7 +1989,7 @@ exports[`validation/conformance/formatter-cases > formats ignore__001 1`] = ` @endif -@switch($i) + @switch($i) @case(1) First case... @break @@ -2004,16 +2003,15 @@ exports[`validation/conformance/formatter-cases > formats ignore__001 1`] = ` Default case... @endswitch -@unless($true) + @unless($true) @endunless - -@directive ($test + $that-$another + $thing) - -@unless($true) + + @directive ($test + $that-$another + $thing) + @unless($true) Hello @endunless - -@unless($true) + + @unless($true)

World

@@ -2031,13 +2029,13 @@ exports[`validation/conformance/formatter-cases > formats ignore__001 1`] = ` @endunless -
+
@forelse ($users as $user)
  • {{ $user->name }}
  • @endforelse
    - -@forelse ($users as $user) + + @forelse ($users as $user)
  • {{ $user->name }}
  • @endforelse formats ignore__002 1`] = `

    {{ $one }}: {{ $two }}

    -

    {{ $one }}: {{ $two }} @@ -2172,7 +2169,7 @@ exports[`validation/conformance/formatter-cases > formats ignore__002 1`] = ` @endif -@switch($i) + @switch($i) @case(1) First case... @break @@ -2186,16 +2183,15 @@ exports[`validation/conformance/formatter-cases > formats ignore__002 1`] = ` Default case... @endswitch -@unless($true) + @unless($true) @endunless - -@directive ($test + $that-$another + $thing) - -@unless($true) + + @directive ($test + $that-$another + $thing) + @unless($true) Hello @endunless - -@unless($true) + + @unless($true)

    World

    @@ -2213,13 +2209,13 @@ exports[`validation/conformance/formatter-cases > formats ignore__002 1`] = ` @endunless -
    +
    @forelse ($users as $user)
  • {{ $user->name }}
  • @endforelse
    - -@forelse ($users as $user) + + @forelse ($users as $user)
  • {{ $user->name }}
  • @endforelse handle( exports[`validation/conformance/formatter-cases > formats ignore__003 1`] = ` "@if (true) {{-- format-ignore-start --}} -
    - {{-- format-ignore-end --}} +
    + {{-- format-ignore-end --}} @endif " `; diff --git a/tests/validation/conformance/support/suite-runner.ts b/tests/validation/conformance/support/suite-runner.ts index 6e883a5..c955235 100644 --- a/tests/validation/conformance/support/suite-runner.ts +++ b/tests/validation/conformance/support/suite-runner.ts @@ -4,6 +4,7 @@ import plugin from "../../../../src/index.js"; import * as phpPlugin from "@prettier/plugin-php"; import { loadConformanceCases, type ConformanceGroup } from "./cases.js"; import { CONFORMANCE_SUITE_DEFAULT_OPTIONS } from "./suite-options.js"; +import { expectIgnoreRangesUnchanged } from "../../../support/ignore-range.js"; type OptionPrecedence = "entry-over-defaults" | "defaults-over-entry"; @@ -34,6 +35,7 @@ export function runConformanceSuite( ...mergedOptions, }); + expectIgnoreRangesUnchanged(entry.input, actual, entry.sourceLabel, mergedOptions); expect(actual, entry.sourceLabel).toMatchSnapshot(); }); } diff --git a/tests/validation/corpora/__snapshots__/filament-fixtures.test.ts.snap b/tests/validation/corpora/__snapshots__/filament-fixtures.test.ts.snap index fc08799..68c6126 100644 --- a/tests/validation/corpora/__snapshots__/filament-fixtures.test.ts.snap +++ b/tests/validation/corpora/__snapshots__/filament-fixtures.test.ts.snap @@ -6147,7 +6147,7 @@ exports[`validation/filament-fixtures > formats panels_resources_views_livewire_ @endphp {{-- format-ignore-start --}} - - {{-- format-ignore-end --}} + {{-- format-ignore-end --}}
    diff --git a/tests/validation/properties/fragment-wrapper-depth.test.ts b/tests/validation/properties/fragment-wrapper-depth.test.ts index 1746b1a..642ca70 100644 --- a/tests/validation/properties/fragment-wrapper-depth.test.ts +++ b/tests/validation/properties/fragment-wrapper-depth.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; import type { Options } from "prettier"; -import { formatWithPasses, wrapInDiv } from "../../helpers.js"; +import { + collectIgnoreRangeSlices, + expectIgnoreRangesUnchanged, + formatWithPasses, + wrapInDiv, +} from "../../helpers.js"; import { expectCoreConstructDelimiterSafety, expectNoBladePhpConstructLoss, @@ -9,8 +14,17 @@ import { const DEFAULT_MAX_DEPTH = 6; -const FRAGMENTS = [ - `
    +type FragmentCase = { + name: string; + source: string; + requiredLiterals?: readonly string[]; + compareAcrossDepth?: "all" | "php-safe-only"; +}; + +const FRAGMENTS: readonly FragmentCase[] = [ + { + name: "conditional-card", + source: `
    @if ($user) {{ $content }} @else @@ -18,13 +32,19 @@ const FRAGMENTS = [ @endif
    `, - ` `, - `

    WRAP_C

    `, - ` + compareAcrossDepth: "php-safe-only", + }, + { + name: "php-loader", + source: `
    `, - `@php + }, + { + name: "php-textarea", + source: `@php $message = "WRAP_E"; @endphp `, -]; + }, + { + name: "ignore-range-blade-whitespace", + source: `{{-- format-ignore-start --}} +Dear {{$user->first_name}}, +Roster on {{$date->format('d-m-Y')}} + +@foreach($messageData as $ecrewMessage) +===== +@endforeach +{{-- format-ignore-end --}} +

    After

    +`, + requiredLiterals: ['

    After

    '], + }, + { + name: "ignore-range-inline-sibling-pressure", + source: `{{-- prettier-ignore-start --}}@csrf('item'){{ $label }}*{{-- prettier-ignore-end --}} +
    +`, + requiredLiterals: ["
    "], + }, + { + name: "ignore-range-mixed-wrapper-html", + source: ` +🚨 Stick Time Monitor Alert 🚨 + +Date: {{ $flightplan->date->format('Y-m-d') }} +{{-- format-ignore-end --}} +
    +`, + requiredLiterals: ["
    "], + }, +] as const; const PROFILES: Array<{ name: string; options: Options }> = [ { name: "default", options: {} }, @@ -65,6 +125,14 @@ function normalizeForCompare(value: string): string { return normalizeEol(value).replace(/\n+$/u, "\n"); } +function shouldCompareAcrossDepth(fragment: FragmentCase, profileName: string): boolean { + if (fragment.compareAcrossDepth === "php-safe-only") { + return profileName === "php-safe"; + } + + return true; +} + function unwrapOneDivLayer(output: string): string { const lines = normalizeEol(output).split("\n"); let start = 0; @@ -92,32 +160,57 @@ function dedentByTwoSpaces(value: string): string { .join("\n")}\n`; } +function assertIgnoredSlicesPresent( + input: string, + output: string, + context: string, + options: Options, +): void { + const normalizedOutput = normalizeEol(output); + + for (const [index, slice] of collectIgnoreRangeSlices(input, options) + .map(normalizeEol) + .entries()) { + expect( + normalizedOutput, + `${context}: missing preserved ignore slice ${index}`, + ).toContain(slice); + } +} + describe("validation/fragment-wrapper-depth", () => { const maxDepth = Number.parseInt(process.env.VALIDATION_WRAPPER_INVARIANCE_MAX_DEPTH ?? "", 10); const depthLimit = Number.isFinite(maxDepth) && maxDepth > 0 ? maxDepth : DEFAULT_MAX_DEPTH; - for (const [fragmentIndex, fragment] of FRAGMENTS.entries()) { + for (const fragment of FRAGMENTS) { for (const profile of PROFILES) { - it(`fragment=${fragmentIndex} :: ${profile.name} :: depth=0..${depthLimit}`, async () => { - const shouldCompareAcrossDepth = !(fragmentIndex === 2 && profile.name !== "php-safe"); + it(`fragment=${fragment.name} :: ${profile.name} :: depth=0..${depthLimit}`, async () => { + const compareAcrossDepth = shouldCompareAcrossDepth(fragment, profile.name); const outputs: string[] = []; for (let depth = 0; depth <= depthLimit; depth++) { - const input = wrapInDiv(fragment, depth); + const input = wrapInDiv(fragment.source, depth); const output = await formatWithPasses(input, profile.options, { passes: 3, assertIdempotent: true, }); - const context = `wrapper-depth fragment=${fragmentIndex} depth=${depth} profile=${profile.name}`; + const context = `wrapper-depth fragment=${fragment.name} depth=${depth} profile=${profile.name}`; expectCoreConstructDelimiterSafety(input, output, context); expectNoBladePhpConstructLoss(input, output, context); expectRespectsFormattingInvariants(output, profile.options, context); + expectIgnoreRangesUnchanged(input, output, context, profile.options); + assertIgnoredSlicesPresent(input, output, context, profile.options); + + for (const literal of fragment.requiredLiterals ?? []) { + expect(output, `${context}: missing required literal ${literal}`).toContain(literal); + } + outputs.push(output); } for (let depth = 1; depth <= depthLimit; depth++) { - if (!shouldCompareAcrossDepth) { + if (!compareAcrossDepth) { continue; } const unwrapped = unwrapOneDivLayer(outputs[depth]); diff --git a/tests/validation/properties/malformed-recovery-cases.test.ts b/tests/validation/properties/malformed-recovery-cases.test.ts index ea9314b..1614096 100644 --- a/tests/validation/properties/malformed-recovery-cases.test.ts +++ b/tests/validation/properties/malformed-recovery-cases.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { Options } from "prettier"; import * as prettier from "prettier"; import bladePlugin from "../../../src/index.js"; -import { wrapInDiv } from "../../helpers.js"; +import { expectIgnoreRangesUnchanged, wrapInDiv } from "../../helpers.js"; import { expectCoreConstructDelimiterSafety, expectNoBladePhpConstructLoss, @@ -13,6 +13,7 @@ type RecoveryCase = { name: string; input: string; markers: string[]; + requiredLiterals?: readonly string[]; }; const CASES: readonly RecoveryCase[] = [ @@ -101,6 +102,16 @@ const x = @foo($x) `, markers: ["MAL_GA_7"], }, + { + name: "ignore-range-malformed-slot-tail-resumes-after-end-marker", + input: `@if($x) +{{-- format-ignore-start --}}{{-- format-ignore-end --}}tail +
    +@endif +`, + markers: ["format-ignore-start", "format-ignore-end"], + requiredLiterals: ["tail", "
    "], + }, ] as const; const PROFILES: Array<{ name: string; options: Options }> = [ @@ -159,6 +170,7 @@ describe("validation/malformed-recovery-cases", () => { expectCoreConstructDelimiterSafety(input, fourth, context); expectNoBladePhpConstructLoss(input, fourth, context); expectRespectsFormattingInvariants(fourth, profile.options, context); + expectIgnoreRangesUnchanged(input, fourth, context, profile.options); for (const marker of caseEntry.markers) { expect( @@ -166,6 +178,10 @@ describe("validation/malformed-recovery-cases", () => { `${context}: marker count drifted for ${marker}`, ).toBe(countOccurrences(input, marker)); } + + for (const literal of caseEntry.requiredLiterals ?? []) { + expect(fourth, `${context}: missing required literal ${literal}`).toContain(literal); + } } }); } diff --git a/tests/validation/properties/malformed-tail.test.ts b/tests/validation/properties/malformed-tail.test.ts index 400c9cb..a3e9312 100644 --- a/tests/validation/properties/malformed-tail.test.ts +++ b/tests/validation/properties/malformed-tail.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatWithPasses } from "../../helpers.js"; +import { expectIgnoreRangesUnchanged, formatWithPasses } from "../../helpers.js"; function countOccurrences(haystack: string, needle: string): number { if (needle.length === 0) return 0; @@ -49,4 +49,15 @@ describe("validation/malformed-tail", () => { expect(output).toBe(expected); expect(countOccurrences(output, marker)).toBe(countOccurrences(input, marker)); }); + + it("stabilizes malformed ignore ranges without absorbing following subtrees", async () => { + const input = `@if($x)\n{{-- format-ignore-start --}}{{-- format-ignore-end --}}tail\n
    \n@endif\n`; + const output = await formatWithPasses(input, {}, { passes: 5, assertIdempotent: true }); + const expected = `@if ($x)\n {{-- format-ignore-start --}}{{-- format-ignore-end --}}tail\n
    \n@endif\n`; + + expect(output).toBe(expected); + expect(countOccurrences(output, "format-ignore-start")).toBe(1); + expect(countOccurrences(output, "format-ignore-end")).toBe(1); + expectIgnoreRangesUnchanged(input, output); + }); }); diff --git a/tests/validation/support/fixture-suite.ts b/tests/validation/support/fixture-suite.ts index 1f811fe..16fee0a 100644 --- a/tests/validation/support/fixture-suite.ts +++ b/tests/validation/support/fixture-suite.ts @@ -5,6 +5,7 @@ import { expect } from "vitest"; import plugin from "../../../src/index.js"; import { tokenize } from "../../../src/lexer/lexer.js"; import { TokenType, tokenLabel, type Token } from "../../../src/lexer/types.js"; +import { expectIgnoreRangesUnchanged } from "../../support/ignore-range.js"; const VALIDATION_DEFAULT_OPTIONS: prettier.Options = { bladePhpFormatting: "off", @@ -186,6 +187,9 @@ export async function formatWithStabilityChecks( expectRespectsFormattingInvariants(first, options, "stability pass 1"); expectRespectsFormattingInvariants(second, options, "stability pass 2"); expectRespectsFormattingInvariants(third, options, "stability pass 3"); + expectIgnoreRangesUnchanged(input, first, "stability pass 1", formatOptions); + expectIgnoreRangesUnchanged(input, second, "stability pass 2", formatOptions); + expectIgnoreRangesUnchanged(input, third, "stability pass 3", formatOptions); return first; } @@ -209,6 +213,9 @@ export async function formatWithConvergenceChecks( expectRespectsFormattingInvariants(first, options, "convergence pass 1"); expectRespectsFormattingInvariants(second, options, "convergence pass 2"); expectRespectsFormattingInvariants(third, options, "convergence pass 3"); + expectIgnoreRangesUnchanged(input, first, "convergence pass 1", formatOptions); + expectIgnoreRangesUnchanged(input, second, "convergence pass 2", formatOptions); + expectIgnoreRangesUnchanged(input, third, "convergence pass 3", formatOptions); return { first, second, third }; } @@ -229,6 +236,8 @@ export async function formatWithRoundTripChecks( expectRespectsFormattingInvariants(first, options, "round-trip pass 1"); expectRespectsFormattingInvariants(second, options, "round-trip pass 2"); + expectIgnoreRangesUnchanged(input, first, "round-trip pass 1", formatOptions); + expectIgnoreRangesUnchanged(input, second, "round-trip pass 2", formatOptions); return { first, second }; }