diff --git a/package-lock.json b/package-lock.json index a7b23c5..c601e05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,9 @@ "lerna": "^9.0.5", "neostandard": "^0.13.0", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^9.3.2" } }, "node_modules/@babel/code-frame": { @@ -96,7 +98,6 @@ "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -12708,7 +12709,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -20490,10 +20490,24 @@ } } }, + "node_modules/vscode-oniguruma": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz", + "integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-react-pug-tsx": { "resolved": "packages/vscode-react-pug-tsx", "link": true }, + "node_modules/vscode-textmate": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.3.2.tgz", + "integrity": "sha512-n2uGbUcrjhUEBH16uGA0TvUfhWwliFZ1e3+pTjrkim1Mt7ydB41lV08aUvsi70OlzDWp6X7Bx3w/x3fAXIsN0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -23037,7 +23051,9 @@ "name": "@react-pug/react-pug-core", "version": "0.1.10", "dependencies": { + "@babel/generator": "^7.0.0", "@babel/parser": "^7.0.0", + "@babel/types": "^7.0.0", "@jridgewell/gen-mapping": "^0.3.13", "@react-pug/pug-lexer": "^0.1.7", "@volar/source-map": "^2.4.28", diff --git a/package.json b/package.json index a625c0e..72d0584 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,9 @@ "lerna": "^9.0.5", "neostandard": "^0.13.0", "typescript": "^5.9.3", - "vitest": "^4.0.18" + "vitest": "^4.0.18", + "vscode-oniguruma": "^1.7.0", + "vscode-textmate": "^9.3.2" }, "repository": "https://github.com/startupjs/react-pug" } diff --git a/packages/babel-plugin-react-pug/src/basicRuntimeTransform.ts b/packages/babel-plugin-react-pug/src/basicRuntimeTransform.ts new file mode 100644 index 0000000..24db88c --- /dev/null +++ b/packages/babel-plugin-react-pug/src/basicRuntimeTransform.ts @@ -0,0 +1,263 @@ +import { parseExpression } from '@babel/parser'; +import type { TaggedTemplateExpression } from '@babel/types'; +import type { PugRegion, StyleTagLang } from '@react-pug/react-pug-core'; + +interface BasicTransformMetadata { + regions: PugRegion[]; +} + +function parseRuntimeExpression(code: string) { + return parseExpression(code, { + sourceType: 'module', + plugins: ['typescript', 'jsx', 'decorators-legacy'], + errorRecovery: false, + }); +} + +function escapeTemplateLiteralContent(content: string): string { + return content + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${'); +} + +function formatStyleTemplateLiteralContent(content: string): string { + const normalized = content.endsWith('\n') ? content.slice(0, -1) : content; + const lines = normalized.split('\n'); + const indented = lines.map(line => (line.length > 0 ? ` ${line}` : '')); + return `\n${indented.join('\n')}\n`; +} + +function parseRuntimeStyleCall(helper: string, content: string) { + return parseRuntimeExpression(`${helper}\`${escapeTemplateLiteralContent(formatStyleTemplateLiteralContent(content))}\``); +} + +function isDirectiveStatementPath(path: any): boolean { + return path.isExpressionStatement() && typeof path.node.directive === 'string'; +} + +function shouldWrapStatementBodyPath(parentPath: any, key: string | number | null, childPath: any): boolean { + if (!childPath || childPath.isBlockStatement()) return false; + if (typeof key !== 'string') return false; + + if (parentPath.isIfStatement() && (key === 'consequent' || key === 'alternate')) return true; + if ( + (parentPath.isWhileStatement() + || parentPath.isDoWhileStatement() + || parentPath.isForStatement() + || parentPath.isForInStatement() + || parentPath.isForOfStatement() + || parentPath.isWithStatement() + || parentPath.isLabeledStatement()) + && key === 'body' + ) { + return true; + } + + return false; +} + +function findStyleInsertionTarget(taggedPath: any): { kind: 'program' | 'block' | 'arrow-expression' | 'statement-body'; path: any; key?: string } { + let current = taggedPath; + while (current?.parentPath) { + const parentPath = current.parentPath; + const key = typeof current.key === 'string' ? current.key : null; + + if (parentPath.isArrowFunctionExpression() && key === 'body' && !current.isBlockStatement()) { + return { kind: 'arrow-expression', path: parentPath }; + } + if (shouldWrapStatementBodyPath(parentPath, key, current)) { + return { kind: 'statement-body', path: parentPath, key: key ?? undefined }; + } + if (parentPath.isBlockStatement()) { + return { kind: 'block', path: parentPath }; + } + if (parentPath.isProgram()) { + return { kind: 'program', path: parentPath }; + } + + current = parentPath; + } + + return { kind: 'program', path: taggedPath.findParent((p: any) => p.isProgram()) }; +} + +function insertAtStartOfContainer(api: { types: any }, target: { kind: 'program' | 'block'; path: any }, statements: any[]): void { + const bodyPaths = target.path.get('body'); + const anchor = bodyPaths.find((statementPath: any) => ( + target.kind === 'program' + ? !statementPath.isImportDeclaration() && !isDirectiveStatementPath(statementPath) + : !isDirectiveStatementPath(statementPath) + )); + + if (anchor) { + anchor.insertBefore(statements); + } else { + target.path.pushContainer('body', statements); + } +} + +function ensureStyleHelpersOnImport(api: { types: any }, importPath: any, helpers: string[]): void { + const existing = new Set( + importPath.node.specifiers + .filter((specifier: any) => specifier.type === 'ImportSpecifier' && specifier.imported?.type === 'Identifier') + .map((specifier: any) => specifier.imported.name), + ); + + for (const helper of helpers) { + if (existing.has(helper)) continue; + importPath.node.specifiers.push( + api.types.importSpecifier(api.types.identifier(helper), api.types.identifier(helper)), + ); + existing.add(helper); + } +} + +function hoistStyleCallAtTarget( + api: { types: any }, + taggedPath: any, + helper: string, + content: string, +): void { + const statement = api.types.expressionStatement(parseRuntimeStyleCall(helper, content)); + const target = findStyleInsertionTarget(taggedPath); + + if (target.kind === 'program' || target.kind === 'block') { + insertAtStartOfContainer(api, { kind: target.kind, path: target.path }, [statement]); + return; + } + + if (target.kind === 'arrow-expression') { + const originalBody = target.path.get('body').node; + target.path.get('body').replaceWith( + api.types.blockStatement([ + statement, + api.types.returnStatement(originalBody), + ]), + ); + return; + } + + if (target.kind === 'statement-body') { + const originalBody = target.path.get(target.key as string).node; + target.path.get(target.key as string).replaceWith( + api.types.blockStatement([statement, originalBody]), + ); + } +} + +function hasMatchingTagImport(programPath: any, tagFunction: string): boolean { + return programPath.get('body').some((statementPath: any) => { + if (!statementPath.isImportDeclaration()) return false; + return statementPath.get('specifiers').some((specifierPath: any) => { + if (specifierPath.isImportSpecifier()) { + return ( + specifierPath.node.local?.name === tagFunction + && specifierPath.node.imported?.type === 'Identifier' + && specifierPath.node.imported.name === 'pug' + ); + } + if (specifierPath.isImportDefaultSpecifier()) { + return specifierPath.node.local?.name === tagFunction; + } + return false; + }); + }); +} + +export function applyBasicRuntimeTransform( + api: { types: any }, + programPath: any, + transformed: { metadata: BasicTransformMetadata }, + tagFunction: string, + requirePugImport: boolean, +): void { + if (requirePugImport && !hasMatchingTagImport(programPath, tagFunction)) { + throw programPath.buildCodeFrameError(`Missing import for tag function "${tagFunction}"`); + } + + const taggedTemplates = new Map(); + const matchingImportPaths: any[] = []; + const matchingImportSources = new Set(); + + programPath.traverse({ + TaggedTemplateExpression(taggedPath: any) { + const node = taggedPath.node as TaggedTemplateExpression; + if (typeof node.start !== 'number' || typeof node.end !== 'number') return; + taggedTemplates.set(`${node.start}:${node.end}`, taggedPath); + }, + ImportDeclaration(importPath: any) { + const matched = importPath.get('specifiers').some((specifierPath: any) => { + if (specifierPath.isImportSpecifier()) { + return ( + specifierPath.node.local?.name === tagFunction + && specifierPath.node.imported?.type === 'Identifier' + && specifierPath.node.imported.name === 'pug' + ); + } + if (specifierPath.isImportDefaultSpecifier()) { + return specifierPath.node.local?.name === tagFunction; + } + return false; + }); + if (!matched) return; + matchingImportPaths.push(importPath); + if (typeof importPath.node?.source?.value === 'string') { + matchingImportSources.add(importPath.node.source.value); + } + }, + }); + + const helpersNeeded = [...new Set( + transformed.metadata.regions + .map(region => region.styleBlock?.lang) + .filter((helper): helper is StyleTagLang => helper != null), + )]; + + if (helpersNeeded.length > 0 && matchingImportPaths.length > 0) { + ensureStyleHelpersOnImport(api, matchingImportPaths[0], helpersNeeded); + } + + const sortedRegions = [...transformed.metadata.regions] + .sort((a, b) => b.originalStart - a.originalStart); + + for (const region of sortedRegions) { + const taggedPath = taggedTemplates.get(`${region.originalStart}:${region.originalEnd}`); + if (!taggedPath?.node) continue; + if (region.styleBlock) { + hoistStyleCallAtTarget(api, taggedPath, region.styleBlock.lang, region.styleBlock.content); + } + taggedPath.replaceWith(parseRuntimeExpression(region.tsxText)); + } + + programPath.traverse({ + ImportDeclaration(importPath: any) { + const sourceValue = importPath.node?.source?.value; + if (!sourceValue) return; + if (matchingImportSources.size > 0 && !matchingImportSources.has(sourceValue)) return; + + const matched = importPath.node.specifiers.filter((specifier: any) => { + if (specifier.type === 'ImportSpecifier') { + return specifier.local?.name === tagFunction && specifier.imported?.type === 'Identifier' && specifier.imported.name === 'pug'; + } + if (specifier.type === 'ImportDefaultSpecifier') { + return specifier.local?.name === tagFunction; + } + return false; + }); + + if (matched.length === 0) return; + + importPath.node.specifiers = importPath.node.specifiers.filter((specifier: any) => !matched.includes(specifier)); + if (importPath.node.specifiers.length === 0) { + if (importPath.node.importKind === 'type') { + importPath.remove(); + } else { + importPath.replaceWith(api.types.importDeclaration([], api.types.stringLiteral(sourceValue))); + } + } + }, + }); + + programPath.scope.crawl(); +} diff --git a/packages/babel-plugin-react-pug/src/index.ts b/packages/babel-plugin-react-pug/src/index.ts index 0534098..ac1098f 100644 --- a/packages/babel-plugin-react-pug/src/index.ts +++ b/packages/babel-plugin-react-pug/src/index.ts @@ -1,7 +1,6 @@ import type { PluginObj, PluginPass } from '@babel/core'; -import { parseExpression } from '@babel/parser'; import type { ParseResult } from '@babel/parser'; -import type { File, TaggedTemplateExpression } from '@babel/types'; +import type { File } from '@babel/types'; import { createTransformSourceMap, hasTagFunctionCall, @@ -9,7 +8,6 @@ import { type ClassAttributeOption, type ClassMergeOption, type StartupjsCssxjsOption, - type StyleTagLang, transformSourceFile, type GeneratedDiagnosticLike, type OriginalDiagnosticLocation, @@ -17,6 +15,7 @@ import { type PugRegion, type TransformSourceMap, } from '@react-pug/react-pug-core'; +import { applyBasicRuntimeTransform } from './basicRuntimeTransform'; export type BabelPugCompileMode = 'runtime' | 'languageService'; export type BabelPugSourceMapMode = 'basic' | 'detailed'; @@ -43,10 +42,6 @@ export interface BabelReactPugTransformResult { sourceMap: TransformSourceMap; } -function hasStyleBlocks(metadata: BabelReactPugMetadata): boolean { - return metadata.regions.some(region => region.styleBlock != null); -} - export function transformReactPugSourceForBabel( sourceText: string, fileName: string, @@ -83,146 +78,6 @@ function buildTransformCacheKey(sourceText: string, fileName: string): string { return `${fileName}\0${sourceText}`; } -function parseRuntimeExpression(code: string) { - return parseExpression(code, { - sourceType: 'module', - plugins: ['typescript', 'jsx', 'decorators-legacy'], - errorRecovery: false, - }); -} - -function escapeTemplateLiteralContent(content: string): string { - return content - .replace(/\\/g, '\\\\') - .replace(/`/g, '\\`') - .replace(/\$\{/g, '\\${'); -} - -function formatStyleTemplateLiteralContent(content: string): string { - const normalized = content.endsWith('\n') ? content.slice(0, -1) : content; - const lines = normalized.split('\n'); - const indented = lines.map(line => (line.length > 0 ? ` ${line}` : '')); - return `\n${indented.join('\n')}\n`; -} - -function parseRuntimeStyleCall(helper: string, content: string) { - return parseRuntimeExpression(`${helper}\`${escapeTemplateLiteralContent(formatStyleTemplateLiteralContent(content))}\``); -} - -function isDirectiveStatementPath(path: any): boolean { - return path.isExpressionStatement() && typeof path.node.directive === 'string'; -} - -function shouldWrapStatementBodyPath(parentPath: any, key: string | number | null, childPath: any): boolean { - if (!childPath || childPath.isBlockStatement()) return false; - if (typeof key !== 'string') return false; - - if (parentPath.isIfStatement() && (key === 'consequent' || key === 'alternate')) return true; - if ( - (parentPath.isWhileStatement() - || parentPath.isDoWhileStatement() - || parentPath.isForStatement() - || parentPath.isForInStatement() - || parentPath.isForOfStatement() - || parentPath.isWithStatement() - || parentPath.isLabeledStatement()) - && key === 'body' - ) { - return true; - } - - return false; -} - -function findStyleInsertionTarget(taggedPath: any): { kind: 'program' | 'block' | 'arrow-expression' | 'statement-body'; path: any; key?: string } { - let current = taggedPath; - while (current?.parentPath) { - const parentPath = current.parentPath; - const key = typeof current.key === 'string' ? current.key : null; - - if (parentPath.isArrowFunctionExpression() && key === 'body' && !current.isBlockStatement()) { - return { kind: 'arrow-expression', path: parentPath }; - } - if (shouldWrapStatementBodyPath(parentPath, key, current)) { - return { kind: 'statement-body', path: parentPath, key: key ?? undefined }; - } - if (parentPath.isBlockStatement()) { - return { kind: 'block', path: parentPath }; - } - if (parentPath.isProgram()) { - return { kind: 'program', path: parentPath }; - } - - current = parentPath; - } - - return { kind: 'program', path: taggedPath.findParent((p: any) => p.isProgram()) }; -} - -function insertAtStartOfContainer(api: { types: any }, target: { kind: 'program' | 'block'; path: any }, statements: any[]): void { - const bodyPaths = target.path.get('body'); - const anchor = bodyPaths.find((statementPath: any) => ( - target.kind === 'program' - ? !statementPath.isImportDeclaration() && !isDirectiveStatementPath(statementPath) - : !isDirectiveStatementPath(statementPath) - )); - - if (anchor) { - anchor.insertBefore(statements); - } else { - target.path.pushContainer('body', statements); - } -} - -function ensureStyleHelpersOnImport(api: { types: any }, importPath: any, helpers: string[]): void { - const existing = new Set( - importPath.node.specifiers - .filter((specifier: any) => specifier.type === 'ImportSpecifier' && specifier.imported?.type === 'Identifier') - .map((specifier: any) => specifier.imported.name), - ); - - for (const helper of helpers) { - if (existing.has(helper)) continue; - importPath.node.specifiers.push( - api.types.importSpecifier(api.types.identifier(helper), api.types.identifier(helper)), - ); - existing.add(helper); - } -} - -function hoistStyleCallAtTarget( - api: { types: any }, - taggedPath: any, - helper: string, - content: string, -): void { - const statement = api.types.expressionStatement(parseRuntimeStyleCall(helper, content)); - const target = findStyleInsertionTarget(taggedPath); - - if (target.kind === 'program' || target.kind === 'block') { - insertAtStartOfContainer(api, { kind: target.kind, path: target.path }, [statement]); - return; - } - - if (target.kind === 'arrow-expression') { - const originalBody = target.path.get('body').node; - target.path.get('body').replaceWith( - api.types.blockStatement([ - statement, - api.types.returnStatement(originalBody), - ]), - ); - return; - } - - if (target.kind === 'statement-body') { - const originalBody = target.path.get(target.key as string).node; - target.path.get(target.key as string).replaceWith( - api.types.blockStatement([statement, originalBody]), - ); - } -} - export default function babelPluginReactPug( api: { types: any }, options: BabelReactPugPluginOptions = {}, @@ -239,25 +94,6 @@ export default function babelPluginReactPug( const requirePugImport = options.requirePugImport ?? false; const transformCache = new Map(); - function hasMatchingTagImport(programPath: any): boolean { - return programPath.get('body').some((statementPath: any) => { - if (!statementPath.isImportDeclaration()) return false; - return statementPath.get('specifiers').some((specifierPath: any) => { - if (specifierPath.isImportSpecifier()) { - return ( - specifierPath.node.local?.name === tagFunction - && specifierPath.node.imported?.type === 'Identifier' - && specifierPath.node.imported.name === 'pug' - ); - } - if (specifierPath.isImportDefaultSpecifier()) { - return specifierPath.node.local?.name === tagFunction; - } - return false; - }); - }); - } - const plugin: PluginObj & { parserOverride?: ( sourceText: string, @@ -287,93 +123,7 @@ export default function babelPluginReactPug( if (transformed.metadata.regions.length === 0) return; if (sourceMapsMode === 'basic') { - if (requirePugImport && !hasMatchingTagImport(path)) { - throw path.buildCodeFrameError(`Missing import for tag function "${tagFunction}"`); - } - - const taggedTemplates = new Map(); - const matchingImportPaths: any[] = []; - const matchingImportSources = new Set(); - path.traverse({ - TaggedTemplateExpression(taggedPath: any) { - const node = taggedPath.node as TaggedTemplateExpression; - if (typeof node.start !== 'number' || typeof node.end !== 'number') return; - taggedTemplates.set(`${node.start}:${node.end}`, taggedPath); - }, - ImportDeclaration(importPath: any) { - const matched = importPath.get('specifiers').some((specifierPath: any) => { - if (specifierPath.isImportSpecifier()) { - return ( - specifierPath.node.local?.name === tagFunction - && specifierPath.node.imported?.type === 'Identifier' - && specifierPath.node.imported.name === 'pug' - ); - } - if (specifierPath.isImportDefaultSpecifier()) { - return specifierPath.node.local?.name === tagFunction; - } - return false; - }); - if (!matched) return; - matchingImportPaths.push(importPath); - if (typeof importPath.node?.source?.value === 'string') { - matchingImportSources.add(importPath.node.source.value); - } - }, - }); - - const helpersNeeded = [...new Set( - transformed.metadata.regions - .map(region => region.styleBlock?.lang) - .filter((helper): helper is StyleTagLang => helper != null), - )]; - - if (helpersNeeded.length > 0 && matchingImportPaths.length > 0) { - ensureStyleHelpersOnImport(api, matchingImportPaths[0], helpersNeeded); - } - - const sortedRegions = [...transformed.metadata.regions] - .sort((a, b) => b.originalStart - a.originalStart); - - for (const region of sortedRegions) { - const taggedPath = taggedTemplates.get(`${region.originalStart}:${region.originalEnd}`); - if (!taggedPath?.node) continue; - if (region.styleBlock) { - hoistStyleCallAtTarget(api, taggedPath, region.styleBlock.lang, region.styleBlock.content); - } - taggedPath.replaceWith(parseRuntimeExpression(region.tsxText)); - } - - path.traverse({ - ImportDeclaration(importPath: any) { - const sourceValue = importPath.node?.source?.value; - if (!sourceValue) return; - if (matchingImportSources.size > 0 && !matchingImportSources.has(sourceValue)) return; - - const matched = importPath.node.specifiers.filter((specifier: any) => { - if (specifier.type === 'ImportSpecifier') { - return specifier.local?.name === tagFunction && specifier.imported?.type === 'Identifier' && specifier.imported.name === 'pug'; - } - if (specifier.type === 'ImportDefaultSpecifier') { - return specifier.local?.name === tagFunction; - } - return false; - }); - - if (matched.length === 0) return; - - importPath.node.specifiers = importPath.node.specifiers.filter((specifier: any) => !matched.includes(specifier)); - if (importPath.node.specifiers.length === 0) { - if (importPath.node.importKind === 'type') { - importPath.remove(); - } else { - importPath.replaceWith(api.types.importDeclaration([], api.types.stringLiteral(sourceValue))); - } - } - }, - }); - - path.scope.crawl(); + applyBasicRuntimeTransform(api, path, transformed, tagFunction, requirePugImport); } (state.file.metadata as Record).reactPug = transformed.metadata; diff --git a/packages/eslint-plugin-react-pug/src/index.ts b/packages/eslint-plugin-react-pug/src/index.ts index 3d8af60..ebed7b4 100644 --- a/packages/eslint-plugin-react-pug/src/index.ts +++ b/packages/eslint-plugin-react-pug/src/index.ts @@ -1,12 +1,20 @@ import { + buildExpressionBoundaryMap, type ClassAttributeOption, type ClassMergeOption, + collectMappedInsertionRangesByKind, + createFormattingWrapper, + createLintTransform, + extractFormattedExpressionFromWrapper, hasTagFunctionCall, + type InsertionOffsetRange, lineColumnToOffset, mapGeneratedRangeToOriginal, offsetToLineColumn, + rewriteSegmentedPugRegions, + type RewrittenPugRegionsResult, + type RegionFormattingContext, type StartupjsCssxjsOption, - transformSourceFile, } from '@react-pug/react-pug-core'; import { parse } from '@babel/parser'; import { Linter, SourceCode } from 'eslint'; @@ -45,39 +53,14 @@ interface EslintLintMessage { [key: string]: unknown; } -type SourceTransformState = ReturnType; - -interface FormattedCopySegment { - formattedStart: number; - formattedEnd: number; - transformedStart: number; - transformedEnd: number; -} - -interface FormattedRegionSegment { - formattedStart: number; - formattedEnd: number; - transformedStart: number; - transformedEnd: number; - boundaryMap: number[]; -} - -interface FormattedLintCode { - code: string; - copySegments: FormattedCopySegment[]; - regionSegments: FormattedRegionSegment[]; -} - -interface OffsetRange { - start: number; - end: number; -} +type LintTransformState = ReturnType; interface CachedLintState { originalText: string; - transformed: SourceTransformState | null; - formatted: FormattedLintCode | null; - legacyStyleStatementRanges: OffsetRange[]; + transformed: LintTransformState | null; + formatted: RewrittenPugRegionsResult | null; + legacyStyleStatementRanges: InsertionOffsetRange[]; + syntheticStyleCallRanges: InsertionOffsetRange[]; } interface EslintProcessorLike { @@ -89,7 +72,6 @@ interface EslintProcessorLike { supportsAutofix: boolean; } -const FORMAT_WRAPPER_PREFIX = 'const __pug = '; const FLAT_LINT_FILES = ['**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}']; const LEGACY_STYLE_HELPERS = new Set(['styl', 'css', 'sass', 'scss']); const LEGACY_STYLE_SUPPRESSED_RULES = new Set(['no-unused-expressions', 'no-unreachable']); @@ -217,11 +199,7 @@ function containsJsxSyntax(text: string, filename: string): boolean { try { const ast = parse(text, { sourceType: 'module', - plugins: [ - 'jsx', - 'decorators-legacy', - ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), - ] as any, + plugins: getExpressionParserPlugins(filename), errorRecovery: false, }) as any; return astContainsJsx(ast.program); @@ -230,19 +208,15 @@ function containsJsxSyntax(text: string, filename: string): boolean { } } -function collectLegacyStyleStatementRanges(text: string, filename: string): OffsetRange[] { +function collectLegacyStyleStatementRanges(text: string, filename: string): InsertionOffsetRange[] { try { const ast = parse(text, { sourceType: 'module', - plugins: [ - 'jsx', - 'decorators-legacy', - ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), - ] as any, + plugins: getExpressionParserPlugins(filename), errorRecovery: false, }) as any; - const ranges: OffsetRange[] = []; + const ranges: InsertionOffsetRange[] = []; const visit = (node: any) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { @@ -279,114 +253,35 @@ function getLineIndent(text: string, offset: number): string { return lineText.match(/^[ \t]*/)?.[0] ?? ''; } -function indentFormattedRegion(text: string, baseIndent: string): string { - if (baseIndent.length === 0 || text.length === 0) return text; +function getExpressionParserPlugins(filename: string): any[] { + return [ + 'jsx', + 'decorators-legacy', + ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), + ] as any; +} +function rebaseFormattedRegion( + text: string, + baseIndent: string, + wrapperLineIndentWidth: number, +): string { + if (text.length === 0) return text; const lines = text.split('\n'); if (lines.length === 1) return text; - const firstTrimmed = lines[0].trim(); - const lastTrimmed = lines[lines.length - 1].trim(); - const isStructuredMultilineExpression = ( - (firstTrimmed === '(' && lastTrimmed === ')') - || (firstTrimmed.startsWith('(() => {') && lastTrimmed.startsWith('})()')) - || (firstTrimmed.startsWith('<') && (lastTrimmed.startsWith('' || lastTrimmed === '>')) - ); - - const structuredBodyIndent = isStructuredMultilineExpression - ? Math.min(...lines - .slice(1, -1) - .filter(line => line.trim().length > 0) - .map(line => line.match(/^[ \t]*/)?.[0].length ?? 0)) - : 0; - return lines .map((line, index) => { - if (index === 0) return line; - - if (isStructuredMultilineExpression && index < lines.length - 1) { - return `${baseIndent} ${line.slice(structuredBodyIndent)}`; - } + if (index === 0) return line.trimStart(); + if (line.trim().length === 0) return ''; - if (isStructuredMultilineExpression) { - return `${baseIndent}${line.trimStart()}`; - } - - return `${baseIndent}${line}`; + const indentWidth = line.match(/^[ \t]*/)?.[0].length ?? 0; + const relativeIndent = Math.max(0, indentWidth - wrapperLineIndentWidth); + return `${baseIndent}${' '.repeat(relativeIndent)}${line.trimStart()}`; }) .join('\n'); } -function normalizeTernaryBranchIndent(text: string): string { - const lines = text.split('\n'); - const stack: Array<{ - branchIndent: number; - iifeOpenIndex: number | null; - }> = []; - - const getIndent = (line: string) => line.match(/^[ \t]*/)?.[0].length ?? 0; - - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - const trimmed = line.trim(); - const indent = getIndent(line); - - if (/^[?:]\s*\($/.test(trimmed)) { - stack.push({ branchIndent: indent, iifeOpenIndex: null }); - continue; - } - - const current = stack[stack.length - 1]; - if (!current) continue; - - if (current.iifeOpenIndex == null && (trimmed === ')' || trimmed === ')}')) { - lines[i] = `${' '.repeat(current.branchIndent + 2)}${trimmed}`; - stack.pop(); - continue; - } - - if (trimmed.startsWith('(() => {') || trimmed.startsWith('{(() => {')) { - current.iifeOpenIndex = i; - lines[i] = `${' '.repeat(current.branchIndent + 4)}${trimmed}`; - continue; - } - - if (current.iifeOpenIndex != null && trimmed.startsWith('})()')) { - const bodyLines = lines - .slice(current.iifeOpenIndex + 1, i) - .filter(branchLine => branchLine.trim().length > 0); - const bodyBaseIndent = bodyLines.length > 0 - ? Math.min(...bodyLines.map(getIndent)) - : current.branchIndent + 2; - - for (let bodyIndex = current.iifeOpenIndex + 1; bodyIndex < i; bodyIndex += 1) { - const bodyLine = lines[bodyIndex]; - const bodyTrimmed = bodyLine.trim(); - if (bodyTrimmed.length === 0) continue; - - const relativeIndent = Math.max(0, getIndent(bodyLine) - bodyBaseIndent); - lines[bodyIndex] = `${' '.repeat(current.branchIndent + 6 + relativeIndent)}${bodyTrimmed}`; - } - - lines[i] = `${' '.repeat(current.branchIndent + 4)}${trimmed}`; - current.iifeOpenIndex = null; - continue; - } - - if (current.iifeOpenIndex == null && trimmed.length > 0) { - const expectedIndent = /^[<>{]/.test(trimmed) - ? current.branchIndent + 2 - : current.branchIndent + 4; - - if (indent < expectedIndent) { - lines[i] = `${' '.repeat(expectedIndent)}${trimmed}`; - } - } - } - - return lines.join('\n'); -} - function normalizeJsxClosingBracketIndent(text: string): string { const lines = text.split('\n'); const stack: number[] = []; @@ -412,177 +307,10 @@ function normalizeJsxClosingBracketIndent(text: string): string { return lines.join('\n'); } -function parseExpressionTokens(expr: string, filename: string) { - const wrapped = `${FORMAT_WRAPPER_PREFIX}${expr}\n`; - const ast = parse(wrapped, { - sourceType: 'module', - plugins: [ - 'jsx', - 'decorators-legacy', - ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), - ] as any, - errorRecovery: false, - tokens: true, - }) as any; - - const prefixLength = FORMAT_WRAPPER_PREFIX.length; - const endLimit = wrapped.length - 1; - const tokens = (ast.tokens ?? []) - .filter((token: any) => { - if (token.start < prefixLength || token.end > endLimit) return false; - const rawText = wrapped.slice(token.start, token.end); - if (token.type?.label === 'jsxText' && rawText.trim().length === 0) return false; - return true; - }) - .map((token: any) => ({ - start: token.start - prefixLength, - end: token.end - prefixLength, - label: token.type?.label ?? token.type, - value: token.value, - raw: wrapped.slice(token.start, token.end), - })); - - return tokens; -} - -function tokenAlignmentKey(token: { - label: string; - value?: unknown; - raw: string; -}): string { - switch (token.label) { - case 'name': - case 'jsxName': - case 'privateName': - return `${token.label}:${token.raw}`; - case 'string': - return `${token.label}:${String(token.value ?? token.raw)}`; - case 'num': - case 'bigint': - case 'decimal': - case 'regexp': - return `${token.label}:${token.raw}`; - case 'jsxText': - return `${token.label}:${token.raw.trim()}`; - default: - return token.label; - } -} - -function alignExpressionTokens( - originalTokens: Array<{ - start: number; - end: number; - label: string; - value?: unknown; - raw: string; - }>, - formattedTokens: Array<{ - start: number; - end: number; - label: string; - value?: unknown; - raw: string; - }>, -): Array<[number, number]> { - const originalKeys = originalTokens.map(tokenAlignmentKey); - const formattedKeys = formattedTokens.map(tokenAlignmentKey); - const dp = Array.from({ length: originalKeys.length + 1 }, () => ( - new Array(formattedKeys.length + 1).fill(0) - )); - - for (let i = originalKeys.length - 1; i >= 0; i -= 1) { - for (let j = formattedKeys.length - 1; j >= 0; j -= 1) { - if (originalKeys[i] === formattedKeys[j]) { - dp[i][j] = dp[i + 1][j + 1] + 1; - } else { - dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); - } - } - } - - const matches: Array<[number, number]> = []; - let i = 0; - let j = 0; - while (i < originalKeys.length && j < formattedKeys.length) { - if (originalKeys[i] === formattedKeys[j]) { - matches.push([i, j]); - i += 1; - j += 1; - continue; - } - if (dp[i + 1][j] >= dp[i][j + 1]) { - i += 1; - } else { - j += 1; - } - } - - return matches; -} - -function buildBoundaryMap( - originalExpr: string, - formattedExpr: string, - filename: string, -): number[] { - try { - const originalTokens = parseExpressionTokens(originalExpr, filename); - const formattedTokens = parseExpressionTokens(formattedExpr, filename); - const matchedTokens = alignExpressionTokens(originalTokens, formattedTokens); - if (matchedTokens.length === 0) throw new Error('token-alignment-empty'); - - const anchors = [{ formatted: 0, original: 0 }]; - for (const [originalIndex, formattedIndex] of matchedTokens) { - const original = originalTokens[originalIndex]; - const formatted = formattedTokens[formattedIndex]; - anchors.push({ formatted: formatted.start, original: original.start }); - anchors.push({ formatted: formatted.end, original: original.end }); - } - anchors.push({ formatted: formattedExpr.length, original: originalExpr.length }); - - anchors.sort((a, b) => a.formatted - b.formatted || a.original - b.original); - - const deduped: Array<{ formatted: number; original: number }> = []; - for (const anchor of anchors) { - const last = deduped[deduped.length - 1]; - if (!last || last.formatted !== anchor.formatted || last.original !== anchor.original) { - deduped.push(anchor); - } - } - - const boundaryMap = new Array(formattedExpr.length + 1); - for (let i = 0; i < deduped.length - 1; i += 1) { - const current = deduped[i]; - const next = deduped[i + 1]; - const formattedSpan = next.formatted - current.formatted; - const originalSpan = next.original - current.original; - - if (formattedSpan <= 0) continue; - - for (let offset = current.formatted; offset < next.formatted; offset += 1) { - const relative = offset - current.formatted; - boundaryMap[offset] = current.original + Math.round(relative * originalSpan / formattedSpan); - } - } - - boundaryMap[formattedExpr.length] = originalExpr.length; - for (let i = 0; i < boundaryMap.length; i += 1) { - if (boundaryMap[i] == null) { - boundaryMap[i] = i === 0 ? 0 : boundaryMap[i - 1]; - } - } - return boundaryMap; - } catch { - return Array.from({ length: formattedExpr.length + 1 }, (_, index) => ( - Math.min(originalExpr.length, Math.round(index * originalExpr.length / Math.max(1, formattedExpr.length))) - )); - } -} - function formatPugRegionForLint( expr: string, baseIndent: string, + formattingContext: RegionFormattingContext, filename: string, ): { code: string; boundaryMap: number[] } { const lintConfig: any[] = [{ @@ -597,8 +325,8 @@ function formatPugRegionForLint( } : {}), }]; - const wrapped = `${FORMAT_WRAPPER_PREFIX}${expr}\n`; - const prettyWrapped = prettier.format(wrapped, { + const wrapper = createFormattingWrapper(expr, formattingContext.containerKind); + const prettyWrapped = prettier.format(wrapper, { parser: isTypeScriptLikeFilename(filename) ? 'babel-ts' : 'babel', semi: false, singleQuote: true, @@ -608,115 +336,55 @@ function formatPugRegionForLint( }); const fixedWrapped = formatLinter.verifyAndFix(prettyWrapped, lintConfig, getFormatterLintFilename(filename)).output; - let body = fixedWrapped.slice(FORMAT_WRAPPER_PREFIX.length); - if (body.endsWith('\n')) body = body.slice(0, -1); - body = normalizeTernaryBranchIndent(body); - body = normalizeJsxClosingBracketIndent(body); - const normalizedWrapped = `${FORMAT_WRAPPER_PREFIX}${body}\n`; + const normalizedWrapped = normalizeJsxClosingBracketIndent(fixedWrapped); const refixedWrapped = formatLinter.verifyAndFix( normalizedWrapped, lintConfig, getFormatterLintFilename(filename), ).output; - body = refixedWrapped.slice(FORMAT_WRAPPER_PREFIX.length); - if (body.endsWith('\n')) body = body.slice(0, -1); - body = indentFormattedRegion(body, baseIndent); + const finalWrapped = formatLinter.verifyAndFix( + normalizeJsxClosingBracketIndent(refixedWrapped), + lintConfig, + getFormatterLintFilename(filename), + ).output; + + const extracted = extractFormattedExpressionFromWrapper(finalWrapped, formattingContext.containerKind, filename); + const body = rebaseFormattedRegion( + extracted?.code ?? expr, + baseIndent, + extracted?.wrapperLineIndentWidth ?? 0, + ); return { code: body, - boundaryMap: buildBoundaryMap(expr, body, filename), + boundaryMap: buildExpressionBoundaryMap(expr, body, filename), }; } -function formatLintCode(transformed: SourceTransformState, filename: string): FormattedLintCode | null { - const pugRegions = transformed.document.mappedRegions - .filter(region => region.kind === 'pug') - .sort((a, b) => a.shadowStart - b.shadowStart); - - if (pugRegions.length === 0) return null; - - let code = ''; - let cursor = 0; - const copySegments: FormattedCopySegment[] = []; - const regionSegments: FormattedRegionSegment[] = []; - - for (const region of pugRegions) { - if (cursor < region.shadowStart) { - const formattedStart = code.length; - const copied = transformed.code.slice(cursor, region.shadowStart); - code += copied; - copySegments.push({ - formattedStart, - formattedEnd: code.length, - transformedStart: cursor, - transformedEnd: region.shadowStart, - }); - } +function formatLintCode(transformed: LintTransformState, filename: string): RewrittenPugRegionsResult | null { + if (transformed.regionSegments.length === 0) return null; - const formattedStart = code.length; - const baseIndent = getLineIndent(transformed.code, region.shadowStart); - const formattedRegion = formatPugRegionForLint( - transformed.code.slice(region.shadowStart, region.shadowEnd), + return rewriteSegmentedPugRegions(transformed, filename, (expr, region, currentFilename) => { + const baseIndent = getLineIndent(transformed.code, region.rewrittenStart); + return formatPugRegionForLint( + expr, baseIndent, - filename, + region.formattingContext, + currentFilename, ); - code += formattedRegion.code; - regionSegments.push({ - formattedStart, - formattedEnd: code.length, - transformedStart: region.shadowStart, - transformedEnd: region.shadowEnd, - boundaryMap: formattedRegion.boundaryMap, - }); - cursor = region.shadowEnd; - } - - if (cursor < transformed.code.length) { - const formattedStart = code.length; - code += transformed.code.slice(cursor); - copySegments.push({ - formattedStart, - formattedEnd: code.length, - transformedStart: cursor, - transformedEnd: transformed.code.length, - }); - } - - return { code, copySegments, regionSegments }; -} - -function mapFormattedOffsetToTransformed( - formatted: FormattedLintCode, - formattedOffset: number, -): number | null { - const clamped = Math.max(0, Math.min(formattedOffset, formatted.code.length)); - - for (const region of formatted.regionSegments) { - if (clamped < region.formattedStart || clamped > region.formattedEnd) continue; - const localOffset = clamped - region.formattedStart; - const mappedLocal = region.boundaryMap[Math.min(localOffset, region.boundaryMap.length - 1)] ?? 0; - return region.transformedStart + mappedLocal; - } - - for (const segment of formatted.copySegments) { - if (clamped < segment.formattedStart || clamped > segment.formattedEnd) continue; - return segment.transformedStart + (clamped - segment.formattedStart); - } - - return null; + }); } function intersectsTransformedPugRegion( - transformed: SourceTransformState | null, + transformed: LintTransformState | null, generatedStart: number, generatedEnd: number, ): boolean { if (!transformed) return false; const end = Math.max(generatedStart, generatedEnd); - return transformed.document.mappedRegions.some(region => ( - region.kind === 'pug' - && generatedStart < region.shadowEnd - && end > region.shadowStart + return transformed.regionSegments.some(region => ( + generatedStart < region.rewrittenEnd + && end > region.rewrittenStart )); } @@ -728,10 +396,10 @@ function mapLintFix( if (!cached.transformed) return undefined; const generatedStart = cached.formatted - ? mapFormattedOffsetToTransformed(cached.formatted, fix.range[0]) + ? cached.formatted.mapRewrittenOffsetToBase(fix.range[0]) : fix.range[0]; const generatedEnd = cached.formatted - ? mapFormattedOffsetToTransformed(cached.formatted, fix.range[1]) + ? cached.formatted.mapRewrittenOffsetToBase(fix.range[1]) : fix.range[1]; if (generatedStart == null || generatedEnd == null) return undefined; @@ -739,10 +407,14 @@ function mapLintFix( return undefined; } + const baseStart = cached.transformed.mapRewrittenOffsetToBase(generatedStart); + const baseEnd = cached.transformed.mapRewrittenOffsetToBase(generatedEnd); + if (baseStart == null || baseEnd == null) return undefined; + const mapped = mapGeneratedRangeToOriginal( - cached.transformed.document, - generatedStart, - Math.max(0, generatedEnd - generatedStart), + cached.transformed.baseTransform.document, + baseStart, + Math.max(0, baseEnd - baseStart), ); if (!mapped) return undefined; @@ -752,7 +424,7 @@ function mapLintFix( }; } -function overlapsRangeList(ranges: OffsetRange[], start: number, end: number): boolean { +function overlapsRangeList(ranges: InsertionOffsetRange[], start: number, end: number): boolean { return ranges.some(range => start < range.end && end > range.start); } @@ -773,26 +445,13 @@ function shouldSuppressOriginalRangeMessage( return false; } -function isSyntheticStyleCallRange( - cached: CachedLintState, - generatedStart: number, - generatedEnd: number, -): boolean { - if (!cached.transformed) return false; - return cached.transformed.document.insertions.some(insertion => ( - insertion.kind === 'style-call' - && generatedStart >= insertion.shadowStart - && generatedEnd <= insertion.shadowEnd - )); -} - function shouldSuppressGeneratedRangeMessage( cached: CachedLintState, message: EslintLintMessage, generatedStart: number, generatedEnd: number, ): boolean { - if (isSyntheticStyleCallRange(cached, generatedStart, generatedEnd)) { + if (overlapsRangeList(cached.syntheticStyleCallRanges, generatedStart, generatedEnd)) { return true; } return false; @@ -816,8 +475,7 @@ function mapLintMessage( if (message.line == null || message.column == null) return message; const generatedStart = cached.formatted - ? mapFormattedOffsetToTransformed( - cached.formatted, + ? cached.formatted.mapRewrittenOffsetToBase( lineColumnToOffset(cached.formatted.code, message.line, message.column), ) : lineColumnToOffset(cached.transformed.code, message.line, message.column); @@ -826,8 +484,7 @@ function mapLintMessage( const generatedEnd = (message.endLine != null && message.endColumn != null) ? ( cached.formatted - ? mapFormattedOffsetToTransformed( - cached.formatted, + ? cached.formatted.mapRewrittenOffsetToBase( lineColumnToOffset(cached.formatted.code, message.endLine, message.endColumn), ) : lineColumnToOffset(cached.transformed.code, message.endLine, message.endColumn) @@ -839,10 +496,14 @@ function mapLintMessage( return null; } + const baseStart = cached.transformed.mapRewrittenOffsetToBase(generatedStart); + const baseEnd = cached.transformed.mapRewrittenOffsetToBase(generatedEnd); + if (baseStart == null || baseEnd == null) return message; + const mapped = mapGeneratedRangeToOriginal( - cached.transformed.document, - generatedStart, - Math.max(1, generatedEnd - generatedStart), + cached.transformed.baseTransform.document, + baseStart, + Math.max(1, baseEnd - baseStart), ); if (!mapped) return message; @@ -852,7 +513,7 @@ function mapLintMessage( const startLc = offsetToLineColumn(cached.originalText, mapped.start); const endLc = offsetToLineColumn(cached.originalText, mapped.end); - const hasTransformedPug = cached.transformed.regions.length > 0; + const hasTransformedPug = cached.transformed.baseTransform.regions.length > 0; const mappedFix = hasTransformedPug ? undefined : mapLintFix(message.fix, cached); const mappedSuggestions = hasTransformedPug ? undefined @@ -895,15 +556,16 @@ function createReactPugProcessor( shouldAlwaysVirtualizeJs || containsJsxSyntax(text, filename) ); - if (legacyStyleStatementRanges.length > 0) { - cache.set(filename, { - originalText: text, - transformed: null, - formatted: null, - legacyStyleStatementRanges, - }); - } else { - cache.delete(filename); + if (legacyStyleStatementRanges.length > 0) { + cache.set(filename, { + originalText: text, + transformed: null, + formatted: null, + legacyStyleStatementRanges, + syntheticStyleCallRanges: [], + }); + } else { + cache.delete(filename); } if (!shouldUseVirtualJsxFilename) return [text]; return [{ @@ -912,16 +574,15 @@ function createReactPugProcessor( }]; } - const transformed = transformSourceFile(text, filename, { + const transformed = createLintTransform(text, filename, { tagFunction: configuredTagFunction, - compileMode: 'runtime', requirePugImport: options.requirePugImport ?? false, classAttribute: options.classShorthandProperty ?? 'auto', classMerge: options.classShorthandMerge ?? 'auto', startupjsCssxjs: options.startupjsCssxjs ?? 'auto', componentPathFromUppercaseClassShorthand: options.componentPathFromUppercaseClassShorthand ?? true, }); - const hasTransformedPug = transformed.regions.length > 0; + const hasTransformedPug = transformed.baseTransform.regions.length > 0; const jsLikeFilename = isJavaScriptLikeFilename(filename); const shouldAlwaysVirtualizeJs = ( options.jsxInJsFiles === 'always' @@ -939,6 +600,7 @@ function createReactPugProcessor( transformed, formatted, legacyStyleStatementRanges, + syntheticStyleCallRanges: collectMappedInsertionRangesByKind(transformed, 'style-call'), }); if (!shouldUseVirtualJsxFilename) return [transformed.code]; return [{ 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 cd43aa5..4a848d3 100644 --- a/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/autofix.test.ts @@ -9,6 +9,14 @@ import reactPugPlugin from '../../src/index' const repoRoot = resolve(__dirname, '../../../..') const fixtureRoot = resolve(repoRoot, 'test/fixtures/example-unformatted') const snapshotRoot = resolve(fixtureRoot, 'snapshots/fixed') +const reactHooksStubPlugin = { + rules: { + 'rules-of-hooks': { + meta: { schema: [] }, + create: () => ({}), + }, + }, +} const tempDirs: string[] = [] @@ -19,11 +27,17 @@ function createExampleEslint(cwd: string, fix: boolean): ESLint { ignore: false, overrideConfigFile: true, overrideConfig: [ + { + linterOptions: { + reportUnusedDisableDirectives: 'off', + }, + }, ...neostandard({ ts: true, }), { plugins: { + 'react-hooks': reactHooksStubPlugin as any, 'react-pug': reactPugPlugin as any, }, processor: 'react-pug/react-pug', @@ -59,15 +73,38 @@ afterEach(() => { }) describe('eslint --fix integration for react-pug processor', () => { - it('does not corrupt files and produces lint-clean output for an unformatted example fixture', async () => { + it('does not corrupt files and preserves only the expected non-fixable diagnostics for an unformatted example fixture', async () => { const tempDir = createTempFixtureCopy() const firstPass = await createExampleEslint(tempDir, true).lintFiles(['src/**/*.{js,jsx,ts,tsx}']) await ESLint.outputFixes(firstPass) const secondPass = await createExampleEslint(tempDir, false).lintFiles(['src/**/*.{js,jsx,ts,tsx}']) - const allMessages = secondPass.flatMap(result => result.messages) - expect(allMessages).toEqual([]) + const allMessages = secondPass.flatMap(result => ( + result.messages.map(message => ({ + filePath: result.filePath.replace(/.*\/src\//, 'src/'), + ruleId: message.ruleId, + line: message.line, + column: message.column, + 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`', + }, + ]) const fixedFiles = [ 'src/App.tsx', @@ -75,7 +112,13 @@ describe('eslint --fix integration for react-pug processor', () => { 'src/Card.tsx', 'src/ModalScreen.tsx', 'src/RootLayout.tsx', + 'src/StartupjsUiDialogsReadme.js', + 'src/StartupjsUiDraggableReadme.js', 'src/StartupjsLogin.js', + 'src/StartupjsUiMdxComponents.js', + 'src/StartupjsUiPrompt.tsx', + 'src/StartupjsUiTypeCell.js', + 'src/StartupjsUiWrapInput.tsx', 'src/StartupjsTabThree.js', 'src/TypeScriptErrorsInPug.tsx', 'src/TypeScriptInPug.tsx', diff --git a/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts b/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts index c2e8f6d..61bd369 100644 --- a/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts +++ b/packages/eslint-plugin-react-pug/test/integration/fixture-diagnostics.test.ts @@ -7,6 +7,14 @@ import reactPugPlugin from '../../src/index' const repoRoot = resolve(__dirname, '../../../..') const fixtureRoot = resolve(repoRoot, 'test/fixtures/example-unformatted') const diagnosticsSnapshotRoot = resolve(fixtureRoot, 'snapshots/diagnostics') +const reactHooksStubPlugin = { + rules: { + 'rules-of-hooks': { + meta: { schema: [] }, + create: () => ({}), + }, + }, +} function createExampleEslint(): ESLint { return new ESLint({ @@ -15,11 +23,17 @@ function createExampleEslint(): ESLint { ignore: false, overrideConfigFile: true, overrideConfig: [ + { + linterOptions: { + reportUnusedDisableDirectives: 'off', + }, + }, ...neostandard({ ts: true, }), { plugins: { + 'react-hooks': reactHooksStubPlugin as any, 'react-pug': reactPugPlugin as any, }, processor: 'react-pug/react-pug', @@ -60,5 +74,23 @@ describe('eslint diagnostics for example-unformatted fixture', () => { const startupjsLogin = results.find(result => result.filePath.endsWith('/src/StartupjsLogin.js')) expect(startupjsLogin?.messages.some(message => message.ruleId === '@stylistic/jsx-indent')).toBe(false) + + const startupjsUiDialogsReadme = results.find(result => result.filePath.endsWith('/src/StartupjsUiDialogsReadme.js')) + expect(startupjsUiDialogsReadme?.messages.some(message => message.ruleId === 'react/jsx-fragments')).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 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', + ]) }, 30000) }) diff --git a/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts b/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts new file mode 100644 index 0000000..a4ee7a9 --- /dev/null +++ b/packages/eslint-plugin-react-pug/test/integration/startupjs-ui-regressions.test.ts @@ -0,0 +1,126 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { ESLint } from 'eslint' +import neostandard from 'neostandard' +import reactPlugin from 'eslint-plugin-react' +import reactPugPlugin from '../../src/index' + +const repoRoot = resolve(__dirname, '../../../..') +const fixtureRoot = resolve(repoRoot, 'test/fixtures/example-unformatted/src') +const reactHooksStubPlugin = { + rules: { + 'rules-of-hooks': { + meta: { schema: [] }, + create: () => ({}), + }, + }, +} + +function createStartupjsUiStyleEslint(fix: boolean): ESLint { + return new ESLint({ + cwd: repoRoot, + fix, + ignore: false, + overrideConfigFile: true, + overrideConfig: [ + { + linterOptions: { + reportUnusedDisableDirectives: 'off', + }, + }, + ...neostandard({ + ts: true, + }), + { + plugins: { + react: reactPlugin as any, + 'react-hooks': reactHooksStubPlugin as any, + 'react-pug': reactPugPlugin as any, + }, + rules: { + 'react/jsx-boolean-value': 'error', + }, + processor: 'react-pug/react-pug', + }, + ] as any, + }) +} + +describe('startupjs-ui regressions', () => { + it('does not report false processor diagnostics for startupjs-ui repros', async () => { + const files = [ + resolve(fixtureRoot, 'StartupjsUiDialogsReadme.js'), + resolve(fixtureRoot, 'StartupjsUiDraggableReadme.js'), + resolve(fixtureRoot, 'StartupjsUiTypeCell.js'), + resolve(fixtureRoot, 'StartupjsUiWrapInput.tsx'), + resolve(fixtureRoot, 'StartupjsUiMdxComponents.js'), + resolve(fixtureRoot, 'StartupjsUiPrompt.tsx'), + ] + + const results = await createStartupjsUiStyleEslint(false).lintFiles(files) + const messages = results.flatMap(result => ( + result.messages.map(message => ({ + filePath: result.filePath, + ruleId: message.ruleId, + line: message.line, + column: message.column, + message: message.message, + })) + )) + + expect(messages).toEqual([ + { + column: 34, + filePath: resolve(fixtureRoot, 'StartupjsUiMdxComponents.js'), + line: 205, + message: 'Value must be omitted for boolean attribute `value`', + ruleId: 'react/jsx-boolean-value', + }, + { + column: 27, + filePath: resolve(fixtureRoot, 'StartupjsUiMdxComponents.js'), + line: 257, + message: 'Value must be omitted for boolean attribute `value`', + ruleId: 'react/jsx-boolean-value', + }, + ]) + }) + + it('still reports jsx-boolean-value for intrinsic boolean attrs inside pug', async () => { + const filePath = resolve(repoRoot, 'intrinsic-boolean-pug.js') + const input = [ + "import { pug } from 'startupjs'", + '', + 'export default function Demo () {', + ' return pug`', + ' button(disabled=true) Click', + ' `', + '}', + '', + ].join('\n') + + const [result] = await createStartupjsUiStyleEslint(false).lintText(input, { filePath }) + const booleanDiagnostic = result.messages.find(message => message.ruleId === 'react/jsx-boolean-value') + + expect(booleanDiagnostic).toBeTruthy() + expect(booleanDiagnostic?.line).toBe(5) + expect(booleanDiagnostic?.column).toBe(12) + }) + + it('does not rewrite startupjs-ui repros under eslint --fix', async () => { + for (const relativePath of [ + 'StartupjsUiDialogsReadme.js', + 'StartupjsUiDraggableReadme.js', + 'StartupjsUiMdxComponents.js', + 'StartupjsUiPrompt.tsx', + 'StartupjsUiTypeCell.js', + 'StartupjsUiWrapInput.tsx', + ]) { + const filePath = resolve(fixtureRoot, relativePath) + const input = readFileSync(filePath, 'utf8') + const [result] = await createStartupjsUiStyleEslint(true).lintText(input, { filePath }) + expect(result.output ?? input).toBe(input) + } + }) +}) diff --git a/packages/eslint-plugin-react-pug/test/unit/processor.test.ts b/packages/eslint-plugin-react-pug/test/unit/processor.test.ts index 50a2493..2ce5fec 100644 --- a/packages/eslint-plugin-react-pug/test/unit/processor.test.ts +++ b/packages/eslint-plugin-react-pug/test/unit/processor.test.ts @@ -253,6 +253,60 @@ describe('eslint-plugin-react-pug processor', () => { `); }); + it('formats multiline pug used as an object-property value without stylistic indent drift', () => { + const processor = createReactPugProcessor(); + const input = [ + 'const config = {', + ' children: pug`', + " - const label = 'Child'", + ' Span= label', + ' `', + '}', + ].join('\n'); + + const [block] = processor.preprocess(input, 'file.jsx'); + const code = typeof block === 'string' ? block : block.text; + expect(code).toMatchInlineSnapshot(` + "const config = { + children: (() => { + const label = 'Child' + return {label} + })() + }" + `); + + const lintMessages = lintStylisticIndent(code, 'file.jsx'); + const mapped = processor.postprocess([lintMessages as any], 'file.jsx'); + expect(mapped.filter(message => String(message.ruleId).includes('indent'))).toEqual([]); + }); + + it('formats multiline pug used in a ternary branch without stylistic indent drift', () => { + const processor = createReactPugProcessor(); + const input = [ + 'const view = ready', + ' ? pug`', + " - const label = 'Yes'", + ' Span= label', + ' `', + ' : null', + ].join('\n'); + + const [block] = processor.preprocess(input, 'file.jsx'); + const code = typeof block === 'string' ? block : block.text; + expect(code).toMatchInlineSnapshot(` + "const view = ready + ? (() => { + const label = 'Yes' + return {label} + })() + : null" + `); + + const lintMessages = lintStylisticIndent(code, 'file.jsx'); + const mapped = processor.postprocess([lintMessages as any], 'file.jsx'); + expect(mapped.filter(message => String(message.ruleId).includes('indent'))).toEqual([]); + }); + it('suppresses legacy styl warnings without hiding normal linting', () => { const processor = createReactPugProcessor(); const input = [ diff --git a/packages/react-pug-core/package.json b/packages/react-pug-core/package.json index fb9655e..75c34d8 100644 --- a/packages/react-pug-core/package.json +++ b/packages/react-pug-core/package.json @@ -11,7 +11,9 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@babel/generator": "^7.0.0", "@babel/parser": "^7.0.0", + "@babel/types": "^7.0.0", "@jridgewell/gen-mapping": "^0.3.13", "@react-pug/pug-lexer": "^0.1.7", "@volar/source-map": "^2.4.28", diff --git a/packages/react-pug-core/src/index.ts b/packages/react-pug-core/src/index.ts index 8156a38..bd1a658 100644 --- a/packages/react-pug-core/src/index.ts +++ b/packages/react-pug-core/src/index.ts @@ -3,6 +3,10 @@ export * from './language/extractRegions'; export * from './language/pugToTsx'; export * from './language/shadowDocument'; export * from './language/positionMapping'; +export * from './language/regionOffsetMapping'; +export * from './language/queryMapping'; export * from './language/sourceTransform'; +export * from './language/lintTransform'; export * from './language/diagnosticMapping'; +export * from './language/documentIssues'; export * from './language/tagFunctionPresence'; diff --git a/packages/react-pug-core/src/language/documentIssues.ts b/packages/react-pug-core/src/language/documentIssues.ts new file mode 100644 index 0000000..e8efc46 --- /dev/null +++ b/packages/react-pug-core/src/language/documentIssues.ts @@ -0,0 +1,97 @@ +import type { PugDocument, PugRegion, PugTransformError } from './mapping'; +import { regionStrippedOffsetToOriginalOffset } from './regionOffsetMapping'; + +export type PugDocumentIssueKind = 'missing-tag-import' | 'parse-error' | 'transform-error'; + +export interface PugDocumentIssue { + kind: PugDocumentIssueKind; + start: number; + length: number; + message: string; + transformCode?: PugTransformError['code']; +} + +function clampDocumentOffset(text: string, offset: number): number { + if (offset <= 0) return 0; + if (offset >= text.length) return text.length; + return offset; +} + +function nextLineErrorAnchor(text: string, offset: number): number { + let anchor = clampDocumentOffset(text, offset); + let remaining = text.slice(anchor); + + if (!remaining.startsWith('\n')) return anchor; + + const nextLineStart = remaining.indexOf('\n') + 1; + if (nextLineStart <= 0) return anchor; + + const nextLineText = remaining.slice(nextLineStart); + const indentLength = nextLineText.match(/^\s*/)?.[0].length ?? 0; + anchor += nextLineStart + indentLength; + + return clampDocumentOffset(text, anchor); +} + +function lengthToLineEnd(text: string, offset: number, maxLength: number = 20): number { + const safeOffset = clampDocumentOffset(text, offset); + const rest = text.slice(safeOffset); + const newlineIndex = rest.indexOf('\n'); + return Math.max(1, newlineIndex >= 0 ? newlineIndex : Math.min(rest.length, maxLength)); +} + +function createParseErrorIssue(doc: PugDocument, region: PugRegion): PugDocumentIssue | null { + const error = region.parseError; + if (!error) return null; + + const originalStart = regionStrippedOffsetToOriginalOffset(doc, region, error.offset); + const anchoredStart = nextLineErrorAnchor(doc.originalText, originalStart); + + return { + kind: 'parse-error', + start: anchoredStart, + length: lengthToLineEnd(doc.originalText, anchoredStart), + message: error.message, + }; +} + +function createTransformErrorIssue(doc: PugDocument, region: PugRegion): PugDocumentIssue | null { + const error = region.transformError; + if (!error) return null; + + const start = regionStrippedOffsetToOriginalOffset(doc, region, error.offset); + const length = error.code === 'style-tag-must-be-last' + ? 'style'.length + : lengthToLineEnd(doc.originalText, start); + + return { + kind: 'transform-error', + start, + length, + message: error.message, + transformCode: error.code, + }; +} + +export function collectPugDocumentIssues(doc: PugDocument): PugDocumentIssue[] { + const issues: PugDocumentIssue[] = []; + + if (doc.missingTagImport) { + issues.push({ + kind: 'missing-tag-import', + start: doc.missingTagImport.start, + length: doc.missingTagImport.length, + message: doc.missingTagImport.message, + }); + } + + for (const region of doc.regions) { + const parseIssue = createParseErrorIssue(doc, region); + if (parseIssue) issues.push(parseIssue); + + const transformIssue = createTransformErrorIssue(doc, region); + if (transformIssue) issues.push(transformIssue); + } + + return issues; +} diff --git a/packages/react-pug-core/src/language/lintTransform.ts b/packages/react-pug-core/src/language/lintTransform.ts new file mode 100644 index 0000000..27697d7 --- /dev/null +++ b/packages/react-pug-core/src/language/lintTransform.ts @@ -0,0 +1,922 @@ +import generate from '@babel/generator'; +import { parse, parseExpression } from '@babel/parser'; +import * as t from '@babel/types'; +import type { ShadowInsertion, ShadowMappedRegion } from './mapping'; +import type { SourceTransformOptions, SourceTransformResult } from './sourceTransform'; +import { transformSourceFile } from './sourceTransform'; + +export interface RewrittenCopySegment { + rewrittenStart: number; + rewrittenEnd: number; + baseStart: number; + baseEnd: number; +} + +export interface RewrittenRegionSegment { + rewrittenStart: number; + rewrittenEnd: number; + baseStart: number; + baseEnd: number; + boundaryMap: number[]; + region: ShadowMappedRegion; + formattingContext: RegionFormattingContext; +} + +export interface RewrittenPugRegionsResult { + code: string; + copySegments: RewrittenCopySegment[]; + regionSegments: RewrittenRegionSegment[]; + mapRewrittenOffsetToBase: (offset: number) => number | null; + mapBaseOffsetToRewritten: (offset: number) => number | null; +} + +export interface SegmentedPugRegionsInput { + code: string; + copySegments: RewrittenCopySegment[]; + regionSegments: RewrittenRegionSegment[]; +} + +export interface BoundaryMappedExpression { + code: string; + boundaryMap: number[]; +} + +export interface LintTransformResult extends RewrittenPugRegionsResult { + baseTransform: SourceTransformResult; + mapGeneratedOffsetToOriginal: (offset: number) => number | null; + mapBaseOffsetToOriginal: (offset: number) => number | null; +} + +export type RegionContainerKind = + | 'standalone' + | 'return-value' + | 'variable-init' + | 'assignment-value' + | 'object-property-value' + | 'call-argument' + | 'arrow-body' + | 'logical-operand' + | 'conditional-branch' + | 'other-expression'; + +export interface RegionFormattingContext { + containerKind: RegionContainerKind; +} + +export interface FormattingWrapperExtraction { + code: string; + wrapperLineIndentWidth: number; +} + +export interface InsertionOffsetRange { + start: number; + end: number; +} + +interface ExpressionToken { + start: number; + end: number; + label: string; + value?: unknown; + raw: string; +} + +const FORMAT_WRAPPER_PREFIX = 'const __pug = '; + +function isTypeScriptLikeFilename(filename: string): boolean { + return /\.(?:ts|tsx|mts|cts)$/i.test(filename); +} + +function getExpressionParserPlugins(filename: string): any[] { + return [ + 'jsx', + 'decorators-legacy', + ...(isTypeScriptLikeFilename(filename) ? ['typescript'] : []), + ] as any; +} + +function parseExpressionTokens(expr: string, filename: string): ExpressionToken[] { + const wrapped = `${FORMAT_WRAPPER_PREFIX}${expr}\n`; + const ast = parse(wrapped, { + sourceType: 'module', + plugins: getExpressionParserPlugins(filename), + errorRecovery: false, + tokens: true, + }) as any; + + const prefixLength = FORMAT_WRAPPER_PREFIX.length; + const endLimit = wrapped.length - 1; + return (ast.tokens ?? []) + .filter((token: any) => { + if (token.start < prefixLength || token.end > endLimit) return false; + const rawText = wrapped.slice(token.start, token.end); + if (token.type?.label === 'jsxText' && rawText.trim().length === 0) return false; + return true; + }) + .map((token: any) => ({ + start: token.start - prefixLength, + end: token.end - prefixLength, + label: token.type?.label ?? token.type, + value: token.value, + raw: wrapped.slice(token.start, token.end), + })); +} + +function tokenAlignmentKey(token: Pick): string { + switch (token.label) { + case 'name': + case 'jsxName': + case 'privateName': + return `${token.label}:${token.raw}`; + case 'string': + return `${token.label}:${String(token.value ?? token.raw)}`; + case 'num': + case 'bigint': + case 'decimal': + case 'regexp': + return `${token.label}:${token.raw}`; + case 'jsxText': + return `${token.label}:${token.raw.trim()}`; + default: + return token.label; + } +} + +function alignExpressionTokens(originalTokens: ExpressionToken[], rewrittenTokens: ExpressionToken[]): Array<[number, number]> { + const originalKeys = originalTokens.map(tokenAlignmentKey); + const rewrittenKeys = rewrittenTokens.map(tokenAlignmentKey); + const dp = Array.from({ length: originalKeys.length + 1 }, () => new Array(rewrittenKeys.length + 1).fill(0)); + + for (let i = originalKeys.length - 1; i >= 0; i -= 1) { + for (let j = rewrittenKeys.length - 1; j >= 0; j -= 1) { + if (originalKeys[i] === rewrittenKeys[j]) dp[i][j] = dp[i + 1][j + 1] + 1; + else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + const matches: Array<[number, number]> = []; + let i = 0; + let j = 0; + while (i < originalKeys.length && j < rewrittenKeys.length) { + if (originalKeys[i] === rewrittenKeys[j]) { + matches.push([i, j]); + i += 1; + j += 1; + continue; + } + if (dp[i + 1][j] >= dp[i][j + 1]) i += 1; + else j += 1; + } + + return matches; +} + +export function buildExpressionBoundaryMap(originalExpr: string, rewrittenExpr: string, filename: string): number[] { + try { + const originalTokens = parseExpressionTokens(originalExpr, filename); + const rewrittenTokens = parseExpressionTokens(rewrittenExpr, filename); + const matchedTokens = alignExpressionTokens(originalTokens, rewrittenTokens); + if (matchedTokens.length === 0) throw new Error('token-alignment-empty'); + + const anchors = [{ rewritten: 0, original: 0 }]; + for (const [originalIndex, rewrittenIndex] of matchedTokens) { + const original = originalTokens[originalIndex]; + const rewritten = rewrittenTokens[rewrittenIndex]; + anchors.push({ rewritten: rewritten.start, original: original.start }); + anchors.push({ rewritten: rewritten.end, original: original.end }); + } + anchors.push({ rewritten: rewrittenExpr.length, original: originalExpr.length }); + anchors.sort((a, b) => a.rewritten - b.rewritten || a.original - b.original); + + const deduped: Array<{ rewritten: number; original: number }> = []; + for (const anchor of anchors) { + const last = deduped[deduped.length - 1]; + if (!last || last.rewritten !== anchor.rewritten || last.original !== anchor.original) deduped.push(anchor); + } + + const boundaryMap = new Array(rewrittenExpr.length + 1); + for (let i = 0; i < deduped.length - 1; i += 1) { + const current = deduped[i]; + const next = deduped[i + 1]; + const rewrittenSpan = next.rewritten - current.rewritten; + const originalSpan = next.original - current.original; + if (rewrittenSpan <= 0) continue; + + for (let offset = current.rewritten; offset < next.rewritten; offset += 1) { + const relative = offset - current.rewritten; + boundaryMap[offset] = current.original + Math.round(relative * originalSpan / rewrittenSpan); + } + } + + boundaryMap[rewrittenExpr.length] = originalExpr.length; + for (let i = 0; i < boundaryMap.length; i += 1) { + if (boundaryMap[i] == null) boundaryMap[i] = i === 0 ? 0 : boundaryMap[i - 1]; + } + + return boundaryMap; + } catch { + return Array.from({ length: rewrittenExpr.length + 1 }, (_, index) => ( + Math.min(originalExpr.length, Math.round(index * originalExpr.length / Math.max(1, rewrittenExpr.length))) + )); + } +} + +function unwrapLintComparableExpression(node: t.Expression): t.Expression { + if (t.isParenthesizedExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + if (t.isTSAsExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + if (t.isTSTypeAssertion(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + if (t.isTSNonNullExpression(node)) return unwrapLintComparableExpression(node.expression as t.Expression); + return node; +} + +function isRepeatableTruthyExpression(node: t.Expression): boolean { + const unwrapped = unwrapLintComparableExpression(node); + return ( + t.isIdentifier(unwrapped) + || t.isThisExpression(unwrapped) + || t.isSuper(unwrapped) + || t.isMemberExpression(unwrapped) + || t.isOptionalMemberExpression(unwrapped) + ); +} + +function areEquivalentRepeatableExpressions(left: t.Expression, right: t.Expression): boolean { + const a = unwrapLintComparableExpression(left); + const b = unwrapLintComparableExpression(right); + if (a.type !== b.type) return false; + if (t.isIdentifier(a) && t.isIdentifier(b)) return a.name === b.name; + if (t.isThisExpression(a) && t.isThisExpression(b)) return true; + if (t.isSuper(a) && t.isSuper(b)) return true; + + if (t.isMemberExpression(a) && t.isMemberExpression(b)) { + return ( + a.computed === b.computed + && areEquivalentRepeatableExpressions(a.object as t.Expression, b.object as t.Expression) + && ( + a.computed + ? ( + t.isExpression(a.property) + && t.isExpression(b.property) + && areEquivalentRepeatableExpressions(a.property, b.property) + ) + : ( + t.isIdentifier(a.property) + && t.isIdentifier(b.property) + && a.property.name === b.property.name + ) + ) + ); + } + + if (t.isOptionalMemberExpression(a) && t.isOptionalMemberExpression(b)) { + return ( + a.computed === b.computed + && a.optional === b.optional + && areEquivalentRepeatableExpressions(a.object as t.Expression, b.object as t.Expression) + && ( + a.computed + ? areEquivalentRepeatableExpressions(a.property as t.Expression, b.property as t.Expression) + : ( + t.isIdentifier(a.property) + && t.isIdentifier(b.property) + && a.property.name === b.property.name + ) + ) + ); + } + + return false; +} + +function isFragmentJsxName(name: t.JSXIdentifier | t.JSXMemberExpression | t.JSXNamespacedName): boolean { + return ( + (t.isJSXIdentifier(name) && name.name === 'Fragment') + || ( + t.isJSXMemberExpression(name) + && t.isJSXIdentifier(name.object) + && name.object.name === 'React' + && t.isJSXIdentifier(name.property) + && name.property.name === 'Fragment' + ) + ); +} + +function isNode(value: unknown): value is t.Node { + return !!value && typeof value === 'object' && typeof (value as any).type === 'string'; +} + +function isTransparentContainer(node: t.Node): boolean { + return ( + t.isParenthesizedExpression(node) + || t.isTSAsExpression(node) + || t.isTSTypeAssertion(node) + || t.isTSNonNullExpression(node) + ); +} + +interface AstPathEntry { + node: t.Node; + parent: t.Node | null; + key: string | number | null; +} + +function findInnermostAstPathContainingRange( + root: t.Node, + start: number, + end: number, +): AstPathEntry[] | null { + let bestPath: AstPathEntry[] | null = null; + + const visit = (node: t.Node, parent: t.Node | null, key: string | number | null, path: AstPathEntry[]) => { + if (typeof node.start !== 'number' || typeof node.end !== 'number') return; + if (start < node.start || end > node.end) return; + + const nextPath = [...path, { node, parent, key }]; + if ( + !bestPath + || (node.end - node.start) <= ((bestPath[bestPath.length - 1].node.end ?? 0) - (bestPath[bestPath.length - 1].node.start ?? 0)) + ) { + bestPath = nextPath; + } + + for (const [childKey, value] of Object.entries(node as any)) { + if (childKey === 'loc' || childKey === 'start' || childKey === 'end' || childKey === 'extra') continue; + if (Array.isArray(value)) { + value.forEach((item, index) => { + if (isNode(item)) visit(item, node, index, nextPath); + }); + continue; + } + if (isNode(value)) visit(value, node, childKey, nextPath); + } + }; + + visit(root, null, null, []); + return bestPath; +} + +function getInlinePrefix(baseCode: string, offset: number): boolean { + const lineStart = baseCode.lastIndexOf('\n', Math.max(0, offset - 1)) + 1; + return baseCode.slice(lineStart, offset).trim().length > 0; +} + +function classifyRegionFormattingContextFromAst( + baseCode: string, + astRoot: t.Node | null, + start: number, + end: number, +): RegionFormattingContext { + const inlinePrefix = getInlinePrefix(baseCode, start); + + try { + const path = astRoot ? findInnermostAstPathContainingRange(astRoot, start, end) : null; + if (!path || path.length === 0) { + return { + containerKind: inlinePrefix ? 'other-expression' : 'standalone', + }; + } + + for (let index = path.length - 1; index >= 0; index -= 1) { + const entry = path[index]; + const parent = entry.parent; + if (!parent) continue; + + if (t.isConditionalExpression(parent) && (entry.key === 'consequent' || entry.key === 'alternate')) { + return { + containerKind: 'conditional-branch', + }; + } + } + + for (let index = path.length - 1; index >= 0; index -= 1) { + const entry = path[index]; + const parent = entry.parent; + if (!parent) continue; + + if (t.isReturnStatement(parent) && entry.key === 'argument') { + return { containerKind: 'return-value' }; + } + if (t.isVariableDeclarator(parent) && entry.key === 'init') { + return { containerKind: 'variable-init' }; + } + if (t.isAssignmentExpression(parent) && entry.key === 'right') { + return { containerKind: 'assignment-value' }; + } + if ( + (t.isObjectProperty(parent) || t.isObjectMethod(parent)) + && entry.key === 'value' + ) { + return { containerKind: 'object-property-value' }; + } + if ( + (t.isCallExpression(parent) || t.isNewExpression(parent)) + && typeof entry.key === 'number' + ) { + return { containerKind: 'call-argument' }; + } + if (t.isArrowFunctionExpression(parent) && entry.key === 'body') { + return { containerKind: 'arrow-body' }; + } + if (t.isLogicalExpression(parent) && (entry.key === 'left' || entry.key === 'right')) { + return { containerKind: 'logical-operand' }; + } + } + + return { + containerKind: inlinePrefix ? 'other-expression' : 'standalone', + }; + } catch { + return { + containerKind: inlinePrefix ? 'other-expression' : 'standalone', + }; + } +} + +function createRegionFormattingContextResolver(baseCode: string, filename: string) { + let astRoot: t.Node | null = null; + try { + const ast = parse(baseCode, { + sourceType: 'module', + plugins: getExpressionParserPlugins(filename), + createParenthesizedExpressions: true, + errorRecovery: false, + }) as any; + astRoot = ast.program as t.Node; + } catch { + astRoot = null; + } + + return (start: number, end: number) => classifyRegionFormattingContextFromAst(baseCode, astRoot, start, end); +} + +function normalizeLintExpressionAst(node: T): T { + if (t.isConditionalExpression(node)) { + node.test = normalizeLintExpressionAst(node.test); + node.consequent = normalizeLintExpressionAst(node.consequent); + node.alternate = normalizeLintExpressionAst(node.alternate); + + if (isRepeatableTruthyExpression(node.test) && areEquivalentRepeatableExpressions(node.test, node.consequent)) { + return t.logicalExpression('||', node.test, node.alternate) as T; + } + + return node; + } + + if (t.isJSXElement(node)) { + node.children = node.children.map(child => normalizeLintExpressionAst(child)); + if ( + isFragmentJsxName(node.openingElement.name) + && node.openingElement.attributes.length === 0 + && !node.openingElement.selfClosing + && node.closingElement + ) { + return t.jsxFragment(t.jsxOpeningFragment(), t.jsxClosingFragment(), node.children) as T; + } + return node; + } + + if (t.isJSXFragment(node)) { + node.children = node.children.map(child => normalizeLintExpressionAst(child)); + return node; + } + + if (t.isJSXExpressionContainer(node)) { + node.expression = normalizeLintExpressionAst(node.expression); + return node; + } + + if (Array.isArray((node as any).children)) { + (node as any).children = (node as any).children.map((child: any) => normalizeLintExpressionAst(child)); + } + + for (const [key, value] of Object.entries(node as any)) { + if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue; + if (Array.isArray(value)) { + (node as any)[key] = value.map((item: any) => { + if (!item || typeof item !== 'object' || typeof item.type !== 'string') return item; + return normalizeLintExpressionAst(item as t.Node); + }); + continue; + } + if (value && typeof value === 'object' && typeof (value as any).type === 'string') { + (node as any)[key] = normalizeLintExpressionAst(value as t.Node); + } + } + + return node; +} + +export function normalizePugExpressionForLint(expr: string, filename: string): BoundaryMappedExpression { + try { + const ast = parseExpression(expr, { + plugins: getExpressionParserPlugins(filename), + errorRecovery: false, + }) as t.Expression; + const normalized = normalizeLintExpressionAst(ast); + const code = generate(normalized, { + comments: true, + jsescOption: { + minimal: true, + }, + }).code; + return { + code, + boundaryMap: buildExpressionBoundaryMap(expr, code, filename), + }; + } catch { + return { + code: expr, + boundaryMap: buildExpressionBoundaryMap(expr, expr, filename), + }; + } +} + +interface FormattingWrapperPlan { + code: string; + extract: (ast: any) => { start: number; end: number } | null; +} + +function getFormattingWrapperPlan(expr: string, containerKind: RegionContainerKind): FormattingWrapperPlan { + switch (containerKind) { + case 'conditional-branch': + return { + code: `const __ctx = __cond ? ${expr} : __alt\n`, + extract: (ast) => ast.program.body[0]?.declarations?.[0]?.init?.consequent ?? null, + }; + case 'object-property-value': + return { + code: `const __ctx = {\n value: ${expr}\n}\n`, + extract: (ast) => ast.program.body[0]?.declarations?.[0]?.init?.properties?.[0]?.value ?? null, + }; + case 'return-value': + return { + code: `function __ctx () {\n return ${expr}\n}\n`, + extract: (ast) => ast.program.body[0]?.body?.body?.[0]?.argument ?? null, + }; + case 'assignment-value': + return { + code: `__reactPugFmt = ${expr}\n`, + extract: (ast) => ast.program.body[0]?.expression?.right ?? null, + }; + case 'call-argument': + return { + code: `__reactPugFmt(${expr})\n`, + extract: (ast) => ast.program.body[0]?.expression?.arguments?.[0] ?? null, + }; + case 'arrow-body': + return { + code: `const __ctx = () => ${expr}\n`, + extract: (ast) => ast.program.body[0]?.declarations?.[0]?.init?.body ?? null, + }; + case 'logical-operand': + return { + code: `const __ctx = __cond && ${expr}\n`, + extract: (ast) => ast.program.body[0]?.declarations?.[0]?.init?.right ?? null, + }; + case 'standalone': + return { + code: `${expr}\n`, + extract: (ast) => ast.program.body[0]?.expression ?? null, + }; + case 'variable-init': + case 'other-expression': + default: + return { + code: `const __pug = ${expr}\n`, + extract: (ast) => ast.program.body[0]?.declarations?.[0]?.init ?? null, + }; + } +} + +export function createFormattingWrapper(expr: string, containerKind: RegionContainerKind): string { + return getFormattingWrapperPlan(expr, containerKind).code; +} + +function getLineIndentWidth(text: string, offset: number): number { + const lineStart = text.lastIndexOf('\n', Math.max(0, offset - 1)) + 1; + const lineEnd = text.indexOf('\n', lineStart) >= 0 ? text.indexOf('\n', lineStart) : text.length; + const line = text.slice(lineStart, lineEnd); + return line.match(/^[ \t]*/)?.[0].length ?? 0; +} + +export function extractFormattedExpressionFromWrapper( + formattedWrapper: string, + containerKind: RegionContainerKind, + filename: string, +): FormattingWrapperExtraction | null { + try { + const ast = parse(formattedWrapper, { + sourceType: 'module', + plugins: getExpressionParserPlugins(filename), + createParenthesizedExpressions: true, + errorRecovery: false, + }) as any; + const wrapper = getFormattingWrapperPlan('', containerKind); + const node = wrapper.extract(ast); + if (!node || typeof node.start !== 'number' || typeof node.end !== 'number') return null; + + return { + code: formattedWrapper.slice(node.start, node.end), + wrapperLineIndentWidth: getLineIndentWidth(formattedWrapper, node.start), + }; + } catch { + return null; + } +} + +export function rewriteMappedPugRegions( + baseTransform: SourceTransformResult, + filename: string, + rewriteRegion: (expr: string, region: ShadowMappedRegion, filename: string) => BoundaryMappedExpression, +): RewrittenPugRegionsResult { + const pugRegions = baseTransform.document.mappedRegions + .filter(region => region.kind === 'pug') + .sort((a, b) => a.shadowStart - b.shadowStart); + + if (pugRegions.length === 0) { + return { + code: baseTransform.code, + copySegments: [{ + rewrittenStart: 0, + rewrittenEnd: baseTransform.code.length, + baseStart: 0, + baseEnd: baseTransform.code.length, + }], + regionSegments: [], + mapRewrittenOffsetToBase: (offset: number) => ( + offset >= 0 && offset <= baseTransform.code.length ? offset : null + ), + mapBaseOffsetToRewritten: (offset: number) => ( + offset >= 0 && offset <= baseTransform.code.length ? offset : null + ), + }; + } + + let code = ''; + let cursor = 0; + const copySegments: RewrittenCopySegment[] = []; + const regionSegments: RewrittenRegionSegment[] = []; + const resolveFormattingContext = createRegionFormattingContextResolver(baseTransform.code, filename); + + for (const region of pugRegions) { + if (cursor < region.shadowStart) { + const rewrittenStart = code.length; + const copied = baseTransform.code.slice(cursor, region.shadowStart); + code += copied; + copySegments.push({ + rewrittenStart, + rewrittenEnd: code.length, + baseStart: cursor, + baseEnd: region.shadowStart, + }); + } + + const rewrittenStart = code.length; + const originalExpr = baseTransform.code.slice(region.shadowStart, region.shadowEnd); + const rewritten = rewriteRegion(originalExpr, region, filename); + code += rewritten.code; + regionSegments.push({ + rewrittenStart, + rewrittenEnd: code.length, + baseStart: region.shadowStart, + baseEnd: region.shadowEnd, + boundaryMap: rewritten.boundaryMap, + region, + formattingContext: resolveFormattingContext(region.shadowStart, region.shadowEnd), + }); + cursor = region.shadowEnd; + } + + if (cursor < baseTransform.code.length) { + const rewrittenStart = code.length; + const copied = baseTransform.code.slice(cursor); + code += copied; + copySegments.push({ + rewrittenStart, + rewrittenEnd: code.length, + baseStart: cursor, + baseEnd: baseTransform.code.length, + }); + } + + const mapRewrittenOffsetToBase = (offset: number): number | null => { + const clamped = Math.max(0, Math.min(offset, code.length)); + + for (const region of regionSegments) { + if (clamped < region.rewrittenStart || clamped > region.rewrittenEnd) continue; + const localOffset = clamped - region.rewrittenStart; + const mappedLocal = region.boundaryMap[Math.min(localOffset, region.boundaryMap.length - 1)] ?? 0; + return region.baseStart + mappedLocal; + } + + for (const segment of copySegments) { + if (clamped < segment.rewrittenStart || clamped > segment.rewrittenEnd) continue; + return segment.baseStart + (clamped - segment.rewrittenStart); + } + + return null; + }; + + const mapBaseOffsetToRewritten = (offset: number): number | null => { + const clamped = Math.max(0, Math.min(offset, baseTransform.code.length)); + + for (const region of regionSegments) { + if (clamped < region.baseStart || clamped > region.baseEnd) continue; + const localOffset = clamped - region.baseStart; + + for (let i = 0; i < region.boundaryMap.length; i += 1) { + if (region.boundaryMap[i] >= localOffset) { + return region.rewrittenStart + i; + } + } + + return region.rewrittenEnd; + } + + for (const segment of copySegments) { + if (clamped < segment.baseStart || clamped > segment.baseEnd) continue; + return segment.rewrittenStart + (clamped - segment.baseStart); + } + + return null; + }; + + return { + code, + copySegments, + regionSegments, + mapRewrittenOffsetToBase, + mapBaseOffsetToRewritten, + }; +} + +export function rewriteSegmentedPugRegions( + input: SegmentedPugRegionsInput, + filename: string, + rewriteRegion: (expr: string, region: RewrittenRegionSegment, filename: string) => BoundaryMappedExpression, +): RewrittenPugRegionsResult { + const pugRegions = input.regionSegments + .slice() + .sort((a, b) => a.rewrittenStart - b.rewrittenStart); + + if (pugRegions.length === 0) { + return { + code: input.code, + copySegments: [{ + rewrittenStart: 0, + rewrittenEnd: input.code.length, + baseStart: 0, + baseEnd: input.code.length, + }], + regionSegments: [], + mapRewrittenOffsetToBase: (offset: number) => ( + offset >= 0 && offset <= input.code.length ? offset : null + ), + mapBaseOffsetToRewritten: (offset: number) => ( + offset >= 0 && offset <= input.code.length ? offset : null + ), + }; + } + + let code = ''; + let cursor = 0; + const copySegments: RewrittenCopySegment[] = []; + const regionSegments: RewrittenRegionSegment[] = []; + + for (const region of pugRegions) { + if (cursor < region.rewrittenStart) { + const rewrittenStart = code.length; + const copied = input.code.slice(cursor, region.rewrittenStart); + code += copied; + copySegments.push({ + rewrittenStart, + rewrittenEnd: code.length, + baseStart: cursor, + baseEnd: region.rewrittenStart, + }); + } + + const rewrittenStart = code.length; + const originalExpr = input.code.slice(region.rewrittenStart, region.rewrittenEnd); + const rewritten = rewriteRegion(originalExpr, region, filename); + code += rewritten.code; + regionSegments.push({ + rewrittenStart, + rewrittenEnd: code.length, + baseStart: region.rewrittenStart, + baseEnd: region.rewrittenEnd, + boundaryMap: rewritten.boundaryMap, + region: region.region, + formattingContext: region.formattingContext, + }); + cursor = region.rewrittenEnd; + } + + if (cursor < input.code.length) { + const rewrittenStart = code.length; + const copied = input.code.slice(cursor); + code += copied; + copySegments.push({ + rewrittenStart, + rewrittenEnd: code.length, + baseStart: cursor, + baseEnd: input.code.length, + }); + } + + const mapRewrittenOffsetToBase = (offset: number): number | null => { + const clamped = Math.max(0, Math.min(offset, code.length)); + + for (const region of regionSegments) { + if (clamped < region.rewrittenStart || clamped > region.rewrittenEnd) continue; + const localOffset = clamped - region.rewrittenStart; + const mappedLocal = region.boundaryMap[Math.min(localOffset, region.boundaryMap.length - 1)] ?? 0; + return region.baseStart + mappedLocal; + } + + for (const segment of copySegments) { + if (clamped < segment.rewrittenStart || clamped > segment.rewrittenEnd) continue; + return segment.baseStart + (clamped - segment.rewrittenStart); + } + + return null; + }; + + const mapBaseOffsetToRewritten = (offset: number): number | null => { + const clamped = Math.max(0, Math.min(offset, input.code.length)); + + for (const region of regionSegments) { + if (clamped < region.baseStart || clamped > region.baseEnd) continue; + const localOffset = clamped - region.baseStart; + + for (let i = 0; i < region.boundaryMap.length; i += 1) { + if (region.boundaryMap[i] >= localOffset) { + return region.rewrittenStart + i; + } + } + + return region.rewrittenEnd; + } + + for (const segment of copySegments) { + if (clamped < segment.baseStart || clamped > segment.baseEnd) continue; + return segment.rewrittenStart + (clamped - segment.baseStart); + } + + return null; + }; + + return { + code, + copySegments, + regionSegments, + mapRewrittenOffsetToBase, + mapBaseOffsetToRewritten, + }; +} + +interface MappedInsertionRangesInput { + baseTransform: SourceTransformResult; + mapBaseOffsetToRewritten: (offset: number) => number | null; +} + +export function collectMappedInsertionRangesByKind( + input: MappedInsertionRangesInput, + kind: ShadowInsertion['kind'], +): InsertionOffsetRange[] { + const ranges: InsertionOffsetRange[] = []; + + for (const insertion of input.baseTransform.document.insertions) { + if (insertion.kind !== kind) continue; + const start = input.mapBaseOffsetToRewritten(insertion.shadowStart); + const end = input.mapBaseOffsetToRewritten(insertion.shadowEnd); + if (start == null || end == null) continue; + ranges.push({ start, end }); + } + + return ranges; +} + +export function createLintTransform( + sourceText: string, + fileName: string, + options: SourceTransformOptions = {}, +): LintTransformResult { + const baseTransform = transformSourceFile(sourceText, fileName, { + ...options, + compileMode: 'runtime', + }); + const rewritten = rewriteMappedPugRegions(baseTransform, fileName, (expr, _region, currentFileName) => ( + normalizePugExpressionForLint(expr, currentFileName) + )); + + return { + ...rewritten, + baseTransform, + mapGeneratedOffsetToOriginal: (offset: number) => { + const baseOffset = rewritten.mapRewrittenOffsetToBase(offset); + return baseOffset == null ? null : baseTransform.mapGeneratedOffsetToOriginal(baseOffset); + }, + mapBaseOffsetToOriginal: (offset: number) => baseTransform.mapGeneratedOffsetToOriginal(offset), + }; +} diff --git a/packages/react-pug-core/src/language/positionMapping.ts b/packages/react-pug-core/src/language/positionMapping.ts index 18fa7d3..66ebdf4 100644 --- a/packages/react-pug-core/src/language/positionMapping.ts +++ b/packages/react-pug-core/src/language/positionMapping.ts @@ -6,6 +6,10 @@ import type { ShadowCopySegment, ShadowMappedRegion, } from './mapping'; +import { + originalOffsetToRegionStrippedOffset, + strippedToRawOffset, +} from './regionOffsetMapping'; const sourceMapCache = new WeakMap>(); @@ -18,45 +22,6 @@ function getSourceMap(region: ShadowMappedRegion): SourceMap { return sm; } -function rawToStrippedOffset(rawText: string, rawOffset: number, commonIndent: number): number | null { - if (commonIndent === 0) return rawOffset; - let stripped = 0; - let raw = 0; - const lines = rawText.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const lineEnd = raw + line.length; - if (rawOffset <= lineEnd) { - const colInRaw = rawOffset - raw; - const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; - if (indentToRemove > 0 && colInRaw < indentToRemove) return null; - return stripped + Math.max(0, colInRaw - indentToRemove); - } - const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; - stripped += Math.max(0, line.length - indentToRemove) + 1; - raw = lineEnd + 1; - } - return stripped; -} - -function strippedToRawOffset(rawText: string, strippedOffset: number, commonIndent: number): number { - if (commonIndent === 0) return strippedOffset; - let stripped = 0; - let raw = 0; - const lines = rawText.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; - const strippedLineLen = Math.max(0, line.length - indentToRemove); - if (strippedOffset <= stripped + strippedLineLen) { - return raw + indentToRemove + (strippedOffset - stripped); - } - stripped += strippedLineLen + 1; - raw += line.length + 1; - } - return raw; -} - function findSegmentAtOffset( segments: T[], offset: number, @@ -114,10 +79,7 @@ export function originalToShadow( const region = findRegionAtOriginalOffset(doc, originalOffset); if (region) { const regionIndex = doc.regions.indexOf(region); - const rawOffset = originalOffset - region.pugTextStart; - if (rawOffset < 0) return null; - const rawText = doc.originalText.slice(region.pugTextStart, region.pugTextEnd); - const strippedOffset = rawToStrippedOffset(rawText, rawOffset, region.commonIndent); + const strippedOffset = originalOffsetToRegionStrippedOffset(doc, region, originalOffset); if (strippedOffset == null) return null; for (const mappedRegion of getMappedRegionsForRegion(doc, regionIndex)) { diff --git a/packages/react-pug-core/src/language/pugToTsx.ts b/packages/react-pug-core/src/language/pugToTsx.ts index 45a228b..9838774 100644 --- a/packages/react-pug-core/src/language/pugToTsx.ts +++ b/packages/react-pug-core/src/language/pugToTsx.ts @@ -9,6 +9,7 @@ import type { } from './mapping'; import { FULL_FEATURES, CSS_CLASS, SYNTHETIC, VERIFY_ONLY } from './mapping'; import { extractPugRegions } from './extractRegions'; +import { strippedToRawOffset } from './regionOffsetMapping'; // ── TsxEmitter ────────────────────────────────────────────────── @@ -527,25 +528,6 @@ function findNextInterpolationOccurrence( return { index: bestIdx, interpolation: bestInterpolation }; } -function strippedToRawOffset(rawText: string, strippedOffset: number, commonIndent: number): number { - if (commonIndent === 0) return strippedOffset; - let stripped = 0; - let raw = 0; - const lines = rawText.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; - const strippedLineLen = Math.max(0, line.length - indentToRemove); - if (strippedOffset <= stripped + strippedLineLen) { - const colInStripped = strippedOffset - stripped; - return raw + indentToRemove + colInStripped; - } - stripped += strippedLineLen + 1; - raw += line.length + 1; - } - return raw; -} - function countIndent(line: string): number { return line.match(/^(\s*)/)?.[1].length ?? 0; } diff --git a/packages/react-pug-core/src/language/queryMapping.ts b/packages/react-pug-core/src/language/queryMapping.ts new file mode 100644 index 0000000..77c113c --- /dev/null +++ b/packages/react-pug-core/src/language/queryMapping.ts @@ -0,0 +1,126 @@ +import type { PugDocument } from './mapping'; +import type { OffsetRange } from './diagnosticMapping'; +import { findRegionAtOriginalOffset, originalToShadow, shadowToOriginal } from './positionMapping'; + +export interface OffsetSpan { + start: number; + length: number; +} + +export interface EncodedClassificationsLike { + spans: number[]; + endOfLineState: number; +} + +export function mapOriginalSpanToShadow(doc: PugDocument, span: OffsetSpan): OffsetRange | null { + const shadowStart = originalToShadow(doc, span.start); + const shadowEnd = originalToShadow(doc, span.start + span.length); + if (shadowStart == null || shadowEnd == null || shadowEnd < shadowStart) return null; + return { + start: shadowStart, + end: shadowEnd, + length: shadowEnd - shadowStart, + }; +} + +export function mapShadowSpanToOriginal(doc: PugDocument, span: OffsetSpan): OffsetRange | null { + const originalStart = shadowToOriginal(doc, span.start); + if (originalStart == null) return null; + + const originalEnd = shadowToOriginal(doc, span.start + span.length); + const mappedLength = ( + originalEnd != null && originalEnd >= originalStart + ? originalEnd - originalStart + : span.length + ); + + return { + start: originalStart, + end: originalStart + mappedLength, + length: mappedLength, + }; +} + +export function mapEncodedClassificationsToOriginal( + doc: PugDocument, + requestedOriginalSpan: OffsetSpan, + classifications: EncodedClassificationsLike, +): EncodedClassificationsLike { + const originalStart = requestedOriginalSpan.start; + const originalEnd = requestedOriginalSpan.start + requestedOriginalSpan.length; + const maxOriginal = doc.originalText.length; + const mappedSpans: number[] = []; + const encoded = classifications.spans ?? []; + + for (let i = 0; i + 2 < encoded.length; i += 3) { + const shadowStart = encoded[i]; + const shadowLength = encoded[i + 1]; + const classification = encoded[i + 2]; + if (!Number.isFinite(shadowStart) || !Number.isFinite(shadowLength) || shadowLength <= 0) continue; + + const mappedStart = shadowToOriginal(doc, shadowStart); + const mappedEnd = shadowToOriginal(doc, shadowStart + shadowLength); + if (mappedStart == null || mappedEnd == null) continue; + + let start = mappedStart; + let end = mappedEnd; + if (end <= start) continue; + + if (end <= originalStart || start >= originalEnd) continue; + if (start < originalStart) start = originalStart; + if (end > originalEnd) end = originalEnd; + if (start < 0) start = 0; + if (end > maxOriginal) end = maxOriginal; + + const length = end - start; + if (length <= 0) continue; + + mappedSpans.push(start, length, classification); + } + + return { + spans: mappedSpans, + endOfLineState: classifications.endOfLineState, + }; +} + +export function mapOriginalOffsetToNearbyShadowOnSameLine( + doc: PugDocument, + position: number, + maxRadius: number = 3, +): number | null { + const mapped = originalToShadow(doc, position); + if (mapped != null) return mapped; + + const region = findRegionAtOriginalOffset(doc, position); + if (!region) return null; + if (position < region.pugTextStart || position > region.pugTextEnd) return null; + + const lineStart = doc.originalText.lastIndexOf('\n', position - 1) + 1; + const lineEndIdx = doc.originalText.indexOf('\n', position); + const lineEnd = lineEndIdx >= 0 ? lineEndIdx : doc.originalText.length; + + for (let radius = 1; radius <= maxRadius; radius += 1) { + const left = position - radius; + if (left >= lineStart) { + const leftMapped = originalToShadow(doc, left); + if (leftMapped != null) { + if (left === position - 1) { + const ch = doc.originalText[position] ?? ''; + if (/\s|[),]/.test(ch)) { + return Math.min(leftMapped + 1, doc.shadowText.length); + } + } + return leftMapped; + } + } + + const right = position + radius; + if (right <= lineEnd) { + const rightMapped = originalToShadow(doc, right); + if (rightMapped != null) return rightMapped; + } + } + + return null; +} diff --git a/packages/react-pug-core/src/language/regionOffsetMapping.ts b/packages/react-pug-core/src/language/regionOffsetMapping.ts new file mode 100644 index 0000000..bc00a63 --- /dev/null +++ b/packages/react-pug-core/src/language/regionOffsetMapping.ts @@ -0,0 +1,58 @@ +import type { PugDocument, PugRegion } from './mapping'; + +export function rawToStrippedOffset(rawText: string, rawOffset: number, commonIndent: number): number | null { + if (commonIndent === 0) return rawOffset; + let stripped = 0; + let raw = 0; + const lines = rawText.split('\n'); + for (const line of lines) { + const lineEnd = raw + line.length; + if (rawOffset <= lineEnd) { + const colInRaw = rawOffset - raw; + const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; + if (indentToRemove > 0 && colInRaw < indentToRemove) return null; + return stripped + Math.max(0, colInRaw - indentToRemove); + } + const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; + stripped += Math.max(0, line.length - indentToRemove) + 1; + raw = lineEnd + 1; + } + return stripped; +} + +export function strippedToRawOffset(rawText: string, strippedOffset: number, commonIndent: number): number { + if (commonIndent === 0) return strippedOffset; + let stripped = 0; + let raw = 0; + const lines = rawText.split('\n'); + for (const line of lines) { + const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; + const strippedLineLength = Math.max(0, line.length - indentToRemove); + if (strippedOffset <= stripped + strippedLineLength) { + return raw + indentToRemove + (strippedOffset - stripped); + } + stripped += strippedLineLength + 1; + raw += line.length + 1; + } + return raw; +} + +export function originalOffsetToRegionStrippedOffset( + doc: PugDocument, + region: PugRegion, + originalOffset: number, +): number | null { + const rawOffset = originalOffset - region.pugTextStart; + if (rawOffset < 0) return null; + const rawText = doc.originalText.slice(region.pugTextStart, region.pugTextEnd); + return rawToStrippedOffset(rawText, rawOffset, region.commonIndent); +} + +export function regionStrippedOffsetToOriginalOffset( + doc: PugDocument, + region: PugRegion, + strippedOffset: number, +): number { + const rawText = doc.originalText.slice(region.pugTextStart, region.pugTextEnd); + return region.pugTextStart + strippedToRawOffset(rawText, strippedOffset, region.commonIndent); +} diff --git a/packages/react-pug-core/test/unit/documentIssues.test.ts b/packages/react-pug-core/test/unit/documentIssues.test.ts new file mode 100644 index 0000000..9340a14 --- /dev/null +++ b/packages/react-pug-core/test/unit/documentIssues.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import type { PugDocument, PugRegion } from '../../src/language/mapping'; +import { collectPugDocumentIssues } from '../../src/language/documentIssues'; + +function createRegion(overrides: Partial): PugRegion { + return { + originalStart: 0, + originalEnd: 0, + pugTextStart: 0, + pugTextEnd: 0, + pugText: '', + commonIndent: 0, + shadowStart: 0, + shadowEnd: 0, + tsxText: '', + mappings: [], + lexerTokens: [], + parseError: null, + transformError: null, + styleBlock: null, + ...overrides, + }; +} + +function createDoc( + originalText: string, + region: PugRegion, + missingTagImport: PugDocument['missingTagImport'] = null, +): PugDocument { + return { + originalText, + uri: 'test.tsx', + regions: [region], + importCleanups: [], + copySegments: [], + mappedRegions: [], + insertions: [], + shadowText: originalText, + version: 1, + regionDeltas: [], + usesTagFunction: true, + hasTagImport: !missingTagImport, + missingTagImport, + }; +} + +describe('collectPugDocumentIssues', () => { + it('collects missing import issues directly from document metadata', () => { + const source = 'const view = pug`div`;'; + const region = createRegion({ + originalStart: source.indexOf('pug`'), + originalEnd: source.lastIndexOf('`') + 1, + pugTextStart: source.indexOf('div'), + pugTextEnd: source.indexOf('div') + 'div'.length, + pugText: 'div', + shadowStart: source.indexOf('pug`'), + shadowEnd: source.indexOf('pug`') + '
'.length, + }); + const issues = collectPugDocumentIssues( + createDoc(source, region, { + message: 'Missing import for tag function "pug"', + start: source.indexOf('pug`'), + length: 'pug'.length, + }), + ); + + expect(issues).toEqual([{ + kind: 'missing-tag-import', + start: source.indexOf('pug`'), + length: 'pug'.length, + message: 'Missing import for tag function "pug"', + }]); + }); + + it('anchors parse errors that point at a newline to the next content line', () => { + const source = [ + 'const view = pug`', + ' div(', + '`;', + ].join('\n'); + const pugTextStart = source.indexOf('`') + 1; + const pugTextEnd = source.lastIndexOf('`'); + const region = createRegion({ + originalStart: source.indexOf('pug`'), + originalEnd: source.lastIndexOf('`') + 1, + pugTextStart, + pugTextEnd, + pugText: '\ndiv(\n', + commonIndent: 2, + parseError: { + message: 'Unexpected end of input', + line: 1, + column: 1, + offset: 0, + }, + }); + + const issues = collectPugDocumentIssues(createDoc(source, region)); + + expect(issues).toEqual([{ + kind: 'parse-error', + start: source.indexOf('div('), + length: 'div('.length, + message: 'Unexpected end of input', + }]); + }); + + it('uses the style keyword length for style-tag-must-be-last transform errors', () => { + const source = 'const view = pug`style\\n .x\\nspan`;'; + const pugTextStart = source.indexOf('style'); + const pugTextEnd = source.lastIndexOf('`'); + const region = createRegion({ + originalStart: source.indexOf('pug`'), + originalEnd: source.lastIndexOf('`') + 1, + pugTextStart, + pugTextEnd, + pugText: 'style\\n .x\\nspan', + transformError: { + code: 'style-tag-must-be-last', + message: 'style tags must be the last top-level node', + line: 1, + column: 1, + offset: 0, + }, + }); + + const issues = collectPugDocumentIssues(createDoc(source, region)); + + expect(issues).toEqual([{ + kind: 'transform-error', + start: source.indexOf('style'), + length: 'style'.length, + message: 'style tags must be the last top-level node', + transformCode: 'style-tag-must-be-last', + }]); + }); +}); diff --git a/packages/react-pug-core/test/unit/lintTransform.test.ts b/packages/react-pug-core/test/unit/lintTransform.test.ts new file mode 100644 index 0000000..df73dec --- /dev/null +++ b/packages/react-pug-core/test/unit/lintTransform.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest' +import { + buildExpressionBoundaryMap, + collectMappedInsertionRangesByKind, + createLintTransform, + createFormattingWrapper, + extractFormattedExpressionFromWrapper, + normalizePugExpressionForLint, + rewriteSegmentedPugRegions, +} from '../../src/language/lintTransform' + +function remapSnippet (source: string, generated: string, snippet: string, mapper: (offset: number) => number | null): string | null { + const generatedOffset = generated.indexOf(snippet) + expect(generatedOffset).toBeGreaterThanOrEqual(0) + const originalOffset = mapper(generatedOffset) + if (originalOffset == null) return null + return source.slice(originalOffset, originalOffset + snippet.length) +} + +describe('lintTransform', () => { + it('normalizes attrless Fragment elements to JSX fragment shorthand', () => { + const result = normalizePugExpressionForLint('Ok', 'file.jsx') + + expect(result.code).toBe('<>Ok') + expect(result.boundaryMap).toHaveLength(result.code.length + 1) + }) + + it('normalizes repeated ternaries into logical fallback expressions when safe', () => { + const result = normalizePugExpressionForLint('children ? children : Fallback', 'file.jsx') + + expect(result.code).toBe('children || Fallback') + expect(result.boundaryMap).toHaveLength(result.code.length + 1) + }) + + it('keeps non-repeatable ternaries unchanged', () => { + const result = normalizePugExpressionForLint('getValue() ? getValue() : fallback', 'file.jsx') + + expect(result.code).toBe('getValue() ? getValue() : fallback') + }) + + it('rewrites only pug regions and leaves plain JSX untouched', () => { + const source = [ + "import { Fragment, pug } from 'startupjs'", + 'const plain = Plain', + 'const view = pug`', + ' Fragment', + ' span Pug', + '`', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + + expect(result.code).toContain('const plain = Plain') + expect(result.code).toContain('const view = <>Pug') + }) + + it('maps rewritten fragment output back to original pug source', () => { + const source = [ + "import { Fragment, pug } from 'startupjs'", + 'const view = pug`', + ' Fragment', + ' Button Save', + '`', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + + expect(remapSnippet(source, result.code, 'Button', result.mapGeneratedOffsetToOriginal)).toBe('Button') + expect(remapSnippet(source, result.code, 'Save', result.mapGeneratedOffsetToOriginal)).toBe('Save') + }) + + it('maps rewritten logical fallback output back to original pug source', () => { + const source = [ + "import { pug } from 'startupjs'", + 'function Demo ({ children }) {', + ' return pug`', + ' if children', + ' = children', + ' else', + ' span Fallback', + ' `', + '}', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + + expect(result.code).toContain('children || Fallback') + expect(remapSnippet(source, result.code, 'children', result.mapGeneratedOffsetToOriginal)).toBe('children') + expect(remapSnippet(source, result.code, 'Fallback', result.mapGeneratedOffsetToOriginal)).toBe('Fallback') + }) + + it('classifies ternary branch formatting context structurally', () => { + const source = [ + "import { pug } from 'startupjs'", + 'const view = condition', + ' ? pug`', + ' span Yes', + ' `', + " : No", + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + expect(result.regionSegments).toHaveLength(1) + expect(result.regionSegments[0].formattingContext).toEqual({ containerKind: 'conditional-branch' }) + }) + + it('classifies object-property formatting context structurally', () => { + const source = [ + "import { pug } from 'startupjs'", + 'const config = {', + ' children: pug`', + ' span Child', + ' `', + '}', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + expect(result.regionSegments).toHaveLength(1) + expect(result.regionSegments[0].formattingContext).toEqual({ containerKind: 'object-property-value' }) + }) + + it('classifies return-value formatting context structurally', () => { + const source = [ + "import { pug } from 'startupjs'", + 'function Demo () {', + ' return pug`', + ' span Demo', + ' `', + '}', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + expect(result.regionSegments).toHaveLength(1) + expect(result.regionSegments[0].formattingContext).toEqual({ containerKind: 'return-value' }) + }) + + it('classifies call-argument formatting context structurally', () => { + const source = [ + "import { pug } from 'startupjs'", + 'render(', + ' pug`', + ' span Demo', + ' `', + ')', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + expect(result.regionSegments).toHaveLength(1) + expect(result.regionSegments[0].formattingContext).toEqual({ containerKind: 'call-argument' }) + }) + + it('builds a stable boundary map for equivalent expressions', () => { + const boundaryMap = buildExpressionBoundaryMap('children ? children : Fallback', 'children || Fallback', 'file.jsx') + expect(boundaryMap).toHaveLength('children || Fallback'.length + 1) + expect(boundaryMap[0]).toBe(0) + expect(boundaryMap[boundaryMap.length - 1]).toBe('children ? children : Fallback'.length) + }) + + it('extracts a multiline conditional branch expression from a formatting wrapper', () => { + const wrapper = createFormattingWrapper('No photo', 'conditional-branch') + expect(wrapper).toContain('__cond ?') + + const extracted = extractFormattedExpressionFromWrapper( + [ + 'const __ctx = __cond ? (', + ' No photo', + ') : __alt', + '', + ].join('\n'), + 'conditional-branch', + 'file.jsx', + ) + + expect(extracted).toEqual({ + code: "(\n No photo\n)", + wrapperLineIndentWidth: 0, + }) + }) + + it('extracts an object-property expression from a formatting wrapper', () => { + const extracted = extractFormattedExpressionFromWrapper( + [ + 'const __ctx = {', + ' value: (', + ' ', + ' )', + '}', + '', + ].join('\n'), + 'object-property-value', + 'file.jsx', + ) + + expect(extracted).toEqual({ + code: "(\n \n )", + wrapperLineIndentWidth: 2, + }) + }) + + it('can rewrite already-segmented pug regions again while preserving mapping to the previous transform', () => { + const source = [ + "import { pug } from 'startupjs'", + 'const config = {', + ' children: pug`', + ' span Child', + ' `', + '}', + ].join('\n') + + const linted = createLintTransform(source, 'file.jsx') + const formatted = rewriteSegmentedPugRegions(linted, 'file.jsx', (expr) => { + const code = `(\n ${expr}\n)` + return { + code, + boundaryMap: buildExpressionBoundaryMap(expr, code, 'file.jsx'), + } + }) + + expect(formatted.code).toContain('(\n Child\n)') + + const rewrittenOffset = formatted.code.indexOf('Child') + expect(rewrittenOffset).toBeGreaterThanOrEqual(0) + const baseOffset = formatted.mapRewrittenOffsetToBase(rewrittenOffset) + expect(baseOffset).not.toBeNull() + expect(linted.code.slice(baseOffset!, baseOffset! + 'Child'.length)).toBe('Child') + }) + + it('exposes mapped synthetic style-call insertion ranges generically', () => { + const source = [ + "import { pug } from 'startupjs'", + 'export default function Demo () {', + ' return pug`', + ' Div Hello', + " style(lang='styl')", + ' .root', + ' color red', + ' `', + '}', + ].join('\n') + + const result = createLintTransform(source, 'file.jsx') + const styleCallRanges = collectMappedInsertionRangesByKind(result, 'style-call') + + expect(styleCallRanges).toHaveLength(1) + const styleCallText = result.code.slice(styleCallRanges[0].start, styleCallRanges[0].end) + expect(styleCallText).toContain('styl`') + expect(styleCallText).toContain('.root') + }) +}) diff --git a/packages/react-pug-core/test/unit/queryMapping.test.ts b/packages/react-pug-core/test/unit/queryMapping.test.ts new file mode 100644 index 0000000..45d68e6 --- /dev/null +++ b/packages/react-pug-core/test/unit/queryMapping.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { buildShadowDocument } from '../../src/language/shadowDocument'; +import { + originalOffsetToRegionStrippedOffset, + rawToStrippedOffset, + regionStrippedOffsetToOriginalOffset, + strippedToRawOffset, +} from '../../src/language/regionOffsetMapping'; +import { + mapEncodedClassificationsToOriginal, + mapOriginalOffsetToNearbyShadowOnSameLine, + mapOriginalSpanToShadow, + mapShadowSpanToOriginal, +} from '../../src/language/queryMapping'; +import { originalToShadow } from '../../src/language/positionMapping'; + +function makeDoc(text: string) { + return buildShadowDocument(text, 'test.tsx', 1); +} + +describe('regionOffsetMapping helpers', () => { + it('maps raw and stripped offsets across shared indent', () => { + const rawText = [' color red', ' border 1px solid black', ''].join('\n'); + const stripped = rawToStrippedOffset(rawText, rawText.indexOf('border'), 2); + expect(stripped).toBe('color red\n'.length); + expect(strippedToRawOffset(rawText, stripped!, 2)).toBe(rawText.indexOf('border')); + }); + + it('returns null when raw offset lands inside removed indent', () => { + const rawText = ' div Hello'; + expect(rawToStrippedOffset(rawText, 1, 4)).toBeNull(); + }); + + it('maps original file offsets to stripped region offsets and back', () => { + const source = ['const view = pug`', ' Button(onClick=handler)', '`;'].join('\n'); + const doc = makeDoc(source); + const region = doc.regions[0]; + const handlerOriginalOffset = source.indexOf('handler'); + const strippedOffset = originalOffsetToRegionStrippedOffset(doc, region, handlerOriginalOffset); + + expect(strippedOffset).not.toBeNull(); + expect(region.pugText.slice(strippedOffset!, strippedOffset! + 'handler'.length)).toBe('handler'); + expect(regionStrippedOffsetToOriginalOffset(doc, region, strippedOffset!)).toBe(handlerOriginalOffset); + }); +}); + +describe('queryMapping helpers', () => { + it('maps original spans to shadow spans and back', () => { + const source = ['const view = pug`', ' Button(onClick=handler)', '`;'].join('\n'); + const doc = makeDoc(source); + const handlerOriginalOffset = source.indexOf('handler'); + + const shadowSpan = mapOriginalSpanToShadow(doc, { + start: handlerOriginalOffset, + length: 'handler'.length, + }); + + expect(shadowSpan).not.toBeNull(); + expect(doc.shadowText.slice(shadowSpan!.start, shadowSpan!.end)).toBe('handler'); + + const originalSpan = mapShadowSpanToOriginal(doc, { + start: shadowSpan!.start, + length: shadowSpan!.length, + }); + + expect(originalSpan).toEqual({ + start: handlerOriginalOffset, + end: handlerOriginalOffset + 'handler'.length, + length: 'handler'.length, + }); + }); + + it('maps shadow spans back when the trailing boundary is synthetic', () => { + const source = [ + 'declare function pug(strings: TemplateStringsArray, ...values: any[]): any;', + 'const view = pug`', + ' Button(o)', + '`;', + ].join('\n'); + const doc = makeDoc(source); + const cursor = source.indexOf('Button(o') + 'Button(o'.length; + const shadowStart = originalToShadow(doc, cursor); + + expect(shadowStart).not.toBeNull(); + expect(mapShadowSpanToOriginal(doc, { start: shadowStart!, length: 1 })).toEqual({ + start: cursor, + end: cursor + 1, + length: 1, + }); + }); + + it('returns null for unmapped original spans', () => { + const source = 'const view = pug`div`;'; + const doc = makeDoc(source); + const pugOffset = source.indexOf('pug`'); + + expect(mapOriginalSpanToShadow(doc, { start: pugOffset, length: 3 })).toBeNull(); + }); + + it('maps encoded classifications back to original offsets', () => { + const source = ['const view = pug`', ' Button(onClick=handler)', '`;'].join('\n'); + const doc = makeDoc(source); + const handlerOriginalOffset = source.indexOf('handler'); + const handlerShadowOffset = originalToShadow(doc, handlerOriginalOffset); + expect(handlerShadowOffset).not.toBeNull(); + + const mapped = mapEncodedClassificationsToOriginal( + doc, + { start: 0, length: source.length }, + { spans: [handlerShadowOffset!, 'handler'.length, 7], endOfLineState: 0 }, + ); + + expect(mapped).toEqual({ + spans: [handlerOriginalOffset, 'handler'.length, 7], + endOfLineState: 0, + }); + }); + + it('finds a nearby mapped shadow offset on the same line for typing positions', () => { + const source = 'const view = pug`Button(onClick=handler, label="Hi")`;'; + const doc = makeDoc(source); + const spaceAfterCommaOffset = source.indexOf(',') + 1; + + expect(originalToShadow(doc, spaceAfterCommaOffset)).toBeNull(); + expect(mapOriginalOffsetToNearbyShadowOnSameLine(doc, spaceAfterCommaOffset)).not.toBeNull(); + }); +}); diff --git a/packages/typescript-plugin-react-pug/src/index.ts b/packages/typescript-plugin-react-pug/src/index.ts index 4e0f2df..61ca8e9 100644 --- a/packages/typescript-plugin-react-pug/src/index.ts +++ b/packages/typescript-plugin-react-pug/src/index.ts @@ -2,8 +2,15 @@ import type ts from 'typescript'; import { type PugDocument, buildShadowDocument, - hasTagFunctionCall, + collectPugDocumentIssues, findRegionAtOriginalOffset, + findRegionAtShadowOffset, + hasTagFunctionCall, + mapEncodedClassificationsToOriginal, + mapGeneratedRangeToOriginal, + mapOriginalOffsetToNearbyShadowOnSameLine, + mapOriginalSpanToShadow, + mapShadowSpanToOriginal, originalToShadow, shadowToOriginal, } from '@react-pug/react-pug-core'; @@ -43,29 +50,6 @@ function withExtraReactAttributes(shadowText: string): string { return `${shadowText}\n${EXTRA_REACT_ATTRIBUTES_TEXT}`; } -function strippedOffsetToRawOffset(rawText: string, strippedOffset: number, commonIndent: number): number { - if (commonIndent === 0) return strippedOffset; - let stripped = 0; - let raw = 0; - const lines = rawText.split('\n'); - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; - const strippedLineLength = Math.max(0, line.length - indentToRemove); - if (strippedOffset <= stripped + strippedLineLength) { - return raw + indentToRemove + (strippedOffset - stripped); - } - stripped += strippedLineLength + 1; - raw += line.length + 1; - } - return raw; -} - -function regionOffsetToOriginalOffset(doc: PugDocument, region: PugDocument['regions'][number], strippedOffset: number): number { - const rawText = doc.originalText.slice(region.pugTextStart, region.pugTextEnd); - return region.pugTextStart + strippedOffsetToRawOffset(rawText, strippedOffset, region.commonIndent); -} - function init(modules: { typescript: typeof ts }): ts.server.PluginModule { const tsModule = modules.typescript; @@ -262,47 +246,10 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { // Lenient mapping for typing-time completions: if exact position is unmapped, // try nearby mapped offsets on the same line and preserve relative cursor delta. function mapToShadowForTyping(fileName: string, position: number): number | null | undefined { - const mapped = mapToShadow(fileName, position); - if (mapped !== null) return mapped; - ensureCached(fileName); const doc = docCache.get(fileName); if (!doc) return undefined; - - const region = findRegionAtOriginalOffset(doc, position); - if (!region) return null; - if (position < region.pugTextStart || position > region.pugTextEnd) return null; - - const lineStart = doc.originalText.lastIndexOf('\n', position - 1) + 1; - const lineEndIdx = doc.originalText.indexOf('\n', position); - const lineEnd = lineEndIdx >= 0 ? lineEndIdx : doc.originalText.length; - const maxRadius = 3; - - for (let radius = 1; radius <= maxRadius; radius++) { - const left = position - radius; - if (left >= lineStart) { - const leftMapped = originalToShadow(doc, left); - if (leftMapped != null) { - if (left === position - 1) { - const ch = doc.originalText[position] ?? ''; - if (/\s|[),]/.test(ch)) { - return Math.min(leftMapped + 1, doc.shadowText.length); - } - } - return leftMapped; - } - } - - const right = position + radius; - if (right <= lineEnd) { - const rightMapped = originalToShadow(doc, right); - if (rightMapped != null) { - return rightMapped; - } - } - } - - return null; + return mapOriginalOffsetToNearbyShadowOnSameLine(doc, position); } // Helper: map completion result spans back from shadow -> original. @@ -372,13 +319,9 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { function mapTextSpanBack(fileName: string, textSpan: ts.TextSpan): ts.TextSpan { const doc = docCache.get(fileName); if (!doc) return textSpan; - const origStart = shadowToOriginal(doc, textSpan.start); - if (origStart == null) return textSpan; - const origEnd = shadowToOriginal(doc, textSpan.start + textSpan.length); - return { - start: origStart, - length: origEnd != null ? origEnd - origStart : textSpan.length, - }; + const mapped = mapShadowSpanToOriginal(doc, textSpan); + if (!mapped) return textSpan; + return { start: mapped.start, length: mapped.length }; } // Override: getDefinitionAtPosition @@ -569,64 +512,6 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { })); } - // Helper: map a requested original span to shadow span for classification queries. - // Returns null when a clean range mapping is not possible. - function mapQuerySpanToShadow(doc: PugDocument, span: ts.TextSpan): ts.TextSpan | null { - const shadowStart = originalToShadow(doc, span.start); - const shadowEnd = originalToShadow(doc, span.start + span.length); - if (shadowStart == null || shadowEnd == null || shadowEnd < shadowStart) { - return null; - } - return { start: shadowStart, length: shadowEnd - shadowStart }; - } - - // Helper: map encoded classifications (triples: start,length,class) back to original file. - function mapEncodedClassifications( - fileName: string, - requestedOriginalSpan: ts.TextSpan, - classifications: ts.Classifications, - ): ts.Classifications { - const doc = docCache.get(fileName); - if (!doc) return classifications; - - const originalStart = requestedOriginalSpan.start; - const originalEnd = requestedOriginalSpan.start + requestedOriginalSpan.length; - const maxOriginal = doc.originalText.length; - const mappedSpans: number[] = []; - const encoded = classifications.spans ?? []; - - for (let i = 0; i + 2 < encoded.length; i += 3) { - const shadowStart = encoded[i]; - const shadowLength = encoded[i + 1]; - const classification = encoded[i + 2]; - if (!Number.isFinite(shadowStart) || !Number.isFinite(shadowLength) || shadowLength <= 0) continue; - - const mappedStart = shadowToOriginal(doc, shadowStart); - const mappedEnd = shadowToOriginal(doc, shadowStart + shadowLength); - if (mappedStart == null || mappedEnd == null) continue; - - let start = mappedStart; - let end = mappedEnd; - if (end <= start) continue; - - if (end <= originalStart || start >= originalEnd) continue; - if (start < originalStart) start = originalStart; - if (end > originalEnd) end = originalEnd; - - if (start < 0) start = 0; - if (end > maxOriginal) end = maxOriginal; - const length = end - start; - if (length <= 0) continue; - - mappedSpans.push(start, length, classification); - } - - return { - spans: mappedSpans, - endOfLineState: classifications.endOfLineState, - }; - } - // Override: getApplicableRefactors safeOverride('getApplicableRefactors', (fileName, positionOrRange, ...rest) => { ensureCached(fileName); @@ -639,10 +524,16 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { if (mapped == null) return []; return ls.getApplicableRefactors(fileName, mapped, ...rest); } - const mappedPos = originalToShadow(doc, positionOrRange.pos); - const mappedEnd = originalToShadow(doc, positionOrRange.end); - if (mappedPos == null || mappedEnd == null) return []; - return ls.getApplicableRefactors(fileName, { pos: mappedPos, end: mappedEnd }, ...rest); + const mappedSpan = mapOriginalSpanToShadow(doc, { + start: positionOrRange.pos, + length: positionOrRange.end - positionOrRange.pos, + }); + if (!mappedSpan) return []; + return ls.getApplicableRefactors( + fileName, + { pos: mappedSpan.start, end: mappedSpan.end }, + ...rest, + ); }); // Override: getEditsForRefactor @@ -656,10 +547,12 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { if (mapped == null) return undefined; mappedRange = mapped; } else { - const mappedPos = originalToShadow(doc, positionOrRange.pos); - const mappedEnd = originalToShadow(doc, positionOrRange.end); - if (mappedPos == null || mappedEnd == null) return undefined; - mappedRange = { pos: mappedPos, end: mappedEnd }; + const mappedSpan = mapOriginalSpanToShadow(doc, { + start: positionOrRange.pos, + length: positionOrRange.end - positionOrRange.pos, + }); + if (!mappedSpan) return undefined; + mappedRange = { pos: mappedSpan.start, end: mappedSpan.end }; } } const result = ls.getEditsForRefactor(fileName, formatOptions, mappedRange, refactorName, actionName, preferences, interactiveRefactorArguments); @@ -684,11 +577,13 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { let mappedStart = start; let mappedEnd = end; if (doc) { - const ms = originalToShadow(doc, start); - const me = originalToShadow(doc, end); - if (ms == null || me == null) return []; - mappedStart = ms; - mappedEnd = me; + const mappedSpan = mapOriginalSpanToShadow(doc, { + start, + length: end - start, + }); + if (!mappedSpan) return []; + mappedStart = mappedSpan.start; + mappedEnd = mappedSpan.end; } const results = ls.getCodeFixesAtPosition(fileName, mappedStart, mappedEnd, errorCodes, formatOptions, preferences); return results.map(fix => ({ @@ -712,10 +607,10 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { const doc = docCache.get(fileName); if (!doc) return ls.getEncodedSyntacticClassifications(fileName, span); - const querySpan = mapQuerySpanToShadow(doc, span) - ?? { start: 0, length: doc.shadowText.length }; + const querySpan = mapOriginalSpanToShadow(doc, span) + ?? { start: 0, end: doc.shadowText.length, length: doc.shadowText.length }; const result = ls.getEncodedSyntacticClassifications(fileName, querySpan); - return mapEncodedClassifications(fileName, span, result); + return mapEncodedClassificationsToOriginal(doc, span, result); }); // Override: getEncodedSemanticClassifications @@ -724,10 +619,10 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { const doc = docCache.get(fileName); if (!doc) return ls.getEncodedSemanticClassifications(fileName, span, format); - const querySpan = mapQuerySpanToShadow(doc, span) - ?? { start: 0, length: doc.shadowText.length }; + const querySpan = mapOriginalSpanToShadow(doc, span) + ?? { start: 0, end: doc.shadowText.length, length: doc.shadowText.length }; const result = ls.getEncodedSemanticClassifications(fileName, querySpan, format); - return mapEncodedClassifications(fileName, span, result); + return mapEncodedClassificationsToOriginal(doc, span, result); }); // Diagnostic codes to suppress in pug regions (false positives from generated TSX) @@ -736,18 +631,11 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { 2503, // "Expression expected" -- from structural TSX brackets 1109, + // "This JSX tag requires the module path 'react/jsx-runtime' to exist" + // -- shadow TSX infrastructure requirement, not an original-source issue + 2875, ]); - // Helper: check if a shadow offset falls inside any pug region - function isInsidePugRegion(doc: PugDocument, shadowOffset: number): boolean { - for (const region of doc.regions) { - if (shadowOffset >= region.shadowStart && shadowOffset < region.shadowEnd) { - return true; - } - } - return false; - } - // Helper: map diagnostics from shadow -> original, filtering unmapped ones function mapDiagnostics(fileName: string, diagnostics: T[]): T[] { ensureCached(fileName); @@ -762,86 +650,50 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule { continue; } - // Suppress known false-positive codes inside pug regions - if (SUPPRESSED_DIAG_CODES.has(diag.code) && isInsidePugRegion(doc, diag.start)) { - continue; + const mappedRange = mapGeneratedRangeToOriginal(doc, diag.start, diag.length ?? 1); + if (!mappedRange) { + if (SUPPRESSED_DIAG_CODES.has(diag.code) && findRegionAtShadowOffset(doc, diag.start)) { + continue; + } + continue; // falls in synthetic/unmapped region -- filter out } - const origStart = shadowToOriginal(doc, diag.start); - if (origStart == null) continue; // falls in synthetic/unmapped region -- filter out + // Suppress known false-positive codes that ultimately map into pug regions. + if (SUPPRESSED_DIAG_CODES.has(diag.code) && findRegionAtOriginalOffset(doc, mappedRange.start)) { + continue; + } - const origEnd = diag.length != null ? shadowToOriginal(doc, diag.start + diag.length) : null; - // Ensure length is at least 1 for mapped diagnostics - const length = origEnd != null ? Math.max(1, origEnd - origStart) : diag.length; mapped.push({ ...diag, - start: origStart, - length, + start: mappedRange.start, + length: mappedRange.length, }); } // Add pug parse error diagnostics for regions with parseError (if enabled) if (!diagnosticsEnabled) return mapped; - if (doc.missingTagImport) { + for (const issue of collectPugDocumentIssues(doc)) { + const code = issue.kind === 'missing-tag-import' + ? 99002 + : issue.kind === 'parse-error' + ? 99001 + : 99003; + const messagePrefix = issue.kind === 'missing-tag-import' + ? '' + : issue.kind === 'parse-error' + ? 'Pug parse error: ' + : 'Pug transform error: '; + mapped.push({ file: undefined, - start: doc.missingTagImport.start, - length: doc.missingTagImport.length, - messageText: doc.missingTagImport.message, + start: issue.start, + length: issue.length, + messageText: `${messagePrefix}${issue.message}`, category: tsModule.DiagnosticCategory.Error, - code: 99002, + code, source: 'pug-react', } as unknown as T); } - for (const region of doc.regions) { - if (region.parseError) { - const err = region.parseError; - // Compute a meaningful error span length - let errorStart = regionOffsetToOriginalOffset(doc, region, err.offset); - let textAfterError = doc.originalText.slice(errorStart); - - // If error points at a newline, advance to the next non-empty line - if (textAfterError.startsWith('\n')) { - const nextLineStart = textAfterError.indexOf('\n') + 1; - const trimmedNext = textAfterError.slice(nextLineStart); - const indentLen = trimmedNext.match(/^\s*/)?.[0].length ?? 0; - errorStart += nextLineStart + indentLen; - textAfterError = doc.originalText.slice(errorStart); - } - - const nlIdx = textAfterError.indexOf('\n'); - const errorLength = Math.max(1, nlIdx >= 0 ? nlIdx : Math.min(textAfterError.length, 20)); - - mapped.push({ - file: undefined, - start: errorStart, - length: errorLength, - messageText: `Pug parse error: ${err.message}`, - category: tsModule.DiagnosticCategory.Error, - code: 99001, - source: 'pug-react', - } as unknown as T); - } - if (region.transformError) { - const err = region.transformError; - const errorStart = regionOffsetToOriginalOffset(doc, region, err.offset); - const textAfterError = doc.originalText.slice(errorStart); - const nlIdx = textAfterError.indexOf('\n'); - const errorLength = err.code === 'style-tag-must-be-last' - ? 'style'.length - : Math.max(1, nlIdx >= 0 ? nlIdx : Math.min(textAfterError.length, 20)); - - mapped.push({ - file: undefined, - start: errorStart, - length: errorLength, - messageText: `Pug transform error: ${err.message}`, - category: tsModule.DiagnosticCategory.Error, - code: 99003, - source: 'pug-react', - } as unknown as T); - } - } return mapped; } diff --git a/packages/typescript-plugin-react-pug/test/integration/classifications.test.ts b/packages/typescript-plugin-react-pug/test/integration/classifications.test.ts new file mode 100644 index 0000000..1fe4773 --- /dev/null +++ b/packages/typescript-plugin-react-pug/test/integration/classifications.test.ts @@ -0,0 +1,95 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as ts from 'typescript'; + +const FIXTURES_DIR = path.resolve(__dirname, '../fixtures/spike'); +const APP_FILE = path.join(FIXTURES_DIR, 'app.tsx'); +const BUTTON_FILE = path.join(FIXTURES_DIR, 'Button.tsx'); + +async function loadPlugin() { + const mod = await import('../../src/index.ts'); + return mod.default ?? mod; +} + +function createLanguageServiceWithPlugin(init: Function, rootFiles: string[], fixturesDir: string) { + const configPath = path.join(fixturesDir, 'tsconfig.json'); + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, fixturesDir); + + const host: ts.LanguageServiceHost = { + getScriptFileNames: () => rootFiles, + getScriptVersion: () => '0', + getScriptSnapshot: (fileName) => { + if (!fs.existsSync(fileName)) return undefined; + return ts.ScriptSnapshot.fromString(fs.readFileSync(fileName, 'utf-8')); + }, + getCurrentDirectory: () => fixturesDir, + getCompilationSettings: () => parsedConfig.options, + getDefaultLibFileName: ts.getDefaultLibFilePath, + fileExists: ts.sys.fileExists, + readFile: ts.sys.readFile, + readDirectory: ts.sys.readDirectory, + directoryExists: ts.sys.directoryExists, + getDirectories: ts.sys.getDirectories, + }; + + const ls = ts.createLanguageService(host, ts.createDocumentRegistry()); + const pluginModule = init({ typescript: ts }); + const pluginCreateInfo = { + languageServiceHost: host, + languageService: ls, + project: {} as any, + serverHost: {} as any, + config: {}, + }; + + return pluginModule.create(pluginCreateInfo); +} + +describe('encoded classifications through real pipeline', () => { + let ls: ts.LanguageService; + let appText: string; + + beforeAll(async () => { + const init = await loadPlugin(); + ls = createLanguageServiceWithPlugin(init, [APP_FILE, BUTTON_FILE], FIXTURES_DIR); + appText = fs.readFileSync(APP_FILE, 'utf-8'); + }); + + it('maps syntactic classifications back to original pug positions', () => { + const handlerIdx = appText.indexOf('handler', appText.indexOf('pug`')); + expect(handlerIdx).toBeGreaterThan(0); + + const result = ls.getEncodedSyntacticClassifications(APP_FILE, { + start: 0, + length: appText.length, + }); + + const spans = result.spans ?? []; + const hasOriginalHit = spans.some((_, i) => ( + i % 3 === 0 + && spans[i] <= handlerIdx + && handlerIdx < spans[i] + spans[i + 1] + )); + expect(hasOriginalHit).toBe(true); + }); + + it('maps semantic classifications back to original pug positions', () => { + const handlerIdx = appText.indexOf('handler', appText.indexOf('pug`')); + expect(handlerIdx).toBeGreaterThan(0); + + const result = ls.getEncodedSemanticClassifications(APP_FILE, { + start: 0, + length: appText.length, + }, ts.SemanticClassificationFormat.TwentyTwenty); + + const spans = result.spans ?? []; + const hasOriginalHit = spans.some((_, i) => ( + i % 3 === 0 + && spans[i] <= handlerIdx + && handlerIdx < spans[i] + spans[i + 1] + )); + expect(hasOriginalHit).toBe(true); + }); +}); diff --git a/packages/typescript-plugin-react-pug/test/integration/diagnostics.test.ts b/packages/typescript-plugin-react-pug/test/integration/diagnostics.test.ts index 36c962e..4031ec2 100644 --- a/packages/typescript-plugin-react-pug/test/integration/diagnostics.test.ts +++ b/packages/typescript-plugin-react-pug/test/integration/diagnostics.test.ts @@ -244,7 +244,7 @@ describe('suppressed diagnostic codes inside pug regions', () => { it('well-typed pug file produces zero semantic diagnostics', () => { const diags = ls.getSemanticDiagnostics(wellTypedFile); - // All diagnostics should be filtered: suppressed codes (2503, 1109) inside + // All diagnostics should be filtered: suppressed codes (2503, 1109, 2875) inside // pug regions get removed, and unmapped synthetic positions get filtered. expect(diags).toHaveLength(0); }); @@ -264,6 +264,12 @@ describe('suppressed diagnostic codes inside pug regions', () => { expect(expressionExpected).toHaveLength(0); }); + it('no diagnostics with code 2875 appear for pug files', () => { + const diags = ls.getSemanticDiagnostics(wellTypedFile); + const jsxRuntimeDiags = diags.filter(d => d.code === 2875); + expect(jsxRuntimeDiags).toHaveLength(0); + }); + it('suppressed codes outside pug regions are NOT filtered', async () => { const init = await loadPlugin(); diff --git a/packages/vscode-react-pug-tsx/src/index.ts b/packages/vscode-react-pug-tsx/src/index.ts index 94524ae..9dec503 100644 --- a/packages/vscode-react-pug-tsx/src/index.ts +++ b/packages/vscode-react-pug-tsx/src/index.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import type { StyleTagLang } from '../../react-pug-core/src/language/mapping'; import { extractPugAnalysis } from '../../react-pug-core/src/language/extractRegions'; import { compilePugToTsx } from '../../react-pug-core/src/language/pugToTsx'; +import { rawToStrippedOffset } from '../../react-pug-core/src/language/regionOffsetMapping'; import { buildShadowDocument } from '../../react-pug-core/src/language/shadowDocument'; import { hasTagFunctionCall } from '../../react-pug-core/src/language/tagFunctionPresence'; @@ -80,43 +81,6 @@ function resolveClassShorthandOptions( return { classAttribute, classMerge }; } -function rawToStrippedOffset(rawText: string, rawOffset: number, commonIndent: number): number | null { - if (commonIndent === 0) return rawOffset; - let stripped = 0; - let raw = 0; - const lines = rawText.split('\n'); - for (const line of lines) { - const lineEnd = raw + line.length; - if (rawOffset <= lineEnd) { - const colInRaw = rawOffset - raw; - const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; - if (indentToRemove > 0 && colInRaw < indentToRemove) return null; - return stripped + Math.max(0, colInRaw - indentToRemove); - } - const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; - stripped += Math.max(0, line.length - indentToRemove) + 1; - raw = lineEnd + 1; - } - return stripped; -} - -function strippedToRawOffset(rawText: string, strippedOffset: number, commonIndent: number): number { - if (commonIndent === 0) return strippedOffset; - let stripped = 0; - let raw = 0; - const lines = rawText.split('\n'); - for (const line of lines) { - const indentToRemove = line.trim().length === 0 ? line.length : commonIndent; - const strippedLineLen = Math.max(0, line.length - indentToRemove); - if (strippedOffset <= stripped + strippedLineLen) { - return raw + indentToRemove + (strippedOffset - stripped); - } - stripped += strippedLineLen + 1; - raw += line.length + 1; - } - return raw; -} - function languageIdForStyleLang(lang: StyleTagLang): 'css' | 'scss' | 'sass' | 'stylus' { switch (lang) { case 'scss': return 'scss'; diff --git a/packages/vscode-react-pug-tsx/syntaxes/pug-template-literal.json b/packages/vscode-react-pug-tsx/syntaxes/pug-template-literal.json index 1bb9fbc..da0b59b 100644 --- a/packages/vscode-react-pug-tsx/syntaxes/pug-template-literal.json +++ b/packages/vscode-react-pug-tsx/syntaxes/pug-template-literal.json @@ -1,6 +1,6 @@ { "scopeName": "inline.pug-template-literal", - "injectionSelector": "L:source.ts,L:source.tsx,L:source.js,L:source.jsx", + "injectionSelector": "L:source.ts -comment -string, L:source.tsx -comment -string, L:source.js -comment -string, L:source.jsx -comment -string", "patterns": [ { "include": "#pug-tagged-template" diff --git a/packages/vscode-react-pug-tsx/test/fixtures/grammars/source.js.tmLanguage.json b/packages/vscode-react-pug-tsx/test/fixtures/grammars/source.js.tmLanguage.json new file mode 100644 index 0000000..5206d49 --- /dev/null +++ b/packages/vscode-react-pug-tsx/test/fixtures/grammars/source.js.tmLanguage.json @@ -0,0 +1,35 @@ +{ + "scopeName": "source.js", + "patterns": [ + { + "include": "#line-comment" + }, + { + "include": "#double-quoted-string" + }, + { + "include": "#single-quoted-string" + }, + { + "match": "\\b(?:const|let|var|return)\\b", + "name": "keyword.control.js" + } + ], + "repository": { + "line-comment": { + "begin": "//", + "end": "$", + "name": "comment.line.double-slash.js" + }, + "double-quoted-string": { + "begin": "\"", + "end": "\"", + "name": "string.quoted.double.js" + }, + "single-quoted-string": { + "begin": "'", + "end": "'", + "name": "string.quoted.single.js" + } + } +} diff --git a/packages/vscode-react-pug-tsx/test/fixtures/grammars/source.pug.tmLanguage.json b/packages/vscode-react-pug-tsx/test/fixtures/grammars/source.pug.tmLanguage.json new file mode 100644 index 0000000..495027d --- /dev/null +++ b/packages/vscode-react-pug-tsx/test/fixtures/grammars/source.pug.tmLanguage.json @@ -0,0 +1,9 @@ +{ + "scopeName": "source.pug", + "patterns": [ + { + "match": "[A-Za-z_][\\w-]*", + "name": "entity.name.tag.pug" + } + ] +} diff --git a/packages/vscode-react-pug-tsx/test/unit/grammar.test.ts b/packages/vscode-react-pug-tsx/test/unit/grammar.test.ts index 78d1b61..fe2c061 100644 --- a/packages/vscode-react-pug-tsx/test/unit/grammar.test.ts +++ b/packages/vscode-react-pug-tsx/test/unit/grammar.test.ts @@ -63,6 +63,8 @@ describe('TextMate grammar file', () => { expect(selector).toContain('source.jsx'); // Should use L: prefix for left-injection expect(selector).toContain('L:'); + expect(selector).toContain('-comment'); + expect(selector).toContain('-string'); }); it('has patterns array referencing pug-tagged-template', () => { @@ -75,6 +77,100 @@ describe('TextMate grammar file', () => { }); }); +describe('tokenization regressions', () => { + async function createRegistry() { + const vsctm = require('vscode-textmate'); + const onig = require('vscode-oniguruma'); + const wasmBin = readFileSync(require.resolve('vscode-oniguruma/release/onig.wasm')).buffer; + const jsGrammarPath = resolve(extensionRoot, 'test/fixtures/grammars/source.js.tmLanguage.json'); + const pugGrammarPath = resolve(extensionRoot, 'test/fixtures/grammars/source.pug.tmLanguage.json'); + await onig.loadWASM(wasmBin); + + return new vsctm.Registry({ + onigLib: Promise.resolve({ + createOnigScanner(patterns: string[]) { + return new onig.OnigScanner(patterns); + }, + createOnigString(s: string) { + return new onig.OnigString(s); + }, + }), + loadGrammar(scopeName: string) { + const knownScopes: Record = { + 'source.js': jsGrammarPath, + 'source.jsx': jsGrammarPath, + 'source.ts': jsGrammarPath, + 'source.tsx': jsGrammarPath, + 'source.pug': pugGrammarPath, + 'inline.pug-template-literal': grammarPath, + }; + const target = knownScopes[scopeName]; + if (!target) return null; + return vsctm.parseRawGrammar(readFileSync(target, 'utf8'), target); + }, + getInjections(scopeName: string) { + if (['source.js', 'source.jsx', 'source.ts', 'source.tsx'].includes(scopeName)) { + return ['inline.pug-template-literal']; + } + return []; + }, + }); + } + + it('injects pug tokenization into a real tagged template', async () => { + const registry = await createRegistry(); + const grammar = await registry.loadGrammar('source.js'); + const line = "const view = pug`Div.card Hello`"; + const result = grammar.tokenizeLine(line); + const injectedTokens = result.tokens.filter((token: any) => ( + token.scopes.includes('meta.embedded.inline.pug') + )); + + expect(injectedTokens.length).toBeGreaterThan(0); + expect(injectedTokens.some((token: any) => ( + token.scopes.includes('entity.name.tag.pug') + ))).toBe(true); + }); + + it('does not inject pug tokenization into line comments', async () => { + const registry = await createRegistry(); + const grammar = await registry.loadGrammar('source.js'); + const line = "// const RowComponent = props => pug`Div(...props row)`"; + const result = grammar.tokenizeLine(line); + const injectedTokens = result.tokens.filter((token: any) => ( + token.scopes.includes('meta.embedded.inline.pug') + )); + + expect(injectedTokens).toEqual([]); + }); + + it('does not leak pug tokenization onto the next line after a commented pug template', async () => { + const registry = await createRegistry(); + const grammar = await registry.loadGrammar('source.js'); + const firstLine = "// const RowComponent = props => pug`Div(...props row)`"; + const secondLine = "const ALPHABET = 'abcdefghigklmnopqrstuvwxyz'"; + const firstResult = grammar.tokenizeLine(firstLine); + const secondResult = grammar.tokenizeLine(secondLine, firstResult.ruleStack); + const injectedTokens = secondResult.tokens.filter((token: any) => ( + token.scopes.includes('meta.embedded.inline.pug') + )); + + expect(injectedTokens).toEqual([]); + }); + + it('does not inject pug tokenization inside ordinary string literals', async () => { + const registry = await createRegistry(); + const grammar = await registry.loadGrammar('source.js'); + const line = "const x = 'pug`div hi`'"; + const result = grammar.tokenizeLine(line); + const injectedTokens = result.tokens.filter((token: any) => ( + token.scopes.includes('meta.embedded.inline.pug') + )); + + expect(injectedTokens).toEqual([]); + }); +}); + // ── Grammar repository rule tests ──────────────────────────────── describe('pug-tagged-template grammar rule', () => { diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..c4e2434 --- /dev/null +++ b/plan.md @@ -0,0 +1,590 @@ +# Core / ESLint / TS / VSCode Architecture Revamp Plan + +## Context +We found recurring false positives in real consumer repos (`../startupjs`, `../startupjs-ui`) even when the runtime transform was semantically correct: + +- `react/jsx-fragments` on Pug `Fragment` +- `no-unneeded-ternary` on Pug `if / else` +- `@stylistic/no-multi-spaces` mapped to `return pug\`` due to inline region formatting +- previous cases around `jsx-indent`, `Provider(value=true)`, legacy `styl\`...\`` warnings, and comment-triggered VS Code highlighting leakage + +These are not isolated fixture bugs. They expose a structural issue: + +- one transform pipeline is currently serving multiple consumers with different needs +- the ESLint plugin has grown extra logic to compensate for code shape problems after the core transform is already done +- too much of the correction currently happens in the ESLint plugin string-formatting layer instead of in a stable semantic layer + +## Problems Identified + +### 1. Runtime-correct JSX is not automatically lint-correct JSX +Examples: +- attrless `Fragment` lowers to `...`, which is runtime-correct but conflicts with `react/jsx-fragments` +- `if children ... else ...` lowers to a ternary, which is runtime-correct but can conflict with `no-unneeded-ternary` + +### 2. Semantic lowering and lint normalization are mixed into the ESLint plugin +Current plugin responsibilities include: +- source transform orchestration +- lint-only AST normalization +- prettier pass +- internal ESLint stylistic pass +- custom indentation normalization +- range remapping + +This makes the plugin too stateful and too responsible for semantics that should live closer to the core transform. + +### 3. Region rewriting is ad hoc +We currently need to rewrite transformed Pug regions for linting, but the pipeline for doing so is local to the ESLint plugin. +That means: +- harder reuse +- harder testing +- harder reasoning about mappings + +### 4. String-format corrections are too late in the pipeline +Some bugs were not semantic bugs at all; they came from splicing formatted regions back into surrounding code, especially when a Pug region starts inline (for example after `return `). + +## Architecture Direction + +### Goal +Split responsibilities more cleanly: + +- `react-pug-core` owns semantic/lint-specific normalization of Pug-generated JSX +- the ESLint plugin owns only: + - invoking the lint-oriented core transform path + - final stylistic shaping / formatter passes + - diagnostic/fix remapping + +### Explicit Boundary +This branch should enforce one clear rule: + +- if logic changes the semantic/code-shape of generated Pug JSX in a lint-aware but semantics-preserving way, it belongs in `react-pug-core` +- if logic only formats already-normalized JSX for stylistic convergence under ESLint, it stays in the ESLint plugin + +That means the plugin must stop owning: +- JSX fragment normalization +- conditional-expression simplification +- generic region-rewrite infrastructure + +And the core should not grow: +- prettier passes +- internal stylistic ESLint formatting +- rule-specific diagnostic suppression + +### New Core Concepts + +#### A. Lint normalization of transformed Pug regions +Add a core lint-normalization layer which works on already generated JSX expressions from Pug regions and performs **semantics-preserving, lint-oriented** normalization. + +Examples: +- attrless `Fragment` / `React.Fragment` -> fragment shorthand +- safe repeated ternaries like `x ? x : y` -> `x || y` + +Important constraint: +- only perform transformations proven safe and repeatable +- do not broaden rewrites just to silence rules + +#### B. Generic mapped-region rewriting helper +Add a core helper that can rewrite only Pug-mapped regions while preserving mapping back to the base transformed output. + +This helper should: +- iterate only mapped Pug regions +- allow rewriting region text +- generate boundary maps from rewritten region back to original transformed region +- expose mapping from rewritten offsets back to the base transform + +This is generic and reusable, not ESLint-specific. + +#### C. Lint-oriented transform result +Expose a core helper that produces a lint-oriented transform result from runtime output. + +This result should provide: +- `code` for lint consumers +- mapping from lint-oriented output back to original source +- access to the underlying base transform/document for downstream mapping + +This becomes the semantic input to the ESLint plugin. + +## Target State + +### Core +Add something conceptually like: +- `normalizePugExpressionForLint(...)` +- `rewriteMappedPugRegions(...)` +- `createLintTransform(...)` + +### ESLint plugin +The plugin should: +1. call core runtime transform +2. call core lint transform / lint normalization helper +3. run final formatting pass on the lint-oriented code +4. remap diagnostics from formatted -> lint transform -> original source + +The plugin should no longer own lint-specific AST normalization logic itself. + +## Constraints / Non-Goals + +- no broad suppressions for rule classes +- do not weaken diagnostics for real JS/TS code inside Pug +- do not relax existing correctness checks just to get green tests +- keep the old narrow, justified exceptions only where they are fundamentally synthetic (`styl\`...\`` wrapper statements, etc.) +- do not change Babel / TS / runtime semantics for non-lint consumers unless required and proven safe + +## Architectural Decision + +### We are not moving to a full-file Babel IR pipeline +We explicitly do **not** want to replace the current shadow/document pipeline with: + +- parse the whole file into Babel AST +- generate the whole transformed file from AST +- use Babel node locations as the primary metadata model + +That would not simplify the hardest part of this codebase. Our main problem is not syntax generation; it is: + +- precise original-to-generated mapping +- preserving untouched source outside Pug regions +- keeping stable copied/synthetic spans for TS, ESLint, and editor tooling +- carrying feature-level mapping metadata, not just AST node locations + +### Chosen model +The intended architecture is a **hybrid**: + +- keep the file/document pipeline text-and-mapping based +- keep explicit copied slices and synthetic insertions +- use Babel structurally where it gives us leverage: + - region-level lint normalization + - structural container/context classification + - future region-level formatting metadata when justified + +### Why this is the right tradeoff +This gives us: +- stable mapping fidelity +- minimal rewriting outside transformed Pug regions +- fewer whole-file formatting side effects +- structural reasoning where it actually reduces edge cases + +So the goal of this branch is **not** “move to Babel for everything”. +The goal is: +- keep the current mapping architecture +- keep moving structural decisions into explicit core contracts +- avoid whole-file AST reprint pipelines unless there is a compelling reason later + +## Implementation Tasks + +### Phase 1. Capture the architecture in code +1. Add a new core module for lint normalization of JSX expressions. +2. Add a new core module/helper for rewriting mapped Pug regions with preserved mapping to the base transform. +3. Export these helpers from `@react-pug/react-pug-core`. +4. Add the Babel generator/types dependencies to `react-pug-core`, since semantic lint normalization now lives there instead of in the ESLint plugin. + +### Phase 2. Move semantic lint fixes into core +5. Move the current `Fragment` normalization logic from the ESLint plugin into core. +6. Move the safe repeated-ternary normalization logic from the ESLint plugin into core. +7. Add focused unit tests in core for these transformations. +8. Add direct mapping assertions in core tests so we verify rewritten offsets still map to the correct original Pug spans. + +### Phase 3. Introduce a core lint transform path +9. Build a core helper which produces lint-oriented code plus mapping back to original source. +10. Add core tests which verify: + - rewritten code is as expected + - mapping survives region rewriting + - only Pug regions are rewritten + - non-Pug code on the same line or in the same file is not touched + +### Phase 4. Thin the ESLint plugin +11. Remove moved AST normalization logic from the ESLint plugin. +12. Update the plugin to consume the new core lint transform path. +13. Keep only formatter-specific logic in the plugin: + - prettier + - stylistic pass + - indentation/closing-bracket normalization + - remapping from formatted code back through the core lint transform +14. Remove now-duplicated parser/token/boundary-map utilities from the plugin where they are replaced by core utilities. + +### Phase 5. Strengthen tests +15. Expand existing ESLint integration tests with the new startupjs-ui repro fixtures. +16. Keep autofix tests for those files. +17. Keep diagnostics snapshot tests for those files. +18. Add core-level tests for lint normalization behavior so we do not rely only on plugin integration tests. +19. Tighten any tests that were previously only checking “no diagnostics” by also checking the specific false-positive rule classes where appropriate. +20. Keep real-project compiler snapshots updated if the lint-preprocess output changes for good reasons. + +### Phase 6. Real-project validation +21. Validate `npm test` in this repo. +22. Validate full ESLint in `../startupjs-ui` using the local file-based override. +23. Validate relevant ESLint behavior in `../startupjs` using the local file-based override. +24. Check that the VS Code grammar/highlighting fix still behaves correctly and does not regress during refactor. + +## Current Branch Intent + +The specific implementation target for this branch is: + +1. Land `packages/react-pug-core/src/language/lintTransform.ts` as the single source of truth for lint-oriented semantic rewrites. +2. Remove duplicated lint-normalization code from `packages/eslint-plugin-react-pug/src/index.ts`. +3. Keep the plugin-side formatting pipeline for now, but make it consume the core lint transform output instead of re-owning semantics. +4. Validate the result not only with repo tests but also with `../startupjs` and `../startupjs-ui`, because those repos are the most realistic signal for Pug-heavy consumer behavior. + +## Current Implementation Status + +### Landed in code +- `packages/react-pug-core/src/language/lintTransform.ts` exists and owns: + - expression boundary mapping + - lint-oriented semantic normalization + - mapped-region rewriting + - lint transform creation on top of the runtime transform +- `@react-pug/react-pug-core` exports the new lint-transform helpers. +- `@react-pug/react-pug-core` now owns the Babel generator/types dependencies required for lint normalization. +- `packages/eslint-plugin-react-pug/src/index.ts` now consumes the core lint transform instead of owning: + - fragment normalization + - repeated-ternary normalization + - local expression token alignment helpers +- core lint transform now also exposes structural `formattingContext` metadata per rewritten Pug region, and the ESLint plugin consumes that instead of inferring ternary/property placement from raw line-prefix regexes +- core now also owns the structural formatting wrapper contract: + +## TS Plugin / VS Code / Syntax Architecture Review + +### Why this matters +The TypeScript plugin and the VS Code extension are now the other major consumers of the same +shadow-document mapping model. They have not had the same cleanup pass yet. + +Current symptoms: +- `packages/typescript-plugin-react-pug/src/index.ts` mixes: + - generic original/shadow coordinate mapping + - TS-language-service-specific method overrides + - TS-result remapping + - synthetic-diagnostic filtering +- `packages/vscode-react-pug-tsx/src/index.ts` duplicates low-level raw/stripped offset helpers + just to support embedded style completions +- syntax highlighting is a separate TextMate grammar path with very little overlap with shadow + mapping logic, so we should be careful not to invent fake unification where there is none + +### Main architectural observation +The core already has the right backbone: +- `PugDocument` +- copied segments +- mapped regions +- synthetic insertions +- original/shadow offset conversion + +The missing piece is a stable **query/coordinate helper layer** on top of that model. + +Right now, multiple consumers reimplement variants of: +- raw-region offset <-> stripped-region offset +- original span -> shadow span +- shadow span -> original span +- encoded classification span remapping +- "nearby mapped position" fallback for editor typing + +These are not TS-specific. They are generic consequences of the shadow-document model and belong in +`react-pug-core`. + +### Architectural decision for TS/VSCode work +We are **not** doing a whole new editor architecture or a whole-file AST rewrite. + +We are keeping: +- the text-and-mapping shadow-document model +- the TS plugin as a TS-language-service adapter +- the VS Code extension as a thin consumer of core transforms and extension APIs +- the syntax-highlighting path as a separate TextMate grammar path + +We are changing: +- duplicated coordinate/query logic should move into core +- the TS plugin should get thinner by using generic core helpers +- the VS Code extension should stop owning raw/stripped offset math when core can provide it + +### Non-goals +- do not try to merge TextMate syntax highlighting with TS shadow mapping +- do not move TS-language-service result-shape code into core when it depends on TS types +- do not add broad suppressions in the TS plugin to paper over mapping issues +- do not relax existing diagnostics or navigation behavior to make refactors easier + +### High-confidence refactor targets + +#### 1. Shared region coordinate helpers in core +Create/export core helpers for: +- raw offset -> stripped offset +- stripped offset -> raw offset +- original file offset inside a Pug region -> stripped region offset +- stripped region offset -> original file offset + +Consumers: +- `positionMapping.ts` +- `pugToTsx.ts` +- TS plugin parse/transform diagnostic span mapping +- VS Code embedded style completion logic + +This is generic and immediately reduces duplicated edge-case handling. + +#### 2. Shared shadow query/span helpers in core +Create/export core helpers for: +- original span -> shadow span +- shadow span -> original span +- encoded classification triples remapped back to original +- optional nearby-on-same-line fallback for editor typing positions + +Consumers: +- TS plugin completion/hover/definition/reference/refactor/classification wrappers + +This should let the TS plugin stop reimplementing mapping rules method by method. + +#### 3. Thin TS plugin adapters +After the helpers exist, the TS plugin should mostly be: +- document/cache management +- LS override selection +- TS-result shape adaptation +- plugin-specific diagnostics injection/filtering + +The plugin should stop owning generic shadow mapping algorithms. + +#### 4. Keep syntax highlighting separate +The syntax grammar should only share: +- tag-function detection assumptions when practical +- regression tests for comment/string boundaries + +It should not be forced into the shadow-document architecture, because it is a separate TextMate +tokenization path. + +### TS/VSCode implementation tasks + +1. Add shared raw/stripped offset helpers to core and switch existing core code to them. +2. Add shared region offset helpers to core and switch TS plugin + VS Code style completion to them. +3. Add shared original/shadow span-query helpers to core. +4. Add shared encoded-classification remapping helper to core. +5. Add optional nearby-position fallback helper to core if its behavior can be expressed + generically without TS-specific leakage. +6. Refactor the TS plugin to consume these helpers and delete duplicated mapping code. +7. Strengthen tests around: + - completion cursor mapping + - hover span mapping + - classification span mapping + - parse/transform diagnostic locations + - embedded style completion cursor mapping +8. Validate with: + - `npm test` + - targeted TS/VSCode checks in this repo + - real consumer behavior in `../startupjs` and `../startupjs-ui` + +### Success criteria +- fewer custom offset/span helpers outside core +- TS plugin becomes smaller and more adapter-like +- VS Code embedded style features reuse core coordinate logic +- no loss of diagnostics/navigation accuracy +- no new rule suppressions or mapping relaxations + +### TS/VSCode current implementation status +- core now owns shared coordinate/query helpers: + - `regionOffsetMapping.ts` + - `queryMapping.ts` +- core code that previously duplicated raw/stripped conversion now uses those helpers: + - `positionMapping.ts` + - `pugToTsx.ts` +- the TS plugin now consumes shared core helpers for: + - original span -> shadow span + - shadow span -> original span + - encoded classification remapping + - nearby same-line typing fallback + - generated diagnostic range mapping + - core-owned document issues (`missingTagImport`, `parseError`, `transformError`) +- the VS Code extension now reuses the shared raw->stripped helper for embedded style completion context +- targeted regression tests were added for: + - shared query/span helpers + - classification remapping through the TS plugin + - core-owned document issue shaping + +### TS/VSCode current architectural assessment +This refactor pass materially improved the architecture: + +- generic shadow coordinate/query logic is now concentrated in core +- the TS plugin is more adapter-like and owns less mapping math +- core-owned document issues are no longer shaped ad hoc inside the plugin +- real consumer validation in `../startupjs` and `../startupjs-ui` is green for the targeted Pug-heavy files + +The remaining TS plugin complexity is mostly the correct kind of complexity: +- snapshot/cache management +- TS language-service override wiring +- TS result-shape adaptation +- narrow TS-specific false-positive suppression codes caused by generated shadow TSX + +At this point, more refactoring should be conservative. There is no obvious next extraction that is both: +- generic across consumers +- and simpler than leaving the logic in the TS plugin + +So the current recommendation is: +- stop the TS/VSCode refactor here unless a new real consumer bug shows a genuinely generic seam we missed +- keep validating against `../startupjs` and `../startupjs-ui` after any future mapping changes + - wrapper creation per region container kind + - formatted-expression extraction back out of a wrapper + - unit-tested indentation baseline semantics for wrapper extraction +- core now also owns the generic second-pass region rewrite helper used by the ESLint formatter layer + - the plugin no longer owns a custom “rewrite all rewritten Pug regions again” loop + - that remapping infrastructure is now reusable and tested in core +- `RegionFormattingContext` was simplified to only the structural field that still matters: + - `containerKind` + - the earlier `inlinePrefix` / `closingIndentOffset` metadata was removed because it no longer drove real formatting decisions +- startupjs / startupjs-ui real-world repros were copied into the fixture suite and now guard: + - fragment lowering + - repeated ternary lowering + - inline `return pug\`` formatting + - object-property `children: pug\`` formatting + - startupjs-ui prompt / mdx / wrapInput regressions + +### Still intentionally left in the ESLint plugin +- prettier pass +- internal stylistic formatting pass +- rebasing formatted region indentation back into surrounding source +- JSX closing-bracket normalization +- message/fix remapping +- narrow justified filtering for fundamentally synthetic legacy constructs only + +### Newly reduced in the ESLint plugin +- second-pass segmented-region rewrite plumbing +- custom formatted-copy / formatted-region segment bookkeeping +- custom formatted-offset back-mapping helper for that second pass + +Those now rely on the generic core segmented-region rewrite helper instead. + +### Removed during this refactor +- the earlier `Provider(value=true)` suppression was removed +- current startupjs-ui behavior now intentionally reports the two real `react/jsx-boolean-value` diagnostics in `mdxComponents` +- this is the correct outcome: we no longer suppress real rule results just because they were inconvenient in one consumer fixture + +### Remaining validation tasks +- latest validation after the generic second-pass rewrite helper move: + - full `npm test` passed + - targeted `../startupjs-ui` validation passed with local `file:` overrides for: + - `eslint-plugin-cssxjs` + - `@react-pug/react-pug-core` + - targeted `../startupjs` validation passed with the same local `file:` overrides +- continue to re-run these after any further formatter-layer refactors +- when validating `../startupjs-ui`, the expected remaining diagnostics in `mdxComponents` are still: + - the two real `react/jsx-boolean-value` errors + - the repo-local unused disable-directive warning +- when validating `../startupjs`, the targeted repro files are currently clean +- current remaining work is architectural cleanup, not “make the suite green” + +### Known non-goal for this branch +- removing the internal dependency on deprecated `@stylistic/jsx-indent` + - this remains a follow-up + - correctness and stable diagnostics matter more than removing that warning in this branch + +## Current Architectural Assessment + +### Direction check +The refactor direction is correct: + +- semantic, lint-aware code-shape rewrites now live in core +- the ESLint plugin is thinner and no longer owns semantic AST rewrites +- real consumer repos were used as validation targets instead of relying only on synthetic tests + +This is a genuine simplification. The branch is not moving in the wrong direction. + +### Main remaining weak point +The main remaining fragility is no longer semantic normalization. It is the final formatter-layer shaping in the ESLint plugin. + +The biggest earlier problem, formatting-context inference from line prefixes, has now been reduced materially: +- container-kind classification lives in core +- wrapper creation/extraction per container kind lives in core +- the plugin no longer guesses branch/property/return wrappers from raw text + +The remaining heuristic layer is now smaller and genuinely formatter-specific: +- region rebasing against surrounding source indentation +- `normalizeJsxClosingBracketIndent(...)` +- continued reliance on deprecated internal `@stylistic/jsx-indent` + +These are still string-level concerns, but they are now downstream of an explicit structural contract and generic rewrite infrastructure instead of mixed into semantic rewriting. + +### What this means +The branch already improved the architecture materially, but it is **not yet the fully generic end state**. + +The next real simplification target is now smaller: +- keep pushing more formatter decisions toward explicit structural metadata when that reduces real consumer edge cases +- avoid reintroducing line-prefix inference into the plugin + +## Next Generic Cleanup Target + +### Problem +Right now the plugin asks questions like: +- “is this region after `return `?” +- “is this line prefix actually a ternary branch or just an object property like `children:`?” +- “should the closing `)` be aligned with the branch or the property key?” + +Those are structural questions, but the plugin is answering them from line-prefix regex checks. + +That is exactly the class of logic that tends to create edge cases. + +### Better architecture +The better long-term design is: + +- core lint transform returns rewritten regions, offset maps, and structural formatting metadata +- core also owns the wrapper/envelope contract used to format those regions structurally +- the plugin formats that wrapper, extracts the region back out, and remaps diagnostics + +That means the plugin should make as few structural decisions as possible on its own. + +### Why this matters +If we do that: +- the prompt-style `children: pug\`` case stops being a regex special case +- ternary-branch indentation stops being a hand-maintained line normalizer +- future consumer repros are more likely to be covered by the model automatically + +This is the next place where genericity can improve meaningfully. + +## Additional Implementation Tasks + +### Phase 7. Reduce formatter heuristics +25. Audit the remaining plugin formatter helpers and classify them into: + - genuinely style-tool-specific repairs + - still-avoidable string-splice repairs +26. Keep expanding the core structural formatting contract only when it removes real consumer edge cases cleanly. +27. Add or keep fixture coverage for each formatting context shape we support: + - `return pug\`...\`` + - object property `children: pug\`...\`,` + - ternary branch + - arrow body + - call argument + - logical branch if we encounter one in real code +28. Only keep plugin-side formatter post-processing that is genuinely style-tool-specific, not context inference. +29. Reassess whether `normalizeJsxClosingBracketIndent(...)` and the region rebasing helper can be reduced further once enough structural context is exposed. +30. Keep any additional rewrite-stage mapping/bookkeeping out of the plugin unless it is impossible to express generically in core. + +### Phase 8. Keep the hybrid model explicit +31. Keep core/plugin boundaries aligned with the hybrid text+mapping model, not a full-file Babel IR model. +32. When introducing new structural metadata, prefer: + - core contracts + - per-region analysis + over: + - whole-file regeneration + - AST-location-only mapping schemes +33. Only revisit a broader Babel-backed generation layer if a concrete unsolved problem shows that the current hybrid model cannot support it cleanly. + +### Phase 9. Re-evaluate branch completeness +34. After reducing formatter heuristics, re-run: + - repo `npm test` + - `../startupjs` targeted lint checks + - `../startupjs-ui` targeted lint checks +35. For TS/VSCode mapping changes, re-run: + - repo `npm test` + - targeted TS/classification checks in this repo + - targeted shadow-plugin checks in `../startupjs` + - targeted shadow-plugin checks in `../startupjs-ui` +35. Reassess whether any remaining suppressions are still principled and synthetic-only. +36. Only then consider the architecture revamp “complete”. + +## Success Criteria + +We are done when all of the following are true: +- `npm test` passes in this repo +- no broad suppressions were added for the new false-positive classes +- startupjs-ui files previously failing now lint correctly: + - `packages/input/wrapInput.tsx` + - `packages/mdx/client/mdxComponents/index.js` + - fragment / ternary / inline-return repro files +- startupjs targeted regressions remain fixed +- lint-only semantic normalization logic lives in core, not in the ESLint plugin +- the ESLint plugin becomes simpler, not more ad hoc +- the remaining formatter behavior is driven mostly by explicit context, not line-prefix heuristics +- the architecture remains intentionally hybrid: text/mapping backbone plus region-level structural analysis + +## Open Follow-Ups (not mandatory for this branch) + +- consider whether the formatter layer can later stop depending on deprecated `@stylistic/jsx-indent` +- consider whether some formatting-specific utilities should also move into a reusable core utility layer +- consider richer origin metadata for synthetic wrappers so future mapping/filtering decisions can be principled without rule-specific handling diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDialogsReadme.js.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDialogsReadme.js.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDialogsReadme.js.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDraggableReadme.js.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDraggableReadme.js.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiDraggableReadme.js.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt new file mode 100644 index 0000000..c99b293 --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiMdxComponents.js.txt @@ -0,0 +1,2 @@ +205:34 error react/jsx-boolean-value Value must be omitted for boolean attribute `value` +257:27 error react/jsx-boolean-value Value must be omitted for boolean attribute `value` diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiPrompt.tsx.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiPrompt.tsx.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiPrompt.tsx.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiTypeCell.js.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiTypeCell.js.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiTypeCell.js.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiWrapInput.tsx.txt b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiWrapInput.tsx.txt new file mode 100644 index 0000000..69e487c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/diagnostics/src/StartupjsUiWrapInput.tsx.txt @@ -0,0 +1 @@ +No diagnostics. diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDialogsReadme.js b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDialogsReadme.js new file mode 100644 index 0000000..0a118cd --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDialogsReadme.js @@ -0,0 +1,16 @@ +import { Fragment } from 'react' +import { pug } from 'startupjs' + +const Button = 'Button' +const Br = 'Br' + +export function DialogsProviderSandbox ({ onPressAlert, onPressConfirm, onPressPrompt }) { + return pug` + Fragment + Button(onPress=onPressAlert) Show alert + Br + Button(onPress=onPressConfirm) Show confirm + Br + Button(onPress=onPressPrompt) Show prompt + ` +} diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDraggableReadme.js b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDraggableReadme.js new file mode 100644 index 0000000..fbda2f3 --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiDraggableReadme.js @@ -0,0 +1,18 @@ +import { pug } from 'startupjs' + +const DragDropProvider = 'DragDropProvider' +const Droppable = 'Droppable' +const Draggable = 'Draggable' +const Span = 'Span' + +export function DragDropProviderSandbox ({ children, ...props }) { + return pug` + DragDropProvider(...props) + if children + = children + else + Droppable(dropId='sandbox-drop') + Draggable(dragId='sandbox-drag') + Span Drag me + ` +} diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js new file mode 100644 index 0000000..ad0f4c4 --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiMdxComponents.js @@ -0,0 +1,320 @@ +import React, { useState, useContext } from 'react' +import { Image, ScrollView, Platform, Text } from 'react-native' +import { pug, observer, $, BASE_URL } from 'startupjs' +import Alert from '@startupjs-ui/alert' +import Div from '@startupjs-ui/div' +import Span from '@startupjs-ui/span' +import Divider from '@startupjs-ui/divider' +import Br from '@startupjs-ui/br' +import Link from '@startupjs-ui/link' +import Icon from '@startupjs-ui/icon' +import { Table, Tbody, Td, Th, Thead, Tr } from '@startupjs-ui/table' +import Collapse from '@startupjs-ui/collapse' +import { setStringAsync } from 'expo-clipboard' +// import { Anchor, scrollTo } from '@startupjs/scrollable-anchors' +// import { faLink } from '@fortawesome/free-solid-svg-icons/faLink' +import { faCode } from '@fortawesome/free-solid-svg-icons/faCode' +import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy' +// import _kebabCase from 'lodash/kebabCase' +// import _get from 'lodash/get' +import './index.cssx.styl' +import Code from '../Code' + +// const RowComponent = props => pug`Div(...props row)` +const ALPHABET = 'abcdefghigklmnopqrstuvwxyz' +const ListLevelContext = React.createContext() +const BlockquoteContext = React.createContext() +const PreContext = React.createContext() + +function getOrderedListMark (index, level) { + switch (level) { + case 1: + return ALPHABET.charAt(index % ALPHABET.length) + ')' + default: + return '' + (index + 1) + '.' + } +} + +function P (props) { + return pug` + Span.p(style=props.style ...props) + ` +} + +// function getTextChildren (children) { +// const nestedChildren = _get(children, 'props.children') +// if (nestedChildren) { +// return getTextChildren(nestedChildren) +// } + +// return children +// } + +// function MDXAnchor ({ +// children, +// style, +// anchor, +// size +// }) { +// const [hover, setHover] = useState() +// const anchorKebab = _kebabCase(anchor) + +// return pug` +// Anchor.anchor( +// style=style +// id=anchorKebab +// Component=RowComponent +// vAlign='center' +// onMouseEnter=() => setHover(true) +// onMouseLeave=() => setHover() +// ) +// = children +// Link.anchor-link( +// styleName={ hover } +// to='#' + anchorKebab +// ) +// Icon(icon=faLink size=size) +// ` +// } + +export default { + wrapper: ({ children }) => pug` + Div= children + `, + // TODO: MDXAnchor(anchor=getTextChildren(children) size='xl') + h1: ({ children }) => pug` + Span(h2 bold) + = children + `, + // TODO: MDXAnchor.h2(anchor=getTextChildren(children)) + h2: ({ children }) => pug` + Span.h2.h2-text(h5)= children + Div.divider + `, + // TODO: MDXAnchor.h3(anchor=getTextChildren(children) size='s') + h3: ({ children }) => pug` + Span.h3.h3-text(h6 bold)= children + `, + h4: P, + h5: P, + h6: P, + p: ({ children }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const inBlockquote = useContext(BlockquoteContext) + + if (inBlockquote) { + return pug` + Span.blockquoteP= children + ` + } + return pug` + P= children + ` + }, + strong: ({ children }) => pug` + Text(style={ fontWeight: 'bold' })= children + `, + em: ({ children }) => pug` + Text(style={ fontStyle: 'italic' })= children + `, + hr: ({ children }) => pug` + Divider(size='l') + `, + center: ({ children }) => { + return pug` + P.center= children + ` + }, + br: Br, + thematicBreak: P, + table: ({ children }) => { + return pug` + Table(style={ marginTop: 16 })= children + ` + }, + thead: Thead, + tbody: Tbody, + tr: Tr, + td: Td, + th: Th, + delete: P, + a: ({ children, href }) => { + // function onPress (event) { + // const { url, hash } = $root.get('$render') + // const [_url, _hash] = href.split('#') + // if (url === _url && hash === `#${_hash}`) { + // event.preventDefault() + // // scrollTo({ anchorId: _hash }) + // } + // } + + // TODO: handle Anchor click with onPress + return pug` + Link.link( + to=href + size='l' + color='primary' + )= children + ` + }, + ul: ({ children }) => children, + ol: ({ children }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const currentLevel = useContext(ListLevelContext) + const nextLevel = currentLevel == null ? 0 : currentLevel + 1 + const filteredChildren = React.Children + .toArray(children) + .filter(child => child !== '\n') + .map((child, index) => React.cloneElement(child, { index })) + return pug` + ListLevelContext.Provider(value=nextLevel) + Div + = filteredChildren + ` + }, + li: ({ children, index }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const level = useContext(ListLevelContext) + const listIndex = index == null ? '•' : getOrderedListMark(index, level) + let hasTextChild = false + children = React.Children + .toArray(children) + .filter(child => child !== '\n') + .map(child => { + if (typeof child === 'string') { + hasTextChild = true + } + return child + }) + return pug` + Div(row) + Span.listIndex= listIndex + Div.listContent + if hasTextChild + P(size='l')= children + else + = children + ` + }, + blockquote: ({ children }) => { + const filteredChildren = React.Children + .toArray(children) + .filter(child => child !== '\n') + + return pug` + BlockquoteContext.Provider(value=true) + Alert.alert= filteredChildren + ` + }, + img: ({ src }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [style, setStyle] = useState({}) + const isUrl = /^(http|https):\/\//.test(src) + const isLocalUrl = /^\//.test(src) + + if (isLocalUrl) { + src = BASE_URL + src + } else if (!isUrl) { + console.warn('[@startupjs/mdx] Need to provide the url for the image') + return null + } + + function onLayout (e) { + const maxWidth = e.nativeEvent.layout.width + Image.getSize(src, (width, height) => { + const coefficient = maxWidth / width + setStyle({ + width: Math.min(width, maxWidth), + height: coefficient < 1 ? Math.ceil(height * coefficient) : height + }) + }, + error => console.warn(`[@startupjs/mdx], ${error}`) + ) + } + return pug` + Div.img(onLayout=onLayout) + Image(style=style source={ uri: src }) + ` + }, + section: ({ children, noscroll }) => { + const Wrapper = noscroll + ? ({ children }) => pug` + Div.example.padding= children + ` + : ({ children }) => pug` + ScrollView.example( + contentContainerStyleName=['exampleContent', 'padding'] + horizontal + )= children + ` + + return pug` + Wrapper= children + ` + }, + pre: ({ children }) => { + return pug` + PreContext.Provider(value=true) + = children + ` + }, + code: observer(({ children, className, ...props }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const isBlockCode = useContext(PreContext) + + if (!isBlockCode) { + return pug` + Span.inlineCodeWrapper + Span.inlineCodeSpacer   + Span.inlineCode(style={ + fontFamily: Platform.OS === 'ios' ? 'Menlo-Regular' : 'monospace' + })= children + Span.inlineCodeSpacer   + ` + } + + const language = (className || 'language-txt').replace(/language-/, '') + const [open, setOpen] = useState(false) + const $copyText = $('Copy code') + + async function copyHandler () { + await setStringAsync(children) + $copyText.set('Copied') + } + + function onMouseEnter () { + // we need to reutrn default text if it was copied + $copyText.set('Copy code') + } + + let example + + if (typeof children === 'string' && children.includes('[HACK EXAMPLE CODE]')) { + children = children.replace('[HACK EXAMPLE CODE]', '') + example = true + } + + return pug` + Div.code(styleName={ 'code-example': example }) + if example + Collapse.code-collapse(open=open variant='pure') + Collapse.Header.code-collapse-header(icon=false onPress=null) + Div.code-actions(align='right' row) + Div.code-action( + tooltip=open ? 'Hide code' : 'Show code' + onPress=()=> setOpen(!open) + ) + Icon.code-action-collapse(icon=faCode color='error') + Div.code-action( + tooltip=$copyText.get() + onPress=copyHandler + onMouseEnter=onMouseEnter + ) + Icon.code-action-copy(icon=faCopy) + Collapse.Content.code-collapse-content + Code(language=language)= children + else + Code(language=language)= children + ` + }) +} diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiPrompt.tsx b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiPrompt.tsx new file mode 100644 index 0000000..746343c --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiPrompt.tsx @@ -0,0 +1,73 @@ +import { type ReactNode } from 'react' +import { pug, $, observer } from 'startupjs' +import TextInput from '@startupjs-ui/text-input' +import Br from '@startupjs-ui/br' +import Span from '@startupjs-ui/span' +import { openDialog } from './helpers' + +export const _PropsJsonSchema = {/* PromptOptions */} + +export interface PromptOptions { + /** An optional dialog title displayed above the message */ + title?: string + /** The message displayed above the input field */ + message: string + /** An optional initial value for the input field */ + defaultValue?: string +} + +export default async function prompt (message: string, defaultValue?: string): Promise +export default async function prompt (options: PromptOptions, defaultValue?: string): Promise +export default async function prompt (options: string | PromptOptions, defaultValue?: string): Promise { + let title: unknown + let message: unknown + + if (typeof options === 'string') { + message = options + } else if (options && typeof options === 'object') { + title = (options as any).title + message = (options as any).message + defaultValue = defaultValue ?? (options as any).defaultValue + } + + if (title != null && typeof title !== 'string') { + throw new Error('[@startupjs-ui/dialogs] prompt: title should be a string') + } + + if (typeof message !== 'string') { + throw new Error('[@startupjs-ui/dialogs] prompt: message should be a string') + } + + const normalizedTitle = typeof title === 'string' ? title : undefined + + const result = await new Promise(resolve => { + const $prompt = $(defaultValue) + + openDialog({ + title: normalizedTitle, + children: pug` + Span= message + Br(half) + TextInputWrapper($prompt=$prompt) + `, + showCross: false, + enableBackdropPress: false, + cancelLabel: 'Cancel', + confirmLabel: 'OK', + onCancel: () => { resolve(null) }, + onConfirm: () => { resolve(($prompt.get() as string | undefined) ?? '') } + }) + }) + + return result +} + +// We need to make an observable wrapper so that in auto-rerenders when signal changes +const TextInputWrapper = observer(({ $prompt }: { $prompt: any }): ReactNode => { + return pug` + TextInput( + value=$prompt.get() + onChangeText=text => $prompt.set(text) + ) + ` +}) diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiTypeCell.js b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiTypeCell.js new file mode 100644 index 0000000..bd69f11 --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiTypeCell.js @@ -0,0 +1,27 @@ +import { pug } from 'startupjs' + +const Button = 'Button' +const Span = 'Span' +const MAX_ITEMS = 10 + +export function TypeCell ({ possibleValues, toggleList, collapsed, type }) { + const values = possibleValues || [] + + function renderButton () { + if (possibleValues?.length <= MAX_ITEMS) return null + return pug` + Span    + Button(onPress=toggleList)= collapsed ? 'More...' : 'Less' + ` + } + + return pug` + if type === 'oneOf' + Span + each value, index in values + Span(key=index)= value + = renderButton() + else + Span= type + ` +} diff --git a/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiWrapInput.tsx b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiWrapInput.tsx new file mode 100644 index 0000000..a2908cc --- /dev/null +++ b/test/fixtures/example-unformatted/snapshots/fixed/src/StartupjsUiWrapInput.tsx @@ -0,0 +1,360 @@ +import { useEffect, useState, type ReactNode, type RefObject } from 'react' +import { Platform, Text } from 'react-native' +import { pug, styl, observer } from 'startupjs' +import { themed } from '@startupjs-ui/core' +import Div from '@startupjs-ui/div' +import Icon from '@startupjs-ui/icon' +import Span from '@startupjs-ui/span' +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons/faExclamationCircle' +import merge from 'lodash/merge' +import getInputTestId from './helpers/getInputTestId' +import useLayout from './useLayout' + +export const IS_WRAPPED = Symbol('wrapped into wrapInput()') +const IS_WEB = Platform.OS === 'web' + +export type InputLayout = 'pure' | 'rows' | 'columns' + +export interface InputWrapperLayoutConfiguration { + labelPosition?: 'top' | 'right' + descriptionPosition?: 'top' | 'bottom' + _renderWrapper?: any + [key: string]: any +} + +export interface InputWrapperConfiguration extends InputWrapperLayoutConfiguration { + rows?: InputWrapperLayoutConfiguration + columns?: InputWrapperLayoutConfiguration + isLabelColoredWhenFocusing?: boolean + isLabelClickable?: boolean + _webLabelMode?: 'aria' | 'native' +} + +export interface InputWrapperProps { + label?: string + description?: string + layout?: InputLayout + configuration?: InputWrapperConfiguration + error?: string | string[] + required?: boolean | object + disabled?: boolean + readonly?: boolean + onFocus?: (...args: any[]) => void + onBlur?: (...args: any[]) => void + _onLabelPress?: () => void + ref?: RefObject + style?: any + [key: string]: any +} + +export function isWrapped (Component: any): boolean { + return Component[IS_WRAPPED] +} + +export default function wrapInput (Component: any, configuration: InputWrapperConfiguration = {}): any { + const defaultConfiguration = merge( + { + rows: { + labelPosition: 'top', + descriptionPosition: 'top' + }, + isLabelColoredWhenFocusing: false, + isLabelClickable: false + }, + configuration + ) + + function InputWrapper ({ + label, + description, + layout, + configuration: componentConfiguration, + error, + onFocus, + required, + onBlur, + _onLabelPress, + ref, + ...props + }: InputWrapperProps): ReactNode { + const currentLayout = useLayout({ + layout, + label, + description + }) + + const mergedConfiguration = merge({}, defaultConfiguration, componentConfiguration) + const resolvedConfiguration = merge({}, mergedConfiguration, mergedConfiguration[currentLayout]) + + const { + labelPosition, + descriptionPosition, + isLabelColoredWhenFocusing, + isLabelClickable, + _webLabelMode = 'aria' + } = resolvedConfiguration + + const [focused, setFocused] = useState(false) + const isReadOnlyOrDisabled = [props.readonly, props.disabled].some(Boolean) + + function handleFocus (...args: any[]) { + setFocused(true) + onFocus && onFocus(...args) + } + + function handleBlur (...args: any[]) { + setFocused(false) + onBlur && onBlur(...args) + } + + // NOTE + useEffect(() => { + if (!isLabelColoredWhenFocusing) return + if (focused && isReadOnlyOrDisabled) setFocused(false) + }, [focused, isLabelColoredWhenFocusing, isReadOnlyOrDisabled]) + + const hasError = Array.isArray(error) ? error.length > 0 : !!error + const generatedTestID = props.testID ?? getInputTestId({ + ...props, + label, + description, + testId: props.testID + }) + const semanticBaseId = typeof generatedTestID === 'string' && generatedTestID !== '' + ? generatedTestID + : undefined + const inputId = semanticBaseId ? `${semanticBaseId}-input` : undefined + const labelId = label && semanticBaseId ? `${semanticBaseId}-label` : undefined + const descriptionId = description && semanticBaseId ? `${semanticBaseId}-description` : undefined + const errorId = hasError && semanticBaseId ? `${semanticBaseId}-error` : undefined + const useNativeWebLabel = IS_WEB && _webLabelMode === 'native' && !!inputId + + const labelStyleName = [ + currentLayout, + currentLayout + '-' + labelPosition, + { + focused: isLabelColoredWhenFocusing ? focused : false, + error: hasError + } + ] + const requiredAsterisk = required === true + ? pug` + Text.required(aria-hidden=IS_WEB ? true : undefined)= ' *' + ` + : null + const WebLabelElement = 'label' + const _label = label + ? useNativeWebLabel + ? pug` + WebLabelElement.label( + key='label' + id=labelId + htmlFor=inputId + part='label' + styleName=labelStyleName + ) + = label + = requiredAsterisk + ` + : pug` + Span.label( + key='label' + id=labelId + part='label' + styleName=labelStyleName + onPress=isLabelClickable + ? _onLabelPress + : undefined + ) + = label + = requiredAsterisk + ` + : null + const _description = pug` + if description + Span.description( + key='description' + id=descriptionId + part='description' + styleName=[ + currentLayout, + descriptionPosition, + currentLayout + '-' + descriptionPosition + ] + description + )= description + ` + + const passRef = ref ? { ref } : {} + const inputAccessibilityProps: Record = {} + const describedBy = [descriptionId].filter(Boolean).join(' ') || undefined + + if (props['aria-label'] == null) { + if (props.accessibilityLabel != null) inputAccessibilityProps['aria-label'] = props.accessibilityLabel + else if (label) inputAccessibilityProps['aria-label'] = label + } + + if (inputId) { + inputAccessibilityProps.id = inputId + inputAccessibilityProps.nativeID = inputId + } + if (required === true) inputAccessibilityProps['aria-required'] = true + if (labelId) inputAccessibilityProps['aria-labelledby'] = labelId + if (describedBy) inputAccessibilityProps['aria-describedby'] = describedBy + if (hasError && errorId) { + inputAccessibilityProps['aria-errormessage'] = errorId + inputAccessibilityProps['aria-invalid'] = true + } + + const input = pug` + Component( + key='input' + part='wrapper' + layout=currentLayout + _hasError=hasError + onFocus=handleFocus + onBlur=handleBlur + ...inputAccessibilityProps + ...passRef + ...props + ) + ` + const err = pug` + if hasError + Div.errorContainer( + key='error' + id=errorId + styleName=[ + currentLayout, + currentLayout + '-' + descriptionPosition, + ] + vAlign='center' + row + ) + Icon.errorContainer-icon(icon=faExclamationCircle) + Span.errorContainer-text + each _error, index in (Array.isArray(error) ? error : [error]) + if index + Text= ' ' + = _error + ` + + return pug` + Div.root( + part='root' + styleName=[currentLayout] + ) + if currentLayout === 'rows' + if labelPosition === 'top' + = _label + if descriptionPosition === 'top' + = _description + = err + if labelPosition === 'right' + Div(vAlign='center' row) + = input + = _label + else + = input + if descriptionPosition === 'bottom' + = err + = _description + else if currentLayout === 'columns' + Div.leftBlock + = _label + = _description + Div.rightBlock + = input + = err + else if currentLayout === 'pure' + = input + = err + ` + } + + const componentDisplayName = Component.displayName ?? Component.name + + InputWrapper.displayName = componentDisplayName + 'InputWrapper' + + const ObservedInputWrapper = observer( + themed('InputWrapper', InputWrapper) + ) as any + + ObservedInputWrapper[IS_WRAPPED] = true + + return ObservedInputWrapper +} + +styl` + $errorColor = var(--color-text-error) + $focusedColor = var(--color-text-primary) + + // common + .label + color var(--InputWrapper-label-color) + align-self flex-start + font(body2) + + &.focused + color $focusedColor + + &.error + color $errorColor + + .description + font(caption) + + .required + color $errorColor + font-weight bold + + .errorContainer + margin-top 1u + margin-bottom 0.5u + + &-icon + color $errorColor + + &-text + font(caption) + margin-left 0.5u + color $errorColor + + // rows + .rows + &-top + .label& + margin-bottom 0.5u + + .description& + margin-bottom 1u + + .errorContainer& + margin-top 0 + margin-bottom 1u + + &-right + .label& + margin-left 1u + + &-bottom + .description& + margin-top 0.5u + + // columns + .leftBlock + .rightBlock + flex 1 + + .leftBlock + margin-right 1.5u + + .rightBlock + margin-left 1.5u + + .columns + .root& + flex-direction row + align-items center + +` diff --git a/test/fixtures/example-unformatted/src/StartupjsUiDialogsReadme.js b/test/fixtures/example-unformatted/src/StartupjsUiDialogsReadme.js new file mode 100644 index 0000000..0a118cd --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiDialogsReadme.js @@ -0,0 +1,16 @@ +import { Fragment } from 'react' +import { pug } from 'startupjs' + +const Button = 'Button' +const Br = 'Br' + +export function DialogsProviderSandbox ({ onPressAlert, onPressConfirm, onPressPrompt }) { + return pug` + Fragment + Button(onPress=onPressAlert) Show alert + Br + Button(onPress=onPressConfirm) Show confirm + Br + Button(onPress=onPressPrompt) Show prompt + ` +} diff --git a/test/fixtures/example-unformatted/src/StartupjsUiDraggableReadme.js b/test/fixtures/example-unformatted/src/StartupjsUiDraggableReadme.js new file mode 100644 index 0000000..fbda2f3 --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiDraggableReadme.js @@ -0,0 +1,18 @@ +import { pug } from 'startupjs' + +const DragDropProvider = 'DragDropProvider' +const Droppable = 'Droppable' +const Draggable = 'Draggable' +const Span = 'Span' + +export function DragDropProviderSandbox ({ children, ...props }) { + return pug` + DragDropProvider(...props) + if children + = children + else + Droppable(dropId='sandbox-drop') + Draggable(dragId='sandbox-drag') + Span Drag me + ` +} diff --git a/test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js b/test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js new file mode 100644 index 0000000..ad0f4c4 --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiMdxComponents.js @@ -0,0 +1,320 @@ +import React, { useState, useContext } from 'react' +import { Image, ScrollView, Platform, Text } from 'react-native' +import { pug, observer, $, BASE_URL } from 'startupjs' +import Alert from '@startupjs-ui/alert' +import Div from '@startupjs-ui/div' +import Span from '@startupjs-ui/span' +import Divider from '@startupjs-ui/divider' +import Br from '@startupjs-ui/br' +import Link from '@startupjs-ui/link' +import Icon from '@startupjs-ui/icon' +import { Table, Tbody, Td, Th, Thead, Tr } from '@startupjs-ui/table' +import Collapse from '@startupjs-ui/collapse' +import { setStringAsync } from 'expo-clipboard' +// import { Anchor, scrollTo } from '@startupjs/scrollable-anchors' +// import { faLink } from '@fortawesome/free-solid-svg-icons/faLink' +import { faCode } from '@fortawesome/free-solid-svg-icons/faCode' +import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy' +// import _kebabCase from 'lodash/kebabCase' +// import _get from 'lodash/get' +import './index.cssx.styl' +import Code from '../Code' + +// const RowComponent = props => pug`Div(...props row)` +const ALPHABET = 'abcdefghigklmnopqrstuvwxyz' +const ListLevelContext = React.createContext() +const BlockquoteContext = React.createContext() +const PreContext = React.createContext() + +function getOrderedListMark (index, level) { + switch (level) { + case 1: + return ALPHABET.charAt(index % ALPHABET.length) + ')' + default: + return '' + (index + 1) + '.' + } +} + +function P (props) { + return pug` + Span.p(style=props.style ...props) + ` +} + +// function getTextChildren (children) { +// const nestedChildren = _get(children, 'props.children') +// if (nestedChildren) { +// return getTextChildren(nestedChildren) +// } + +// return children +// } + +// function MDXAnchor ({ +// children, +// style, +// anchor, +// size +// }) { +// const [hover, setHover] = useState() +// const anchorKebab = _kebabCase(anchor) + +// return pug` +// Anchor.anchor( +// style=style +// id=anchorKebab +// Component=RowComponent +// vAlign='center' +// onMouseEnter=() => setHover(true) +// onMouseLeave=() => setHover() +// ) +// = children +// Link.anchor-link( +// styleName={ hover } +// to='#' + anchorKebab +// ) +// Icon(icon=faLink size=size) +// ` +// } + +export default { + wrapper: ({ children }) => pug` + Div= children + `, + // TODO: MDXAnchor(anchor=getTextChildren(children) size='xl') + h1: ({ children }) => pug` + Span(h2 bold) + = children + `, + // TODO: MDXAnchor.h2(anchor=getTextChildren(children)) + h2: ({ children }) => pug` + Span.h2.h2-text(h5)= children + Div.divider + `, + // TODO: MDXAnchor.h3(anchor=getTextChildren(children) size='s') + h3: ({ children }) => pug` + Span.h3.h3-text(h6 bold)= children + `, + h4: P, + h5: P, + h6: P, + p: ({ children }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const inBlockquote = useContext(BlockquoteContext) + + if (inBlockquote) { + return pug` + Span.blockquoteP= children + ` + } + return pug` + P= children + ` + }, + strong: ({ children }) => pug` + Text(style={ fontWeight: 'bold' })= children + `, + em: ({ children }) => pug` + Text(style={ fontStyle: 'italic' })= children + `, + hr: ({ children }) => pug` + Divider(size='l') + `, + center: ({ children }) => { + return pug` + P.center= children + ` + }, + br: Br, + thematicBreak: P, + table: ({ children }) => { + return pug` + Table(style={ marginTop: 16 })= children + ` + }, + thead: Thead, + tbody: Tbody, + tr: Tr, + td: Td, + th: Th, + delete: P, + a: ({ children, href }) => { + // function onPress (event) { + // const { url, hash } = $root.get('$render') + // const [_url, _hash] = href.split('#') + // if (url === _url && hash === `#${_hash}`) { + // event.preventDefault() + // // scrollTo({ anchorId: _hash }) + // } + // } + + // TODO: handle Anchor click with onPress + return pug` + Link.link( + to=href + size='l' + color='primary' + )= children + ` + }, + ul: ({ children }) => children, + ol: ({ children }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const currentLevel = useContext(ListLevelContext) + const nextLevel = currentLevel == null ? 0 : currentLevel + 1 + const filteredChildren = React.Children + .toArray(children) + .filter(child => child !== '\n') + .map((child, index) => React.cloneElement(child, { index })) + return pug` + ListLevelContext.Provider(value=nextLevel) + Div + = filteredChildren + ` + }, + li: ({ children, index }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const level = useContext(ListLevelContext) + const listIndex = index == null ? '•' : getOrderedListMark(index, level) + let hasTextChild = false + children = React.Children + .toArray(children) + .filter(child => child !== '\n') + .map(child => { + if (typeof child === 'string') { + hasTextChild = true + } + return child + }) + return pug` + Div(row) + Span.listIndex= listIndex + Div.listContent + if hasTextChild + P(size='l')= children + else + = children + ` + }, + blockquote: ({ children }) => { + const filteredChildren = React.Children + .toArray(children) + .filter(child => child !== '\n') + + return pug` + BlockquoteContext.Provider(value=true) + Alert.alert= filteredChildren + ` + }, + img: ({ src }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [style, setStyle] = useState({}) + const isUrl = /^(http|https):\/\//.test(src) + const isLocalUrl = /^\//.test(src) + + if (isLocalUrl) { + src = BASE_URL + src + } else if (!isUrl) { + console.warn('[@startupjs/mdx] Need to provide the url for the image') + return null + } + + function onLayout (e) { + const maxWidth = e.nativeEvent.layout.width + Image.getSize(src, (width, height) => { + const coefficient = maxWidth / width + setStyle({ + width: Math.min(width, maxWidth), + height: coefficient < 1 ? Math.ceil(height * coefficient) : height + }) + }, + error => console.warn(`[@startupjs/mdx], ${error}`) + ) + } + return pug` + Div.img(onLayout=onLayout) + Image(style=style source={ uri: src }) + ` + }, + section: ({ children, noscroll }) => { + const Wrapper = noscroll + ? ({ children }) => pug` + Div.example.padding= children + ` + : ({ children }) => pug` + ScrollView.example( + contentContainerStyleName=['exampleContent', 'padding'] + horizontal + )= children + ` + + return pug` + Wrapper= children + ` + }, + pre: ({ children }) => { + return pug` + PreContext.Provider(value=true) + = children + ` + }, + code: observer(({ children, className, ...props }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const isBlockCode = useContext(PreContext) + + if (!isBlockCode) { + return pug` + Span.inlineCodeWrapper + Span.inlineCodeSpacer   + Span.inlineCode(style={ + fontFamily: Platform.OS === 'ios' ? 'Menlo-Regular' : 'monospace' + })= children + Span.inlineCodeSpacer   + ` + } + + const language = (className || 'language-txt').replace(/language-/, '') + const [open, setOpen] = useState(false) + const $copyText = $('Copy code') + + async function copyHandler () { + await setStringAsync(children) + $copyText.set('Copied') + } + + function onMouseEnter () { + // we need to reutrn default text if it was copied + $copyText.set('Copy code') + } + + let example + + if (typeof children === 'string' && children.includes('[HACK EXAMPLE CODE]')) { + children = children.replace('[HACK EXAMPLE CODE]', '') + example = true + } + + return pug` + Div.code(styleName={ 'code-example': example }) + if example + Collapse.code-collapse(open=open variant='pure') + Collapse.Header.code-collapse-header(icon=false onPress=null) + Div.code-actions(align='right' row) + Div.code-action( + tooltip=open ? 'Hide code' : 'Show code' + onPress=()=> setOpen(!open) + ) + Icon.code-action-collapse(icon=faCode color='error') + Div.code-action( + tooltip=$copyText.get() + onPress=copyHandler + onMouseEnter=onMouseEnter + ) + Icon.code-action-copy(icon=faCopy) + Collapse.Content.code-collapse-content + Code(language=language)= children + else + Code(language=language)= children + ` + }) +} diff --git a/test/fixtures/example-unformatted/src/StartupjsUiPrompt.tsx b/test/fixtures/example-unformatted/src/StartupjsUiPrompt.tsx new file mode 100644 index 0000000..746343c --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiPrompt.tsx @@ -0,0 +1,73 @@ +import { type ReactNode } from 'react' +import { pug, $, observer } from 'startupjs' +import TextInput from '@startupjs-ui/text-input' +import Br from '@startupjs-ui/br' +import Span from '@startupjs-ui/span' +import { openDialog } from './helpers' + +export const _PropsJsonSchema = {/* PromptOptions */} + +export interface PromptOptions { + /** An optional dialog title displayed above the message */ + title?: string + /** The message displayed above the input field */ + message: string + /** An optional initial value for the input field */ + defaultValue?: string +} + +export default async function prompt (message: string, defaultValue?: string): Promise +export default async function prompt (options: PromptOptions, defaultValue?: string): Promise +export default async function prompt (options: string | PromptOptions, defaultValue?: string): Promise { + let title: unknown + let message: unknown + + if (typeof options === 'string') { + message = options + } else if (options && typeof options === 'object') { + title = (options as any).title + message = (options as any).message + defaultValue = defaultValue ?? (options as any).defaultValue + } + + if (title != null && typeof title !== 'string') { + throw new Error('[@startupjs-ui/dialogs] prompt: title should be a string') + } + + if (typeof message !== 'string') { + throw new Error('[@startupjs-ui/dialogs] prompt: message should be a string') + } + + const normalizedTitle = typeof title === 'string' ? title : undefined + + const result = await new Promise(resolve => { + const $prompt = $(defaultValue) + + openDialog({ + title: normalizedTitle, + children: pug` + Span= message + Br(half) + TextInputWrapper($prompt=$prompt) + `, + showCross: false, + enableBackdropPress: false, + cancelLabel: 'Cancel', + confirmLabel: 'OK', + onCancel: () => { resolve(null) }, + onConfirm: () => { resolve(($prompt.get() as string | undefined) ?? '') } + }) + }) + + return result +} + +// We need to make an observable wrapper so that in auto-rerenders when signal changes +const TextInputWrapper = observer(({ $prompt }: { $prompt: any }): ReactNode => { + return pug` + TextInput( + value=$prompt.get() + onChangeText=text => $prompt.set(text) + ) + ` +}) diff --git a/test/fixtures/example-unformatted/src/StartupjsUiTypeCell.js b/test/fixtures/example-unformatted/src/StartupjsUiTypeCell.js new file mode 100644 index 0000000..bd69f11 --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiTypeCell.js @@ -0,0 +1,27 @@ +import { pug } from 'startupjs' + +const Button = 'Button' +const Span = 'Span' +const MAX_ITEMS = 10 + +export function TypeCell ({ possibleValues, toggleList, collapsed, type }) { + const values = possibleValues || [] + + function renderButton () { + if (possibleValues?.length <= MAX_ITEMS) return null + return pug` + Span    + Button(onPress=toggleList)= collapsed ? 'More...' : 'Less' + ` + } + + return pug` + if type === 'oneOf' + Span + each value, index in values + Span(key=index)= value + = renderButton() + else + Span= type + ` +} diff --git a/test/fixtures/example-unformatted/src/StartupjsUiWrapInput.tsx b/test/fixtures/example-unformatted/src/StartupjsUiWrapInput.tsx new file mode 100644 index 0000000..a2908cc --- /dev/null +++ b/test/fixtures/example-unformatted/src/StartupjsUiWrapInput.tsx @@ -0,0 +1,360 @@ +import { useEffect, useState, type ReactNode, type RefObject } from 'react' +import { Platform, Text } from 'react-native' +import { pug, styl, observer } from 'startupjs' +import { themed } from '@startupjs-ui/core' +import Div from '@startupjs-ui/div' +import Icon from '@startupjs-ui/icon' +import Span from '@startupjs-ui/span' +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons/faExclamationCircle' +import merge from 'lodash/merge' +import getInputTestId from './helpers/getInputTestId' +import useLayout from './useLayout' + +export const IS_WRAPPED = Symbol('wrapped into wrapInput()') +const IS_WEB = Platform.OS === 'web' + +export type InputLayout = 'pure' | 'rows' | 'columns' + +export interface InputWrapperLayoutConfiguration { + labelPosition?: 'top' | 'right' + descriptionPosition?: 'top' | 'bottom' + _renderWrapper?: any + [key: string]: any +} + +export interface InputWrapperConfiguration extends InputWrapperLayoutConfiguration { + rows?: InputWrapperLayoutConfiguration + columns?: InputWrapperLayoutConfiguration + isLabelColoredWhenFocusing?: boolean + isLabelClickable?: boolean + _webLabelMode?: 'aria' | 'native' +} + +export interface InputWrapperProps { + label?: string + description?: string + layout?: InputLayout + configuration?: InputWrapperConfiguration + error?: string | string[] + required?: boolean | object + disabled?: boolean + readonly?: boolean + onFocus?: (...args: any[]) => void + onBlur?: (...args: any[]) => void + _onLabelPress?: () => void + ref?: RefObject + style?: any + [key: string]: any +} + +export function isWrapped (Component: any): boolean { + return Component[IS_WRAPPED] +} + +export default function wrapInput (Component: any, configuration: InputWrapperConfiguration = {}): any { + const defaultConfiguration = merge( + { + rows: { + labelPosition: 'top', + descriptionPosition: 'top' + }, + isLabelColoredWhenFocusing: false, + isLabelClickable: false + }, + configuration + ) + + function InputWrapper ({ + label, + description, + layout, + configuration: componentConfiguration, + error, + onFocus, + required, + onBlur, + _onLabelPress, + ref, + ...props + }: InputWrapperProps): ReactNode { + const currentLayout = useLayout({ + layout, + label, + description + }) + + const mergedConfiguration = merge({}, defaultConfiguration, componentConfiguration) + const resolvedConfiguration = merge({}, mergedConfiguration, mergedConfiguration[currentLayout]) + + const { + labelPosition, + descriptionPosition, + isLabelColoredWhenFocusing, + isLabelClickable, + _webLabelMode = 'aria' + } = resolvedConfiguration + + const [focused, setFocused] = useState(false) + const isReadOnlyOrDisabled = [props.readonly, props.disabled].some(Boolean) + + function handleFocus (...args: any[]) { + setFocused(true) + onFocus && onFocus(...args) + } + + function handleBlur (...args: any[]) { + setFocused(false) + onBlur && onBlur(...args) + } + + // NOTE + useEffect(() => { + if (!isLabelColoredWhenFocusing) return + if (focused && isReadOnlyOrDisabled) setFocused(false) + }, [focused, isLabelColoredWhenFocusing, isReadOnlyOrDisabled]) + + const hasError = Array.isArray(error) ? error.length > 0 : !!error + const generatedTestID = props.testID ?? getInputTestId({ + ...props, + label, + description, + testId: props.testID + }) + const semanticBaseId = typeof generatedTestID === 'string' && generatedTestID !== '' + ? generatedTestID + : undefined + const inputId = semanticBaseId ? `${semanticBaseId}-input` : undefined + const labelId = label && semanticBaseId ? `${semanticBaseId}-label` : undefined + const descriptionId = description && semanticBaseId ? `${semanticBaseId}-description` : undefined + const errorId = hasError && semanticBaseId ? `${semanticBaseId}-error` : undefined + const useNativeWebLabel = IS_WEB && _webLabelMode === 'native' && !!inputId + + const labelStyleName = [ + currentLayout, + currentLayout + '-' + labelPosition, + { + focused: isLabelColoredWhenFocusing ? focused : false, + error: hasError + } + ] + const requiredAsterisk = required === true + ? pug` + Text.required(aria-hidden=IS_WEB ? true : undefined)= ' *' + ` + : null + const WebLabelElement = 'label' + const _label = label + ? useNativeWebLabel + ? pug` + WebLabelElement.label( + key='label' + id=labelId + htmlFor=inputId + part='label' + styleName=labelStyleName + ) + = label + = requiredAsterisk + ` + : pug` + Span.label( + key='label' + id=labelId + part='label' + styleName=labelStyleName + onPress=isLabelClickable + ? _onLabelPress + : undefined + ) + = label + = requiredAsterisk + ` + : null + const _description = pug` + if description + Span.description( + key='description' + id=descriptionId + part='description' + styleName=[ + currentLayout, + descriptionPosition, + currentLayout + '-' + descriptionPosition + ] + description + )= description + ` + + const passRef = ref ? { ref } : {} + const inputAccessibilityProps: Record = {} + const describedBy = [descriptionId].filter(Boolean).join(' ') || undefined + + if (props['aria-label'] == null) { + if (props.accessibilityLabel != null) inputAccessibilityProps['aria-label'] = props.accessibilityLabel + else if (label) inputAccessibilityProps['aria-label'] = label + } + + if (inputId) { + inputAccessibilityProps.id = inputId + inputAccessibilityProps.nativeID = inputId + } + if (required === true) inputAccessibilityProps['aria-required'] = true + if (labelId) inputAccessibilityProps['aria-labelledby'] = labelId + if (describedBy) inputAccessibilityProps['aria-describedby'] = describedBy + if (hasError && errorId) { + inputAccessibilityProps['aria-errormessage'] = errorId + inputAccessibilityProps['aria-invalid'] = true + } + + const input = pug` + Component( + key='input' + part='wrapper' + layout=currentLayout + _hasError=hasError + onFocus=handleFocus + onBlur=handleBlur + ...inputAccessibilityProps + ...passRef + ...props + ) + ` + const err = pug` + if hasError + Div.errorContainer( + key='error' + id=errorId + styleName=[ + currentLayout, + currentLayout + '-' + descriptionPosition, + ] + vAlign='center' + row + ) + Icon.errorContainer-icon(icon=faExclamationCircle) + Span.errorContainer-text + each _error, index in (Array.isArray(error) ? error : [error]) + if index + Text= ' ' + = _error + ` + + return pug` + Div.root( + part='root' + styleName=[currentLayout] + ) + if currentLayout === 'rows' + if labelPosition === 'top' + = _label + if descriptionPosition === 'top' + = _description + = err + if labelPosition === 'right' + Div(vAlign='center' row) + = input + = _label + else + = input + if descriptionPosition === 'bottom' + = err + = _description + else if currentLayout === 'columns' + Div.leftBlock + = _label + = _description + Div.rightBlock + = input + = err + else if currentLayout === 'pure' + = input + = err + ` + } + + const componentDisplayName = Component.displayName ?? Component.name + + InputWrapper.displayName = componentDisplayName + 'InputWrapper' + + const ObservedInputWrapper = observer( + themed('InputWrapper', InputWrapper) + ) as any + + ObservedInputWrapper[IS_WRAPPED] = true + + return ObservedInputWrapper +} + +styl` + $errorColor = var(--color-text-error) + $focusedColor = var(--color-text-primary) + + // common + .label + color var(--InputWrapper-label-color) + align-self flex-start + font(body2) + + &.focused + color $focusedColor + + &.error + color $errorColor + + .description + font(caption) + + .required + color $errorColor + font-weight bold + + .errorContainer + margin-top 1u + margin-bottom 0.5u + + &-icon + color $errorColor + + &-text + font(caption) + margin-left 0.5u + color $errorColor + + // rows + .rows + &-top + .label& + margin-bottom 0.5u + + .description& + margin-bottom 1u + + .errorContainer& + margin-top 0 + margin-bottom 1u + + &-right + .label& + margin-left 1u + + &-bottom + .description& + margin-top 0.5u + + // columns + .leftBlock + .rightBlock + flex 1 + + .leftBlock + margin-right 1.5u + + .rightBlock + margin-left 1.5u + + .columns + .root& + flex-direction row + align-items center + +` diff --git a/test/fixtures/real-project/snapshots/eslint/CatCard.js.output.jsx b/test/fixtures/real-project/snapshots/eslint/CatCard.js.output.jsx index dbfa36e..5670005 100644 --- a/test/fixtures/real-project/snapshots/eslint/CatCard.js.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/CatCard.js.output.jsx @@ -22,26 +22,66 @@ export default observer(({ $cat, showPhone, large, small }) => { {photoFileId ? ( ) : ( - {name} + + {name} + )}
- + {(number || 'X') + '. '}
- {name} + + {name} + {showPhone ? ( <> {phone ? ( - + Phone: {phone} @@ -49,10 +89,22 @@ export default observer(({ $cat, showPhone, large, small }) => { : null} {catgram ? ( - + Catgram: {catgram} @@ -62,10 +114,22 @@ export default observer(({ $cat, showPhone, large, small }) => { : null} {phonegram ? ( - + Phonegram: {phonegram} diff --git a/test/fixtures/real-project/snapshots/eslint/CatCard.tsx.output.jsx b/test/fixtures/real-project/snapshots/eslint/CatCard.tsx.output.jsx index dbfa36e..5670005 100644 --- a/test/fixtures/real-project/snapshots/eslint/CatCard.tsx.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/CatCard.tsx.output.jsx @@ -22,26 +22,66 @@ export default observer(({ $cat, showPhone, large, small }) => { {photoFileId ? ( ) : ( - {name} + + {name} + )}
- + {(number || 'X') + '. '}
- {name} + + {name} + {showPhone ? ( <> {phone ? ( - + Phone: {phone} @@ -49,10 +89,22 @@ export default observer(({ $cat, showPhone, large, small }) => { : null} {catgram ? ( - + Catgram: {catgram} @@ -62,10 +114,22 @@ export default observer(({ $cat, showPhone, large, small }) => { : null} {phonegram ? ( - + Phonegram: {phonegram} diff --git a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx index 11cbe12..c6dcc7f 100644 --- a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.js.output.jsx @@ -60,7 +60,11 @@ const Profile = observer(({ $cat, $event }) => {
) : ( - )} @@ -82,8 +86,8 @@ function renderExpired () { Cat profile link is incorrect or already expired. Your cat meetup - profile link is only valid for a limited period of time. If you believe - this is an error, please contact the cat meetup organizer. + profile link is only valid for a limited period of time. If you + believe this is an error, please contact the cat meetup organizer. diff --git a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx index 11cbe12..c6dcc7f 100644 --- a/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/cat-profile-link.tsx.output.jsx @@ -60,7 +60,11 @@ const Profile = observer(({ $cat, $event }) => {
) : ( - )} @@ -82,8 +86,8 @@ function renderExpired () { Cat profile link is incorrect or already expired. Your cat meetup - profile link is only valid for a limited period of time. If you believe - this is an error, please contact the cat meetup organizer. + profile link is only valid for a limited period of time. If you + believe this is an error, please contact the cat meetup organizer. diff --git a/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx b/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx index c72e1b4..ad880e5 100644 --- a/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx +++ b/test/fixtures/real-project/snapshots/eslint/event-tabs-breed.js.output.jsx @@ -134,7 +134,11 @@ const CatsList = observer(({ onEdit, breed, eventId }) => {
{!hasContact($cat) ? No contact : null} - {!$cat.photoFileId.get() ? No photo : null} + {!$cat.photoFileId.get() + ? ( + No photo + ) + : null}