Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 170 additions & 24 deletions packages/eslint-plugin-react-pug/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
mapGeneratedRangeToOriginal,
offsetToLineColumn,
rewriteSegmentedPugRegions,
type BoundaryMappedExpression,
type RewrittenPugRegionsResult,
type RegionFormattingContext,
type StartupjsCssxjsOption,
Expand Down Expand Up @@ -208,6 +209,38 @@ function containsJsxSyntax(text: string, filename: string): boolean {
}
}

function unwrapRootExpression(node: any): any {
let current = node;
while (
current
&& typeof current === 'object'
&& (
current.type === 'ParenthesizedExpression'
|| current.type === 'TSAsExpression'
|| current.type === 'TSTypeAssertion'
|| current.type === 'TSNonNullExpression'
)
) {
current = current.expression;
}
return current;
}

function isRootJsxExpression(text: string, filename: string): boolean {
try {
const expr = parse(`const __pug = ${text}\n`, {
sourceType: 'module',
plugins: getExpressionParserPlugins(filename),
createParenthesizedExpressions: true,
errorRecovery: false,
}) as any;
const root = unwrapRootExpression(expr.program.body[0]?.declarations?.[0]?.init);
return root?.type === 'JSXElement' || root?.type === 'JSXFragment';
} catch {
return false;
}
}

