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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@glimmer/syntax": ">= 0.92.0",
"@typescript-eslint/tsconfig-utils": "^8.38.0",
"content-tag": "^4.1.0",
"ember-estree": "github:NullVoxPopuli/ember-estree#56dc454",
"eslint-scope": "^9.1.1",
"html-tags": "^5.1.0",
"mathml-tag-names": "^4.0.0",
Expand Down
469 changes: 469 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

114 changes: 70 additions & 44 deletions src/parser/gjs-gts-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ import tsconfigUtils from '@typescript-eslint/tsconfig-utils';
import babelParser from '@babel/eslint-parser/experimental-worker';
import { registerParsedFile } from '../preprocessor/noop.js';
import { patchTs, replaceExtensions, syncMtsGtsSourceFiles, typescriptParser } from './ts-patch.js';
import { transformForLint, preprocessGlimmerTemplates, convertAst } from './transforms.js';
import { toTree, buildGlimmerVisitorKeys } from 'ember-estree';
import { registerGlimmerScopes } from './transforms.js';

const require = createRequire(import.meta.url);

/**
* implements https://eslint.org/docs/latest/extend/custom-parsers
* 1. transforms gts/gjs files into parseable ts/js without changing the offsets and locations around it
* 2. parses the transformed code and generates the AST for TS ot JS
* 3. preprocesses the templates info and prepares the Glimmer AST
* 4. converts the js/ts AST so that it includes the Glimmer AST at the right locations, replacing the original
*
* Uses ember-estree's toTree with a pluggable jsParser to:
* 1. Extract <template> regions via content-tag
* 2. Parse JS/TS with TypeScript-ESLint or Babel
* 3. Process Glimmer templates and splice them into the AST
* 4. Register Glimmer scopes for ESLint rules (no-undef, no-unused-vars)
*/

