From 50113539daefffb4550407e341ccf15c0fd8cd61 Mon Sep 17 00:00:00 2001 From: Pavel Zhukov Date: Sun, 19 Apr 2026 17:20:11 +0300 Subject: [PATCH] fix: stabilize multiline attr autofix reconstruction --- package-lock.json | 1 + packages/eslint-plugin-react-pug/package.json | 1 + packages/eslint-plugin-react-pug/src/index.ts | 278 ++++++++++++++++-- .../test/integration/autofix.test.ts | 260 ++++++++++++++++ 4 files changed, 512 insertions(+), 28 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a3eaf3..b904189 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21319,6 +21319,7 @@ "dependencies": { "@babel/parser": "^7.0.0", "@prettier/sync": "^0.6.1", + "@react-pug/pug-lexer": "^0.1.17", "@react-pug/react-pug-core": "^0.1.18", "@stylistic/eslint-plugin": "^5.10.0", "@typescript-eslint/parser": "^8.57.2", diff --git a/packages/eslint-plugin-react-pug/package.json b/packages/eslint-plugin-react-pug/package.json index 5d67ba2..f7bfbb1 100644 --- a/packages/eslint-plugin-react-pug/package.json +++ b/packages/eslint-plugin-react-pug/package.json @@ -14,6 +14,7 @@ "@babel/parser": "^7.0.0", "@prettier/sync": "^0.6.1", "@react-pug/react-pug-core": "^0.1.18", + "@react-pug/pug-lexer": "^0.1.17", "@stylistic/eslint-plugin": "^5.10.0", "@typescript-eslint/parser": "^8.57.2", "prettier": "^3.8.1" diff --git a/packages/eslint-plugin-react-pug/src/index.ts b/packages/eslint-plugin-react-pug/src/index.ts index 794ff0f..7d6905c 100644 --- a/packages/eslint-plugin-react-pug/src/index.ts +++ b/packages/eslint-plugin-react-pug/src/index.ts @@ -25,6 +25,7 @@ import { Linter, SourceCode } from 'eslint'; import bundledStylisticPlugin from '@stylistic/eslint-plugin'; import prettier from '@prettier/sync'; const tsParser = require('@typescript-eslint/parser'); +const pugLexer = require('@react-pug/pug-lexer'); interface EslintReactPugProcessorOptions { tagFunction?: string; @@ -78,13 +79,20 @@ interface EmbeddedLintBlockState { normalizedEndBoundaryMap: Array; syntheticRanges: InsertionOffsetRange[]; normalizedSiteCode: string; - restorationIndentWidth: number; + restorationIndent: string; + inlineAttributeContainer: InlineAttributeContainer | null; } interface NormalizedEmbeddedSite extends BoundaryMappedExpression { strippedContinuationIndent: number; } +interface InlineAttributeContainer { + start: number; + end: number; + lineIndent: string; +} + interface EslintProcessorLike { preprocess: ( text: string, @@ -477,43 +485,211 @@ function stripSharedContinuationIndent( function restoreSharedContinuationIndent( code: string, - restorationIndentWidth: number, + restorationIndent: string, ): string { - if (restorationIndentWidth <= 0 || !code.includes('\n')) return code; + if (restorationIndent.length === 0 || !code.includes('\n')) return code; - const prefix = ' '.repeat(restorationIndentWidth); return code .split('\n') .map((line, index) => { if (index === 0 || line.length === 0) return line; - return `${prefix}${line}`; + return `${restorationIndent}${line}`; + }) + .join('\n'); +} + +function getLineLeadingIndent( + text: string, + offset: number, +): string { + const { start, end } = findLineBounds(text, offset); + const line = text.slice(start, end); + return line.match(/^[ \t]*/)?.[0] ?? ''; +} + +function findInlineAttributeContainer( + text: string, + startOffset: number, + endOffset: number, +): InlineAttributeContainer | null { + const { start: lineStart, end: firstLineEnd } = findLineBounds(text, startOffset); + const firstLine = text.slice(lineStart, firstLineEnd); + const lineIndent = firstLine.match(/^[ \t]*/)?.[0] ?? ''; + let quote: "'" | '"' | '`' | null = null; + let escaped = false; + let openIndex: number | null = null; + + for (let index = 0; index < firstLine.length; index += 1) { + const char = firstLine[index] ?? ''; + + if (quote) { + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === quote) quote = null; + continue; + } + + if (char === '\'' || char === '"' || char === '`') { + quote = char; + continue; + } + + if (char === '(') { + const prefix = firstLine.slice(0, index).trim(); + const isTagLikePrefix = /^[A-Za-z0-9_.$:#-]+$/.test(prefix); + if (isTagLikePrefix) openIndex = lineStart + index; + break; + } + } + + if (openIndex == null || openIndex > startOffset) return null; + + quote = null; + escaped = false; + let parenDepth = 0; + for (let index = openIndex; index < text.length; index += 1) { + const char = text[index] ?? ''; + + if (quote) { + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === quote) quote = null; + continue; + } + + if (char === '\'' || char === '"' || char === '`') { + quote = char; + continue; + } + + if (char === '(') { + parenDepth += 1; + continue; + } + + if (char === ')' && parenDepth > 0) { + parenDepth -= 1; + if (parenDepth === 0) { + const absoluteEnd = index + 1; + if (absoluteEnd < endOffset) return null; + return { + start: openIndex, + end: absoluteEnd, + lineIndent, + }; + } + } + } + + return null; +} + +function normalizeMultilineAttributeValueIndent(value: string): string { + if (!value.includes('\n')) return value; + + const lines = value.split('\n'); + let minIndent: number | null = null; + for (const line of lines.slice(1)) { + if (line.trim().length === 0) continue; + const indent = line.match(/^[ \t]*/)?.[0].length ?? 0; + minIndent = minIndent == null ? indent : Math.min(minIndent, indent); + } + + if (minIndent == null || minIndent === 0) return value; + + return lines + .map((line, index) => { + if (index === 0 || line.length === 0) return line; + return line.slice(Math.min(minIndent!, line.match(/^[ \t]*/)?.[0].length ?? 0)); }) .join('\n'); } -function calculateRestorationIndentWidth( - originalSiteText: string, - normalizedSiteCode: string, -): number { - if (!originalSiteText.includes('\n') || !normalizedSiteCode.includes('\n')) return 0; +function extractInlinePugAttributeTexts(containerText: string): string[] | null { + const synthetic = `Div${containerText}`; + + try { + const tokens = pugLexer(synthetic, { filename: 'inline-attrs.pug' }); + const attributes = tokens.filter((token: any) => token.type === 'attribute'); + if (attributes.length === 0) return null; + + return attributes.map((token: any) => { + if (token.val === true) return token.name; + const operator = token.mustEscape === false ? '!=' : '='; + return `${token.name}${operator}${normalizeMultilineAttributeValueIndent(token.val)}`; + }); + } catch { + return null; + } +} + +function countInlinePugAttributes(containerText: string): number { + const synthetic = `Div${containerText}`; - const originalLines = originalSiteText.split('\n'); - const normalizedLines = normalizedSiteCode.split('\n'); - let minDiff: number | null = null; + try { + const tokens = pugLexer(synthetic, { filename: 'inline-attrs.pug' }); + return tokens.filter((token: any) => token.type === 'attribute').length; + } catch { + return 0; + } +} - for (let index = 1; index < Math.min(originalLines.length, normalizedLines.length); index += 1) { - const originalLine = originalLines[index] ?? ''; - const normalizedLine = normalizedLines[index] ?? ''; - if (originalLine.trim().length === 0 || normalizedLine.trim().length === 0) continue; +function buildInlineAttributeContainerAutofix( + entries: Array<{ block: EmbeddedLintBlockState; fix: NonNullable }>, + container: InlineAttributeContainer, + originalText: string, +): EslintLintMessage['fix'] | undefined { + const containerText = originalText.slice(container.start, container.end); + const localFixes = entries + .map(({ block, fix }) => ({ + start: block.site.originalStart - container.start, + end: block.site.originalEnd - container.start, + text: fix.text, + })) + .sort((a, b) => a.start - b.start || a.end - b.end); - const originalIndent = originalLine.match(/^[ \t]*/)?.[0].length ?? 0; - const normalizedIndent = normalizedLine.match(/^[ \t]*/)?.[0].length ?? 0; - const diff = originalIndent - normalizedIndent; - if (diff < 0) continue; - minDiff = minDiff == null ? diff : Math.min(minDiff, diff); + const replacedContainerText = applyLocalFixesToText(containerText, localFixes); + if (!replacedContainerText.includes('\n')) return undefined; + if (!replacedContainerText.startsWith('(') || !replacedContainerText.endsWith(')')) { + return { + range: [container.start, container.end], + text: replacedContainerText, + }; + } + + const attrs = extractInlinePugAttributeTexts(replacedContainerText); + if (!attrs || attrs.length === 0) { + return { + range: [container.start, container.end], + text: replacedContainerText, + }; } - return minDiff ?? 0; + const attrIndent = `${container.lineIndent} `; + const rebuilt = [ + '(', + ...attrs.map(attr => attr.split('\n').map(line => `${attrIndent}${line}`).join('\n')), + `${container.lineIndent})`, + ].join('\n'); + + if (rebuilt === containerText) return undefined; + + return { + range: [container.start, container.end], + text: rebuilt, + }; } function createEmbeddedLintBlockFilename( @@ -549,7 +725,6 @@ function createEmbeddedLintBlocks( site.code, Array.from({ length: site.code.length + 1 }, (_, offset) => offset), ); - const originalSiteText = originalText.slice(site.originalStart, site.originalEnd); const normalizedSiteIdentityMap = Array.from( { length: normalizedSite.code.length + 1 }, (_, offset) => offset, @@ -581,7 +756,12 @@ function createEmbeddedLintBlocks( normalizedBoundaryMap: normalizedBuilder.boundaryMap, normalizedEndBoundaryMap: normalizedBuilder.endBoundaryMap, normalizedSiteCode: normalizedSite.code, - restorationIndentWidth: calculateRestorationIndentWidth(originalSiteText, normalizedSite.code), + restorationIndent: getLineLeadingIndent(originalText, site.originalStart), + inlineAttributeContainer: findInlineAttributeContainer( + originalText, + site.originalStart, + site.originalEnd, + ), }); }); @@ -1278,7 +1458,7 @@ function buildEmbeddedSiteAutofix( const stabilizedCode = stabilizeEmbeddedSiteAutofix(block, fixedNormalizedCode); const restoredCode = restoreSharedContinuationIndent( stabilizedCode, - block.restorationIndentWidth, + block.restorationIndent, ); const originalSiteText = originalText.slice(block.site.originalStart, block.site.originalEnd); @@ -1468,6 +1648,40 @@ function createReactPugProcessor( .filter((msg): msg is EslintLintMessage => msg != null), ]; + const embeddedAutofixes = cached.embeddedLintBlocks.map((block, index) => ( + buildEmbeddedSiteAutofix(embeddedMessages[index] ?? [], block, cached.originalText) + )); + const containerAutofixes = new Map>(); + const containerGroups = new Map }>; + }>(); + + cached.embeddedLintBlocks.forEach((block, index) => { + const fix = embeddedAutofixes[index]; + const container = block.inlineAttributeContainer; + if (!fix || !container || !fix.text.includes('\n')) return; + if (countInlinePugAttributes(cached.originalText.slice(container.start, container.end)) <= 1) return; + const key = `${container.start}:${container.end}`; + const existing = containerGroups.get(key); + if (existing) { + existing.entries.push({ index, block, fix }); + } else { + containerGroups.set(key, { + container, + entries: [{ index, block, fix }], + }); + } + }); + + for (const { container, entries } of containerGroups.values()) { + const aggregatedFix = buildInlineAttributeContainerAutofix(entries, container, cached.originalText); + if (!aggregatedFix) continue; + for (const entry of entries) containerAutofixes.set(entry.index, aggregatedFix); + } + + const attachedFixKeys = new Set(); + embeddedMessages.forEach((blockMessages, index) => { const block = cached.embeddedLintBlocks[index]; if (!block) return; @@ -1475,17 +1689,25 @@ function createReactPugProcessor( .map((message) => mapEmbeddedLintMessage(message, block, cached.originalText, { includeFix: false, })); - const aggregatedFix = buildEmbeddedSiteAutofix(blockMessages, block, cached.originalText); + const aggregatedFix = containerAutofixes.get(index) ?? embeddedAutofixes[index]; let aggregatedFixAttached = false; + const aggregatedFixKey = aggregatedFix + ? `${aggregatedFix.range[0]}:${aggregatedFix.range[1]}:${aggregatedFix.text}` + : null; for (const mappedMessage of mappedBlockMessages) { if (!mappedMessage) continue; - if (!aggregatedFixAttached && aggregatedFix) { + if ( + !aggregatedFixAttached + && aggregatedFix + && (!aggregatedFixKey || !attachedFixKeys.has(aggregatedFixKey)) + ) { mapped.push({ ...mappedMessage, fix: aggregatedFix, }); aggregatedFixAttached = true; + if (aggregatedFixKey) attachedFixKeys.add(aggregatedFixKey); } else { mapped.push(mappedMessage); } diff --git a/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts b/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts index 20b3aad..0863703 100644 --- a/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts @@ -160,6 +160,266 @@ describe('eslint --fix integration for react-pug processor', () => { expect(secondPass.messages.some(message => message.ruleId === '@stylistic/space-infix-ops')).toBe(false) }) + it('preserves pug indentation when a single-line embedded handler autofix expands into multiline code', async () => { + const filePath = resolve(repoRoot, 'embedded-autofix-inline-handler.js') + const input = [ + "import { pug } from 'startupjs'", + "const router = { push: () => {} }", + '', + 'const view = pug`', + " Div.logoLink(onPress=()=> { router.push('/') })", + '`', + '', + ].join('\n') + + const [firstPass] = await createInlineEslint(true).lintText(input, { filePath }) + const output = firstPass.output ?? input + + expect(output).toMatchInlineSnapshot(` + "import { pug } from 'startupjs' + const router = { push: () => {} } + + const view = pug\` + Div.logoLink(onPress=() => { + router.push('/') + }) + \` + " + `) + + const [secondPass] = await createInlineEslint(false).lintText(output, { filePath }) + expect(secondPass.messages.some(message => message.ruleId === '@stylistic/arrow-spacing')).toBe(false) + expect(secondPass.messages.some(message => message.ruleId === '@stylistic/object-curly-newline')).toBe(false) + }) + + it('keeps complex embedded callback autofixes stable when a single-line site expands into multiline ternary and call formatting', async () => { + const filePath = resolve(repoRoot, 'embedded-autofix-complex-callback.js') + const input = [ + "import { pug } from 'startupjs'", + 'const isSelectable = true', + 'const handleSelect = () => {}', + "const item = { id: 1, title: 'Cash', subCategory: 'bank', path: ['Assets'] }", + 'const depth = 1', + "const fullPath = ['Assets', 'Cash']", + '', + 'const view = pug`', + ' Div.item(', + ' onPress=!isSelectable ? undefined : () => handleSelect(', + ' {', + ' id: item.id,', + ' title: item.title,', + ' subCategory: item.subCategory,', + ' depth,', + ' path: item.path,', + ' pathWithSelectedAccount: fullPath', + ' }', + ' )', + ' )', + '`', + '', + ].join('\n') + + const [firstPass] = await createInlineEslint(true).lintText(input, { filePath }) + const output = firstPass.output ?? input + + expect(output).toMatchInlineSnapshot(` + "import { pug } from 'startupjs' + const isSelectable = true + const handleSelect = () => {} + const item = { id: 1, title: 'Cash', subCategory: 'bank', path: ['Assets'] } + const depth = 1 + const fullPath = ['Assets', 'Cash'] + + const view = pug\` + Div.item( + onPress=!isSelectable + ? undefined + : () => + handleSelect({ + id: item.id, + title: item.title, + subCategory: item.subCategory, + depth, + path: item.path, + pathWithSelectedAccount: fullPath + }) + ) + \` + " + `) + + const [secondPass] = await createInlineEslint(false).lintText(output, { filePath }) + expect(secondPass.messages.some(message => String(message.ruleId).startsWith('@stylistic/'))).toBe(false) + }) + + it('preserves sibling attrs when multiple single-line handlers expand into multiline code', async () => { + const filePath = resolve(repoRoot, 'embedded-autofix-multi-handler-attrs.js') + const input = [ + "import { pug } from 'startupjs'", + "const router = { push: () => {} }", + '', + 'const view = pug`', + " Div(onPress=()=> { router.push('/') } onLongPress=()=> { router.push('/home') } variant='flat' disabled)", + '`', + '', + ].join('\n') + + const [firstPass] = await createInlineEslint(true).lintText(input, { filePath }) + const output = firstPass.output ?? input + + expect(output).toMatchInlineSnapshot(` + "import { pug } from 'startupjs' + const router = { push: () => {} } + + const view = pug\` + Div( + onPress=() => { + router.push('/') + } + onLongPress=() => { + router.push('/home') + } + variant='flat' + disabled + ) + \` + " + `) + + const [secondPass] = await createInlineEslint(false).lintText(output, { filePath }) + expect(secondPass.messages.some(message => String(message.ruleId).startsWith('@stylistic/'))).toBe(false) + }) + + it('keeps already-multiline attr lists stable when single-line handlers expand', async () => { + const filePath = resolve(repoRoot, 'embedded-autofix-multiline-handler-attrs.js') + const input = [ + "import { pug } from 'startupjs'", + "const router = { push: () => {} }", + '', + 'const view = pug`', + ' Div(', + " onPress=()=> {router.push('/')}", + " onLongPress=() => {router.push('/home')}", + " variant='flat'", + ' disabled', + ' )', + '`', + '', + ].join('\n') + + const [firstPass] = await createInlineEslint(true).lintText(input, { filePath }) + const output = firstPass.output ?? input + + expect(output).toMatchInlineSnapshot(` + "import { pug } from 'startupjs' + const router = { push: () => {} } + + const view = pug\` + Div( + onPress=() => { + router.push('/') + } + onLongPress=() => { + router.push('/home') + } + variant='flat' + disabled + ) + \` + " + `) + + const [secondPass] = await createInlineEslint(false).lintText(output, { filePath }) + expect(secondPass.messages.some(message => String(message.ruleId).startsWith('@stylistic/'))).toBe(false) + }) + + it('rebuilds partially-inline attr lists when attrs before the handler stay intact', async () => { + const filePath = resolve(repoRoot, 'embedded-autofix-partial-inline-handler-attrs.js') + const input = [ + "import { pug } from 'startupjs'", + "const router = { push: () => {} }", + '', + 'const view = pug`', + " Div(disabled variant='flat' onPress=()=> {router.push('/')}", + " onLongPress=() => {", + " router.push('/home')", + ' }', + ' )', + '`', + '', + ].join('\n') + + const [firstPass] = await createInlineEslint(true).lintText(input, { filePath }) + const output = firstPass.output ?? input + + expect(output).toMatchInlineSnapshot(` + "import { pug } from 'startupjs' + const router = { push: () => {} } + + const view = pug\` + Div( + disabled + variant='flat' + onPress=() => { + router.push('/') + } + onLongPress=() => { + router.push('/home') + } + ) + \` + " + `) + + const [secondPass] = await createInlineEslint(false).lintText(output, { filePath }) + expect(secondPass.messages.some(message => String(message.ruleId).startsWith('@stylistic/'))).toBe(false) + }) + + it('preserves attr order when an already-multiline handler precedes a single-line handler that expands', async () => { + const filePath = resolve(repoRoot, 'embedded-autofix-mixed-handler-order.js') + const input = [ + "import { pug } from 'startupjs'", + "const router = { push: () => {} }", + '', + 'const view = pug`', + ' Div(', + " onLongPress=() => {", + " router.push('/home')", + ' }', + " variant='flat'", + " onPress=()=> {router.push('/')}", + ' disabled', + ' )', + '`', + '', + ].join('\n') + + const [firstPass] = await createInlineEslint(true).lintText(input, { filePath }) + const output = firstPass.output ?? input + + expect(output).toMatchInlineSnapshot(` + "import { pug } from 'startupjs' + const router = { push: () => {} } + + const view = pug\` + Div( + onLongPress=() => { + router.push('/home') + } + variant='flat' + onPress=() => { + router.push('/') + } + disabled + ) + \` + " + `) + + const [secondPass] = await createInlineEslint(false).lintText(output, { filePath }) + expect(secondPass.messages.some(message => String(message.ruleId).startsWith('@stylistic/'))).toBe(false) + }) + it('does not corrupt files and preserves only the expected non-fixable diagnostics for an unformatted example fixture', async () => { const tempDir = createTempFixtureCopy()