function collectLegacyStyleStatementRanges(text: string, filename: string): InsertionOffsetRange[] {
try {
const ast = parse(text, {
Expand Down Expand Up @@ -307,12 +340,30 @@ function normalizeJsxClosingBracketIndent(text: string): string {
return lines.join('\n');
}

function formatPugRegionForLint(
expr: string,
baseIndent: string,
formattingContext: RegionFormattingContext,
filename: string,
): { code: string; boundaryMap: number[] } {
function normalizeSyntheticWrapperClosingIndent(
text: string,
containerKind: RegionFormattingContext['containerKind'],
): string {
const lines = text.split('\n');
if (lines.length < 3) return text;
if (lines[0].trim() !== '(') return text;
if (lines[lines.length - 1].trim() !== ')') return text;

const firstContentLine = lines
.slice(1, -1)
.find(line => line.trim().length > 0);
if (!firstContentLine) return text;

const contentIndent = firstContentLine.match(/^[ \t]*/)?.[0] ?? '';
const shouldAlignWithContent = (
containerKind === 'conditional-branch'
|| containerKind === 'logical-operand'
);
lines[lines.length - 1] = `${shouldAlignWithContent ? contentIndent : ''})`;
return lines.join('\n');
}

function applyFormatterLintPasses(text: string, filename: string): string {
const lintConfig: any[] = [{
files: FLAT_LINT_FILES,
...FORMAT_RULE_CONFIG,
Expand All @@ -325,39 +376,105 @@ function formatPugRegionForLint(
}
: {}),
}];
const wrapper = createFormattingWrapper(expr, formattingContext.containerKind);
const prettyWrapped = prettier.format(wrapper, {
const pretty = prettier.format(text, {
parser: isTypeScriptLikeFilename(filename) ? 'babel-ts' : 'babel',
semi: false,
singleQuote: true,
jsxSingleQuote: true,
trailingComma: 'none',
bracketSameLine: false,
});

const fixedWrapped = formatLinter.verifyAndFix(prettyWrapped, lintConfig, getFormatterLintFilename(filename)).output;
const normalizedWrapped = normalizeJsxClosingBracketIndent(fixedWrapped);
const refixedWrapped = formatLinter.verifyAndFix(
normalizedWrapped,
const fixed = formatLinter.verifyAndFix(pretty, lintConfig, getFormatterLintFilename(filename)).output;
const normalized = normalizeJsxClosingBracketIndent(fixed);
const refixed = formatLinter.verifyAndFix(
normalized,
lintConfig,
getFormatterLintFilename(filename),
).output;
const finalWrapped = formatLinter.verifyAndFix(
normalizeJsxClosingBracketIndent(refixedWrapped),

return formatLinter.verifyAndFix(
normalizeJsxClosingBracketIndent(refixed),
lintConfig,
getFormatterLintFilename(filename),
).output;
}

function normalizeFormattedExpressionForLint(
text: string,
wrapperLineIndentWidth: number,
formattingContext: RegionFormattingContext,
filename: string,
): {
code: string;
wrapperLineIndentWidth: number;
hasSyntheticWrapperLines?: boolean;
} {
if (
formattingContext.containerKind !== 'standalone'
&& text.includes('\n')
&& isRootJsxExpression(text, filename)
) {
const wrapped = applyFormatterLintPasses(`const __pug = (${text})\n`, filename);
const extracted = extractFormattedExpressionFromWrapper(wrapped, 'variable-init', filename);
if (extracted) {
const normalizedCode = normalizeSyntheticWrapperClosingIndent(
extracted.code,
formattingContext.containerKind,
);
const lastNewline = extracted.code.lastIndexOf('\n');
return {
code: normalizedCode,
wrapperLineIndentWidth: extracted.wrapperLineIndentWidth,
hasSyntheticWrapperLines: lastNewline >= 0,
};
}
}

return {
code: text,
wrapperLineIndentWidth,
};
}

function getSyntheticWrapperLineRanges(text: string): InsertionOffsetRange[] {
const firstNewline = text.indexOf('\n');
const lastNewline = text.lastIndexOf('\n');
if (firstNewline < 0 || lastNewline < 0 || firstNewline === lastNewline) return [];

return [
{ start: 0, end: firstNewline + 1 },
{ start: lastNewline + 1, end: text.length },
];
}

function formatPugRegionForLint(
expr: string,
baseIndent: string,
formattingContext: RegionFormattingContext,
filename: string,
): BoundaryMappedExpression {
const wrapper = createFormattingWrapper(expr, formattingContext.containerKind);
const finalWrapped = applyFormatterLintPasses(wrapper, filename);

const extracted = extractFormattedExpressionFromWrapper(finalWrapped, formattingContext.containerKind, filename);
const body = rebaseFormattedRegion(
const normalized = normalizeFormattedExpressionForLint(
extracted?.code ?? expr,
baseIndent,
extracted?.wrapperLineIndentWidth ?? 0,
formattingContext,
filename,
);
const body = rebaseFormattedRegion(
normalized.code,
baseIndent,
normalized.wrapperLineIndentWidth,
);

return {
code: body,
boundaryMap: buildExpressionBoundaryMap(expr, body, filename),
syntheticRanges: normalized.hasSyntheticWrapperLines
? getSyntheticWrapperLineRanges(body)
: undefined,
};
}

Expand Down Expand Up @@ -424,10 +541,24 @@ function mapLintFix(
};
}

function overlapsRangeList(ranges: InsertionOffsetRange[], start: number, end: number): boolean {
function overlapsRangeList(
ranges: InsertionOffsetRange[] | null | undefined,
start: number,
end: number,
): boolean {
if (!ranges || ranges.length === 0) return false;
return ranges.some(range => start < range.end && end > range.start);
}

function overlapsFormattedSyntheticRegion(
formatted: RewrittenPugRegionsResult | null,
start: number,
end: number,
): boolean {
if (!formatted) return false;
return formatted.regionSegments.some(region => overlapsRangeList(region.syntheticRanges, start, end));
}

function shouldSuppressOriginalRangeMessage(
cached: CachedLintState,
message: EslintLintMessage,
Expand Down Expand Up @@ -474,19 +605,34 @@ function mapLintMessage(

if (message.line == null || message.column == null) return message;

const formattedStart = cached.formatted
? lineColumnToOffset(cached.formatted.code, message.line, message.column)
: null;
const formattedEnd = (
cached.formatted
&& message.endLine != null
&& message.endColumn != null
)
? lineColumnToOffset(cached.formatted.code, message.endLine, message.endColumn)
: null;

if (formattedStart != null && overlapsFormattedSyntheticRegion(
cached.formatted,
formattedStart,
Math.max(formattedStart + 1, formattedEnd ?? (formattedStart + 1)),
)) {
return null;
}

const generatedStart = cached.formatted
? cached.formatted.mapRewrittenOffsetToBase(
lineColumnToOffset(cached.formatted.code, message.line, message.column),
)
? cached.formatted.mapRewrittenOffsetToBase(formattedStart!)
: lineColumnToOffset(cached.transformed.code, message.line, message.column);
if (generatedStart == null) return message;

const generatedEnd = (message.endLine != null && message.endColumn != null)
? (
cached.formatted
? cached.formatted.mapRewrittenOffsetToBase(
lineColumnToOffset(cached.formatted.code, message.endLine, message.endColumn),
)
? cached.formatted.mapRewrittenOffsetToBase(formattedEnd!)
: lineColumnToOffset(cached.transformed.code, message.endLine, message.endColumn)
)
: generatedStart + 1;
Expand Down
24 changes: 8 additions & 16 deletions packages/eslint-plugin-react-pug/test/integration/autofix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const reactHooksStubPlugin = {
meta: { schema: [] },
create: () => ({}),
},
'exhaustive-deps': {
meta: { schema: [] },
create: () => ({}),
},
},
}

Expand Down Expand Up @@ -89,22 +93,7 @@ describe('eslint --fix integration for react-pug processor', () => {
message: message.message,
}))
))
expect(allMessages).toEqual([
{
filePath: 'src/StartupjsUiMdxComponents.js',
ruleId: 'react/jsx-boolean-value',
line: 205,
column: 34,
message: 'Value must be omitted for boolean attribute `value`',
},
{
filePath: 'src/StartupjsUiMdxComponents.js',
ruleId: 'react/jsx-boolean-value',
line: 257,
column: 27,
message: 'Value must be omitted for boolean attribute `value`',
},
])
expect(allMessages).toEqual([])

const fixedFiles = [
'src/App.tsx',
Expand All @@ -113,10 +102,13 @@ describe('eslint --fix integration for react-pug processor', () => {
'src/ModalScreen.tsx',
'src/RootLayout.tsx',
'src/StartupjsUiDialogsReadme.js',
'src/StartupjsUiDropdown.tsx',
'src/StartupjsUiDraggableReadme.js',
'src/StartupjsLogin.js',
'src/StartupjsUiMdxComponents.js',
'src/StartupjsUiMultiSelect.tsx',
'src/StartupjsUiPrompt.tsx',
'src/StartupjsUiTextInput.tsx',
'src/StartupjsUiTypeCell.js',
'src/StartupjsUiWrapInput.tsx',
'src/StartupjsTabThree.js',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ const reactHooksStubPlugin = {
meta: { schema: [] },
create: () => ({}),
},
'exhaustive-deps': {
meta: { schema: [] },
create: () => ({}),
},
},
}

Expand Down Expand Up @@ -78,19 +82,25 @@ describe('eslint diagnostics for example-unformatted fixture', () => {
const startupjsUiDialogsReadme = results.find(result => result.filePath.endsWith('/src/StartupjsUiDialogsReadme.js'))
expect(startupjsUiDialogsReadme?.messages.some(message => message.ruleId === 'react/jsx-fragments')).toBe(false)

const startupjsUiDropdown = results.find(result => result.filePath.endsWith('/src/StartupjsUiDropdown.tsx'))
expect(startupjsUiDropdown?.messages.some(message => message.ruleId === '@stylistic/jsx-closing-tag-location')).toBe(false)

const startupjsUiDraggableReadme = results.find(result => result.filePath.endsWith('/src/StartupjsUiDraggableReadme.js'))
expect(startupjsUiDraggableReadme?.messages.some(message => message.ruleId === 'no-unneeded-ternary')).toBe(false)

const startupjsUiTypeCell = results.find(result => result.filePath.endsWith('/src/StartupjsUiTypeCell.js'))
expect(startupjsUiTypeCell?.messages.some(message => message.ruleId === '@stylistic/no-multi-spaces')).toBe(false)

const startupjsUiTextInput = results.find(result => result.filePath.endsWith('/src/StartupjsUiTextInput.tsx'))
expect(startupjsUiTextInput?.messages.some(message => message.ruleId === '@stylistic/jsx-closing-tag-location')).toBe(false)

const startupjsUiPrompt = results.find(result => result.filePath.endsWith('/src/StartupjsUiPrompt.tsx'))
expect(startupjsUiPrompt?.messages.some(message => message.ruleId === '@stylistic/indent')).toBe(false)

const startupjsUiMdxComponents = results.find(result => result.filePath.endsWith('/src/StartupjsUiMdxComponents.js'))
expect(startupjsUiMdxComponents?.messages.map(message => message.ruleId)).toEqual([
'react/jsx-boolean-value',
'react/jsx-boolean-value',
])
expect(startupjsUiMdxComponents?.messages.some(message => message.ruleId === '@stylistic/indent')).toBe(false)

const startupjsUiMultiSelect = results.find(result => result.filePath.endsWith('/src/StartupjsUiMultiSelect.tsx'))
expect(startupjsUiMultiSelect?.messages.some(message => message.ruleId === '@stylistic/indent')).toBe(false)
}, 30000)
})
Loading