/**
Expand Down Expand Up @@ -77,12 +80,10 @@ function getAllowJsFromPrograms(programs) {
function getProjectServiceTsconfigPath(projectService) {
if (!projectService) return null;

// If projectService is true, use default behavior (nearest tsconfig.json, allowJs from config)
if (projectService === true) {
return 'tsconfig.json';
}

// If projectService is an object, handle ProjectServiceOptions
if (typeof projectService === 'object') {
if (typeof projectService.allowDefaultProject !== 'undefined') {
// eslint-disable-next-line no-console
Expand Down Expand Up @@ -138,14 +139,10 @@ export function parseForESLint(code, options) {
const allowGjsWasSet = options.allowGjs !== undefined;
const allowGjs = allowGjsWasSet ? options.allowGjs : getAllowJs(options);
let actualAllowGjs;
// Only patch TypeScript if we actually need it.
if (options.programs || options.projectService || options.project) {
({ allowGjs: actualAllowGjs } = patchTs({ allowGjs }));
}
registerParsedFile(options.filePath);
let jsCode = code;
const info = transformForLint(code, options.filePath);
jsCode = info.output;

const isTypescript = options.filePath.endsWith('.gts') || options.filePath.endsWith('.ts');
let useTypescript = true;
Expand All @@ -154,42 +151,51 @@ export function parseForESLint(code, options) {
useTypescript = false;
}

let result = null;
const filePath = options.filePath;
if (options.project || options.projectService) {
jsCode = replaceExtensions(jsCode);
}

if (isTypescript && !typescriptParser) {
throw new Error('Please install typescript to process gts');
}

const filePath = options.filePath;

// scopeManager is captured from jsParser result for use in the visitor
let scopeManager = null;
let jsParserResult = null;

try {
result =
isTypescript || useTypescript
? typescriptParser.parseForESLint(jsCode, {
...options,
ranges: true,
extraFileExtensions: ['.gts', '.gjs'],
filePath,
})
: babelParser.parseForESLint(jsCode, {
...options,
requireConfigFile: false,
ranges: true,
});
if (!info.templateInfos?.length) {
return result;
}
const preprocessedResult = preprocessGlimmerTemplates(info, code);
preprocessedResult.code = code;
const { templateVisitorKeys } = preprocessedResult;
const visitorKeys = { ...result.visitorKeys, ...templateVisitorKeys };
result.isTypescript = isTypescript || useTypescript;
convertAst(result, preprocessedResult, visitorKeys);
if (result.services?.program) {
// Compare allowJs with the actual program's compiler options
const programAllowJs = result.services.program.getCompilerOptions?.()?.allowJs;
const result = toTree(code, {
filePath,
jsParser(placeholderJS, filename) {
let jsCode = placeholderJS;
if (options.project || options.projectService) {
jsCode = replaceExtensions(jsCode);
}

jsParserResult =
isTypescript || useTypescript
? typescriptParser.parseForESLint(jsCode, {
...options,
ranges: true,
extraFileExtensions: ['.gts', '.gjs'],
filePath: filename,
})
: babelParser.parseForESLint(jsCode, {
...options,
requireConfigFile: false,
ranges: true,
});

scopeManager = jsParserResult.scopeManager;
return jsParserResult;
},
visitor(path) {
if (scopeManager) {
registerGlimmerScopes(path, scopeManager, isTypescript || useTypescript);
}
},
});

if (jsParserResult?.services?.program) {
const programAllowJs = jsParserResult.services.program.getCompilerOptions?.()?.allowJs;
if (
!allowGjsWasSet &&
programAllowJs !== undefined &&
Expand All @@ -202,10 +208,30 @@ export function parseForESLint(code, options) {
` Current: ${allowGjs}, Program: ${programAllowJs}`
);
}
syncMtsGtsSourceFiles(result.services.program);
syncMtsGtsSourceFiles(jsParserResult.services.program);
}
return { ...result, visitorKeys };

const visitorKeys = {
...(jsParserResult?.visitorKeys || {}),
...buildGlimmerVisitorKeys(),
};

return {
ast: result.program,
visitorKeys,
scopeManager,
services: jsParserResult?.services || {},
};
} catch (e) {
// Wrap content-tag parse errors with ESLint-friendly properties
if (e.message?.includes('Parse Error at')) {
const [line, column] = e.message.split(':').slice(-2).map((x) => parseInt(x));
const err = new Error(e.source_code || e.message);
err.lineNumber = line;
err.column = column;
err.fileName = filePath;
throw err;
}
console.error(e);
throw e;
}
Expand Down
35 changes: 6 additions & 29 deletions src/parser/hbs-parser.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as eslintScope from 'eslint-scope';
import DocumentLines from '../utils/document.js';
import { processGlimmerTemplate, buildGlimmerVisitorKeys } from './transforms.js';
import { parseTemplate, DocumentLines, buildGlimmerVisitorKeys } from 'ember-estree';

// Constant: Program + all Glimmer node types. Computed once at module load.
const hbsVisitorKeys = { Program: ['body'], ...buildGlimmerVisitorKeys() };
Expand All @@ -26,15 +25,10 @@ export function parseForESLint(code, options) {
const filePath = (options && options.filePath) || '<hbs>';
const codeLines = new DocumentLines(code);

let result;
let templateNode;
try {
result = processGlimmerTemplate({
templateContent: code,
codeLines,
templateRange: [0, code.length],
});
templateNode = parseTemplate(code);
} catch (e) {
// Transform glimmer parse error to ESLint-compatible error
const loc = e.location || (e.hash && e.hash.loc);
if (loc && loc.start) {
const err = Object.assign(new SyntaxError(e.message), {
Expand All @@ -48,14 +42,11 @@ export function parseForESLint(code, options) {
throw e;
}

const { ast: templateNode, comments } = result;

// Wrap in a synthetic Program node (required by ESLint)
const program = {
type: 'Program',
body: [templateNode],
tokens: templateNode.tokens,
comments,
comments: templateNode.comments || [],
range: [0, code.length],
start: 0,
end: code.length,
Expand All @@ -65,28 +56,14 @@ export function parseForESLint(code, options) {
},
};

// Build visitor keys: Program + all Glimmer node types
const visitorKeys = hbsVisitorKeys;

// Create an empty scope manager.
// For HBS, all locals are assumed to be defined at runtime,
// so we don't track variable references (no no-undef errors).
const scopeManager = eslintScope.analyze(
{
type: 'Program',
body: [],
range: [0, code.length],
loc: program.loc,
},
{ type: 'Program', body: [], range: [0, code.length], loc: program.loc },
{ range: true }
);

return {
ast: program,
scopeManager,
visitorKeys,
services: {},
};
return { ast: program, scopeManager, visitorKeys, services: {} };
}

export default { meta, parseForESLint };
Loading
Loading