diff --git a/lib/parsers/gjs-gts-parser.js b/lib/parsers/gjs-gts-parser.js
index d0fe31e97f..54566d18d7 100644
--- a/lib/parsers/gjs-gts-parser.js
+++ b/lib/parsers/gjs-gts-parser.js
@@ -1,526 +1,9 @@
-const ContentTag = require('content-tag');
-const glimmer = require('@glimmer/syntax');
-const DocumentLines = require('../utils/document');
-const { visitorKeys: glimmerVisitorKeys } = require('@glimmer/syntax');
const babelParser = require('@babel/eslint-parser');
const typescriptParser = require('@typescript-eslint/parser');
-const ts = require('typescript');
-const TypescriptScope = require('@typescript-eslint/scope-manager');
-const { Reference, Scope, Variable, Definition } = require('eslint-scope');
const { registerParsedFile } = require('../preprocessors/noop');
-const htmlTags = require('html-tags');
const path = require('node:path');
-const fs = require('node:fs');
-
-/**
- * finds the nearest node scope
- * @param scopeManager
- * @param nodePath
- * @return {*|null}
- */
-function findParentScope(scopeManager, nodePath) {
- let scope = null;
- let path = nodePath;
- while (path) {
- scope = scopeManager.acquire(path.node, true);
- if (scope) {
- return scope;
- }
- path = path.parentPath;
- }
- return null;
-}
-
-/**
- * tries to find the variable names {name} in any parent scope
- * if the variable is not found it just returns the nearest scope,
- * so that it's usage can be registered.
- * @param scopeManager
- * @param nodePath
- * @param name
- * @return {{scope: null, variable: *}|{scope: (*|null)}}
- */
-function findVarInParentScopes(scopeManager, nodePath, name) {
- let scope = null;
- let path = nodePath;
- while (path) {
- scope = scopeManager.acquire(path.node, true);
- if (scope && scope.set.has(name)) {
- break;
- }
- path = path.parentPath;
- }
- const currentScope = findParentScope(scopeManager, nodePath);
- if (!scope) {
- return { scope: currentScope };
- }
- return { scope: currentScope, variable: scope.set.get(name) };
-}
-
-/**
- * registers a node variable usage in the scope.
- * @param node
- * @param scope
- * @param variable
- */
-function registerNodeInScope(node, scope, variable) {
- const ref = new Reference(node, scope, Reference.READ);
- if (variable) {
- variable.references.push(ref);
- ref.resolved = variable;
- } else {
- // register missing variable in most upper scope.
- let s = scope;
- while (s.upper) {
- s = s.upper;
- }
- s.through.push(ref);
- }
- scope.references.push(ref);
-}
-
-/**
- * traverses all nodes using the {visitorKeys} calling the callback function, visitor
- * @param visitorKeys
- * @param node
- * @param visitor
- */
-function traverse(visitorKeys, node, visitor) {
- const allVisitorKeys = visitorKeys;
- const queue = [];
-
- queue.push({
- node,
- parent: null,
- parentKey: null,
- parentPath: null,
- });
-
- while (queue.length > 0) {
- const currentPath = queue.pop();
-
- visitor(currentPath);
-
- const visitorKeys = allVisitorKeys[currentPath.node.type];
- if (!visitorKeys) {
- continue;
- }
-
- for (const visitorKey of visitorKeys) {
- const child = currentPath.node[visitorKey];
-
- if (!child) {
- continue;
- } else if (Array.isArray(child)) {
- for (const item of child) {
- queue.push({
- node: item,
- parent: currentPath.node,
- parentKey: visitorKey,
- parentPath: currentPath,
- });
- }
- } else {
- queue.push({
- node: child,
- parent: currentPath.node,
- parentKey: visitorKey,
- parentPath: currentPath,
- });
- }
- }
- }
-}
-
-function isUpperCase(char) {
- return char.toUpperCase() === char;
-}
-
-function isAlphaNumeric(code) {
- return !(
- !(code > 47 && code < 58) && // numeric (0-9)
- !(code > 64 && code < 91) && // upper alpha (A-Z)
- !(code > 96 && code < 123)
- );
-}
-
-function isWhiteSpace(code) {
- return code === ' ' || code === '\t' || code === '\r' || code === '\n' || code === '\v';
-}
-
-/**
- * simple tokenizer for templates, just splits it up into words and punctuators
- * @param template {string}
- * @param startOffset {number}
- * @param doc {DocumentLines}
- * @return {Token[]}
- */
-function tokenize(template, doc, startOffset) {
- const tokens = [];
- let current = '';
- let start = 0;
- function pushToken(value, type, range) {
- const t = {
- type,
- value,
- range,
- start: range[0],
- end: range[1],
- loc: {
- start: { ...doc.offsetToPosition(range[0]), index: range[0] },
- end: { ...doc.offsetToPosition(range[1]), index: range[1] },
- },
- };
- tokens.push(t);
- }
- for (const [i, c] of [...template].entries()) {
- if (isAlphaNumeric(c.codePointAt(0))) {
- if (current.length === 0) {
- start = i;
- }
- current += c;
- } else {
- let range = [startOffset + start, startOffset + i];
- if (current.length > 0) {
- pushToken(current, 'word', range);
- current = '';
- }
- range = [startOffset + i, startOffset + i + 1];
- if (!isWhiteSpace(c)) {
- pushToken(c, 'Punctuator', range);
- }
- }
- }
- return tokens;
-}
-
-/**
- * Preprocesses the template info, parsing the template content to Glimmer AST,
- * fixing the offsets and locations of all nodes
- * also calculates the block params locations & ranges
- * and adding it to the info
- * @param info
- * @param code
- * @return {{templateVisitorKeys: {}, comments: *[], templateInfos: {templateRange: *, range: *, replacedRange: *}[]}}
- */
-function preprocessGlimmerTemplates(info, code) {
- const templateInfos = info.templateInfos.map((r) => ({
- range: [r.contentRange.start, r.contentRange.end],
- templateRange: [r.range.start, r.range.end],
- }));
- const templateVisitorKeys = {};
- const codeLines = new DocumentLines(code);
- const comments = [];
- const textNodes = [];
- for (const tpl of templateInfos) {
- const range = tpl.range;
- const template = code.slice(...range);
- const docLines = new DocumentLines(template);
- const ast = glimmer.preprocess(template, { mode: 'codemod' });
- ast.tokens = tokenize(code.slice(...tpl.templateRange), codeLines, tpl.templateRange[0]);
- const allNodes = [];
- glimmer.traverse(ast, {
- All(node, path) {
- const n = node;
- n.parent = path.parentNode;
- allNodes.push(node);
- if (node.type === 'CommentStatement' || node.type === 'MustacheCommentStatement') {
- comments.push(node);
- }
- if (node.type === 'TextNode') {
- n.value = node.chars;
- textNodes.push(node);
- }
- },
- });
- ast.content = template;
- const allNodeTypes = new Set();
- for (const n of allNodes) {
- if (n.type === 'PathExpression') {
- n.head.range = [
- range[0] + docLines.positionToOffset(n.head.loc.start),
- range[0] + docLines.positionToOffset(n.head.loc.end),
- ];
- n.head.loc = {
- start: codeLines.offsetToPosition(n.head.range[0]),
- end: codeLines.offsetToPosition(n.head.range[1]),
- };
- }
- n.range =
- n.type === 'Template'
- ? [tpl.templateRange[0], tpl.templateRange[1]]
- : [
- range[0] + docLines.positionToOffset(n.loc.start),
- range[0] + docLines.positionToOffset(n.loc.end),
- ];
-
- n.start = n.range[0];
- n.end = n.range[1];
- n.loc = {
- start: codeLines.offsetToPosition(n.range[0]),
- end: codeLines.offsetToPosition(n.range[1]),
- };
- if (n.type === 'Template') {
- n.loc.start = codeLines.offsetToPosition(tpl.templateRange[0]);
- n.loc.end = codeLines.offsetToPosition(tpl.templateRange[1]);
- }
- // split up element node into sub nodes to be able to reference tag name
- // parts -> nodes for `Foo` and `Bar`
- if (n.type === 'ElementNode') {
- n.name = n.tag;
- n.parts = [];
- let start = n.range[0];
- let codeSlice = code.slice(...n.range);
- for (const part of n.tag.split('.')) {
- const regex = new RegExp(`\\b${part}\\b`);
- const match = codeSlice.match(regex);
- const range = [start + match.index, 0];
- range[1] = range[0] + part.length;
- codeSlice = code.slice(range[1], n.range[1]);
- start = range[1];
- n.parts.push({
- type: 'GlimmerElementNodePart',
- name: part,
- range,
- parent: n,
- loc: {
- start: codeLines.offsetToPosition(range[0]),
- end: codeLines.offsetToPosition(range[1]),
- },
- });
- }
- }
- // block params do not have location information
- // add our own nodes so we can reference them
- if ('blockParams' in n) {
- n.params = [];
- }
- if ('blockParams' in n && n.parent) {
- let part = code.slice(...n.parent.range);
- let start = n.parent.range[0];
- let idx = part.indexOf('|') + 1;
- start += idx;
- part = part.slice(idx, -1);
- idx = part.indexOf('|');
- part = part.slice(0, idx);
- for (const param of n.blockParams) {
- const regex = new RegExp(`\\b${param}\\b`);
- const match = part.match(regex);
- const range = [start + match.index, 0];
- range[1] = range[0] + param.length;
- n.params.push({
- type: 'BlockParam',
- name: param,
- range,
- parent: n,
- loc: {
- start: codeLines.offsetToPosition(range[0]),
- end: codeLines.offsetToPosition(range[1]),
- },
- });
- }
- }
- n.type = `Glimmer${n.type}`;
- allNodeTypes.add(n.type);
- }
- // ast should not contain comment nodes
- for (const comment of comments) {
- const parentBody = comment.parent.body || comment.parent.children;
- const idx = parentBody.indexOf(comment);
- parentBody.splice(idx, 1);
- // comment type can be a block comment or a line comment
- // mark comments as always block comment, this works for eslint in all cases
- comment.type = 'Block';
- }
- // tokens should not contain tokens of comments
- ast.tokens = ast.tokens.filter(
- (t) => !comments.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
- );
- // tokens should not contain tokens of text nodes, but represent the whole node
- // remove existing tokens
- ast.tokens = ast.tokens.filter(
- (t) => !textNodes.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
- );
- // merge in text nodes
- let currentTextNode = textNodes.pop();
- for (let i = ast.tokens.length - 1; i >= 0; i--) {
- const t = ast.tokens[i];
- while (currentTextNode && t.range[0] < currentTextNode.range[0]) {
- ast.tokens.splice(i + 1, 0, currentTextNode);
- currentTextNode = textNodes.pop();
- }
- }
- ast.contents = template;
- tpl.ast = ast;
- }
- for (const [k, v] of Object.entries(glimmerVisitorKeys)) {
- templateVisitorKeys[`Glimmer${k}`] = [...v];
- }
- return {
- templateVisitorKeys,
- templateInfos,
- comments,
- };
-}
-
-/**
- * traverses the AST and replaces the transformed template parts with the Glimmer
- * AST.
- * This also creates the scopes for the Glimmer Blocks and registers the block params
- * in the scope, and also any usages of variables in path expressions
- * this allows the basic eslint rules no-undef and no-unsused to work also for the
- * templates without needing any custom rules
- * @param result
- * @param preprocessedResult
- * @param visitorKeys
- */
-function convertAst(result, preprocessedResult, visitorKeys) {
- const templateInfos = preprocessedResult.templateInfos;
- let counter = 0;
- result.ast.comments.push(...preprocessedResult.comments);
-
- for (const ti of templateInfos) {
- const firstIdx = result.ast.tokens.findIndex((t) => t.range[0] === ti.templateRange[0]);
- const lastIdx = result.ast.tokens.findIndex((t) => t.range[1] === ti.templateRange[1]);
- result.ast.tokens.splice(firstIdx, lastIdx - firstIdx + 1, ...ti.ast.tokens);
- }
-
- // eslint-disable-next-line complexity
- traverse(visitorKeys, result.ast, (path) => {
- const node = path.node;
- if (
- node.type === 'ExpressionStatement' ||
- node.type === 'StaticBlock' ||
- node.type === 'TemplateLiteral' ||
- node.type === 'ExportDefaultDeclaration'
- ) {
- let range = node.range;
- if (node.type === 'ExportDefaultDeclaration') {
- range = [node.declaration.range[0], node.declaration.range[1]];
- }
-
- const template = templateInfos.find(
- (t) => t.templateRange[0] === range[0] && t.templateRange[1] === range[1]
- );
- if (!template) {
- return null;
- }
- counter++;
- const ast = template.ast;
- Object.assign(node, ast);
- }
-
- if (node.type === 'GlimmerPathExpression' && node.head.type === 'VarHead') {
- const name = node.head.name;
- if (glimmer.isKeyword(name)) {
- return null;
- }
- const { scope, variable } = findVarInParentScopes(result.scopeManager, path, name) || {};
- if (scope) {
- node.head.parent = node;
- registerNodeInScope(node.head, scope, variable);
- }
- }
- if (node.type === 'GlimmerElementNode') {
- // always reference first part of tag name, this also has the advantage
- // that errors regarding this tag will only mark the tag name instead of
- // the whole tag + children
- const n = node.parts[0];
- const { scope, variable } = findVarInParentScopes(result.scopeManager, path, n.name) || {};
- if (
- scope &&
- (variable ||
- isUpperCase(n.name[0]) ||
- node.name.includes('.') ||
- !htmlTags.includes(node.name))
- ) {
- registerNodeInScope(n, scope, variable);
- }
- }
-
- if ('blockParams' in node) {
- const upperScope = findParentScope(result.scopeManager, path);
- const scope = result.isTypescript
- ? new TypescriptScope.BlockScope(result.scopeManager, upperScope, node)
- : new Scope(result.scopeManager, 'block', upperScope, node);
- for (const [i, b] of node.params.entries()) {
- const v = new Variable(b.name, scope);
- v.identifiers.push(b);
- v.defs.push(new Definition('Parameter', b, node, node, i, 'Block Param'));
- scope.variables.push(v);
- scope.set.set(b.name, v);
- }
- }
- return null;
- });
-
- if (counter !== templateInfos.length) {
- throw new Error('failed to process all templates');
- }
-}
-
-function replaceRange(s, start, end, substitute) {
- return s.slice(0, start) + substitute + s.slice(end);
-}
-
-const processor = new ContentTag.Preprocessor();
-
-function transformForLint(code) {
- let jsCode = code;
- /**
- *
- * @type {{
- * type: 'expression' | 'class-member';
- * tagName: 'template';
- * contents: string;
- * range: {
- * start: number;
- * end: number;
- * };
- * contentRange: {
- * start: number;
- * end: number;
- * };
- * startRange: {
- * end: number;
- * start: number;
- * };
- * endRange: {
- * start: number;
- * end: number;
- * };
- * }[]}
- */
- const result = processor.parse(code);
- for (const tplInfo of result.reverse()) {
- const lineBreaks = [...tplInfo.contents].reduce(
- (prev, curr) => prev + (DocumentLines.isLineBreak(curr.codePointAt(0)) ? 1 : 0),
- 0
- );
- if (tplInfo.type === 'class-member') {
- const tplLength = tplInfo.range.end - tplInfo.range.start;
- const spaces = tplLength - 'static{`'.length - '`}'.length - lineBreaks;
- const total = ' '.repeat(spaces) + '\n'.repeat(lineBreaks);
- const replacementCode = `static{\`${total}\`}`;
- jsCode = replaceRange(jsCode, tplInfo.range.start, tplInfo.range.end, replacementCode);
- } else {
- const tplLength = tplInfo.range.end - tplInfo.range.start;
- const spaces = tplLength - '`'.length - '`'.length - lineBreaks;
- const total = ' '.repeat(spaces) + '\n'.repeat(lineBreaks);
- const replacementCode = `\`${total}\``;
- jsCode = replaceRange(jsCode, tplInfo.range.start, tplInfo.range.end, replacementCode);
- }
- }
- if (jsCode.length !== code.length) {
- throw new Error('bad transform');
- }
- return {
- templateInfos: result,
- output: jsCode,
- };
-}
-
-const TsProgramMap = {};
+const { getTsProgram } = require('./ts-utils');
+const { transformForLint, preprocessGlimmerTemplates, convertAst } = require('./transform');
/**
* implements https://eslint.org/docs/latest/extend/custom-parsers
@@ -545,71 +28,17 @@ module.exports = {
let result = null;
- const projectFile = path.resolve(options.tsconfigRootDir, options.project);
-
- if (!TsProgramMap[projectFile]) {
- const config = ts.getParsedCommandLineOfConfigFile(
- projectFile,
- {},
- {
- ...ts.sys,
- readDirectory(dir) {
- const results = ts.sys.readDirectory(dir);
- return results.map((f) => f.replace(/\.gts$/, '.ts'));
- },
- onUnRecoverableConfigFileDiagnostic(diagnostic) {
- let { messageText } = diagnostic;
- if (typeof messageText !== 'string') {
- messageText = messageText.messageText;
- }
-
- throw new Error(messageText);
- },
- }
- );
-
- const host = ts.createCompilerHost(config.options);
- host.fileExists = function (fileName) {
- return fs.existsSync(fileName.replace(/\.ts$/, '.gts')) || fs.existsSync(fileName);
- };
+ let programs = null;
+ let filePath = options.filePath;
+ if (options.project) {
+ const projectFile = path.resolve(options.tsconfigRootDir || '.', options.project);
- const readDirectory = host.readDirectory;
- host.readDirectory = function(dir) {
- const results = readDirectory.call(this, dir);
- return results.map((f) => f.replace(/\.gts$/, '.ts'));
- };
- host.readFile = function(fname) {
- let fileName = fname;
- if (fileName === options.filePath.replace(/\\/g, '/').replace(/\.gts$/, '.ts')) {
- return jsCode;
- }
- let content = '';
- try {
- content = fs.readFileSync(fileName).toString();
- } catch {
- fileName = fileName.replace(/\.ts$/, '.gts');
- content = fs.readFileSync(fileName).toString();
- }
- if (fileName.endsWith('.gts')) {
- content = transformForLint(content).output;
- }
- content = content.replaceAll(/\.gts(["'])/g, '.ts$1 ');
- return content;
- };
-
- TsProgramMap[projectFile] = ts.createProgram({
- rootNames: config.fileNames,
- options: config.options,
- host,
- });
+ filePath = options.filePath.replace(/\.gts$/, '.ts');
+ programs = [getTsProgram(projectFile, filePath, jsCode)];
}
- const program = TsProgramMap[projectFile];
-
- options.filePath = options.filePath.replace(/\.gts$/, '.ts');
-
result = isTypescript
- ? typescriptParser.parseForESLint(jsCode, { ...options, ranges: true, programs: [program] })
+ ? typescriptParser.parseForESLint(jsCode, { ...options, ranges: true, programs, filePath })
: babelParser.parseForESLint(jsCode, { ...options, ranges: true });
if (!info.templateInfos?.length) {
return result;
diff --git a/lib/parsers/transform.js b/lib/parsers/transform.js
new file mode 100644
index 0000000000..0ab64bd047
--- /dev/null
+++ b/lib/parsers/transform.js
@@ -0,0 +1,515 @@
+const ContentTag = require('content-tag');
+const glimmer = require('@glimmer/syntax');
+const DocumentLines = require('../utils/document');
+const { visitorKeys: glimmerVisitorKeys } = require('@glimmer/syntax');
+const TypescriptScope = require('@typescript-eslint/scope-manager');
+const { Reference, Scope, Variable, Definition } = require('eslint-scope');
+const htmlTags = require('html-tags');
+
+/**
+ * finds the nearest node scope
+ * @param scopeManager
+ * @param nodePath
+ * @return {*|null}
+ */
+function findParentScope(scopeManager, nodePath) {
+ let scope = null;
+ let path = nodePath;
+ while (path) {
+ scope = scopeManager.acquire(path.node, true);
+ if (scope) {
+ return scope;
+ }
+ path = path.parentPath;
+ }
+ return null;
+}
+
+/**
+ * tries to find the variable names {name} in any parent scope
+ * if the variable is not found it just returns the nearest scope,
+ * so that it's usage can be registered.
+ * @param scopeManager
+ * @param nodePath
+ * @param name
+ * @return {{scope: null, variable: *}|{scope: (*|null)}}
+ */
+function findVarInParentScopes(scopeManager, nodePath, name) {
+ let scope = null;
+ let path = nodePath;
+ while (path) {
+ scope = scopeManager.acquire(path.node, true);
+ if (scope && scope.set.has(name)) {
+ break;
+ }
+ path = path.parentPath;
+ }
+ const currentScope = findParentScope(scopeManager, nodePath);
+ if (!scope) {
+ return { scope: currentScope };
+ }
+ return { scope: currentScope, variable: scope.set.get(name) };
+}
+
+/**
+ * registers a node variable usage in the scope.
+ * @param node
+ * @param scope
+ * @param variable
+ */
+function registerNodeInScope(node, scope, variable) {
+ const ref = new Reference(node, scope, Reference.READ);
+ if (variable) {
+ variable.references.push(ref);
+ ref.resolved = variable;
+ } else {
+ // register missing variable in most upper scope.
+ let s = scope;
+ while (s.upper) {
+ s = s.upper;
+ }
+ s.through.push(ref);
+ }
+ scope.references.push(ref);
+}
+
+/**
+ * traverses all nodes using the {visitorKeys} calling the callback function, visitor
+ * @param visitorKeys
+ * @param node
+ * @param visitor
+ */
+function traverse(visitorKeys, node, visitor) {
+ const allVisitorKeys = visitorKeys;
+ const queue = [];
+
+ queue.push({
+ node,
+ parent: null,
+ parentKey: null,
+ parentPath: null,
+ });
+
+ while (queue.length > 0) {
+ const currentPath = queue.pop();
+
+ visitor(currentPath);
+
+ const visitorKeys = allVisitorKeys[currentPath.node.type];
+ if (!visitorKeys) {
+ continue;
+ }
+
+ for (const visitorKey of visitorKeys) {
+ const child = currentPath.node[visitorKey];
+
+ if (!child) {
+ continue;
+ } else if (Array.isArray(child)) {
+ for (const item of child) {
+ queue.push({
+ node: item,
+ parent: currentPath.node,
+ parentKey: visitorKey,
+ parentPath: currentPath,
+ });
+ }
+ } else {
+ queue.push({
+ node: child,
+ parent: currentPath.node,
+ parentKey: visitorKey,
+ parentPath: currentPath,
+ });
+ }
+ }
+ }
+}
+
+function isUpperCase(char) {
+ return char.toUpperCase() === char;
+}
+
+function isAlphaNumeric(code) {
+ return !(
+ !(code > 47 && code < 58) && // numeric (0-9)
+ !(code > 64 && code < 91) && // upper alpha (A-Z)
+ !(code > 96 && code < 123)
+ );
+}
+
+function isWhiteSpace(code) {
+ return code === ' ' || code === '\t' || code === '\r' || code === '\n' || code === '\v';
+}
+
+/**
+ * simple tokenizer for templates, just splits it up into words and punctuators
+ * @param template {string}
+ * @param startOffset {number}
+ * @param doc {DocumentLines}
+ * @return {Token[]}
+ */
+function tokenize(template, doc, startOffset) {
+ const tokens = [];
+ let current = '';
+ let start = 0;
+ function pushToken(value, type, range) {
+ const t = {
+ type,
+ value,
+ range,
+ start: range[0],
+ end: range[1],
+ loc: {
+ start: { ...doc.offsetToPosition(range[0]), index: range[0] },
+ end: { ...doc.offsetToPosition(range[1]), index: range[1] },
+ },
+ };
+ tokens.push(t);
+ }
+ for (const [i, c] of [...template].entries()) {
+ if (isAlphaNumeric(c.codePointAt(0))) {
+ if (current.length === 0) {
+ start = i;
+ }
+ current += c;
+ } else {
+ let range = [startOffset + start, startOffset + i];
+ if (current.length > 0) {
+ pushToken(current, 'word', range);
+ current = '';
+ }
+ range = [startOffset + i, startOffset + i + 1];
+ if (!isWhiteSpace(c)) {
+ pushToken(c, 'Punctuator', range);
+ }
+ }
+ }
+ return tokens;
+}
+
+/**
+ * Preprocesses the template info, parsing the template content to Glimmer AST,
+ * fixing the offsets and locations of all nodes
+ * also calculates the block params locations & ranges
+ * and adding it to the info
+ * @param info
+ * @param code
+ * @return {{templateVisitorKeys: {}, comments: *[], templateInfos: {templateRange: *, range: *, replacedRange: *}[]}}
+ */
+module.exports.preprocessGlimmerTemplates = function preprocessGlimmerTemplates(info, code) {
+ const templateInfos = info.templateInfos.map((r) => ({
+ range: [r.contentRange.start, r.contentRange.end],
+ templateRange: [r.range.start, r.range.end],
+ }));
+ const templateVisitorKeys = {};
+ const codeLines = new DocumentLines(code);
+ const comments = [];
+ const textNodes = [];
+ for (const tpl of templateInfos) {
+ const range = tpl.range;
+ const template = code.slice(...range);
+ const docLines = new DocumentLines(template);
+ const ast = glimmer.preprocess(template, { mode: 'codemod' });
+ ast.tokens = tokenize(code.slice(...tpl.templateRange), codeLines, tpl.templateRange[0]);
+ const allNodes = [];
+ glimmer.traverse(ast, {
+ All(node, path) {
+ const n = node;
+ n.parent = path.parentNode;
+ allNodes.push(node);
+ if (node.type === 'CommentStatement' || node.type === 'MustacheCommentStatement') {
+ comments.push(node);
+ }
+ if (node.type === 'TextNode') {
+ n.value = node.chars;
+ textNodes.push(node);
+ }
+ },
+ });
+ ast.content = template;
+ const allNodeTypes = new Set();
+ for (const n of allNodes) {
+ if (n.type === 'PathExpression') {
+ n.head.range = [
+ range[0] + docLines.positionToOffset(n.head.loc.start),
+ range[0] + docLines.positionToOffset(n.head.loc.end),
+ ];
+ n.head.loc = {
+ start: codeLines.offsetToPosition(n.head.range[0]),
+ end: codeLines.offsetToPosition(n.head.range[1]),
+ };
+ }
+ n.range =
+ n.type === 'Template'
+ ? [tpl.templateRange[0], tpl.templateRange[1]]
+ : [
+ range[0] + docLines.positionToOffset(n.loc.start),
+ range[0] + docLines.positionToOffset(n.loc.end),
+ ];
+
+ n.start = n.range[0];
+ n.end = n.range[1];
+ n.loc = {
+ start: codeLines.offsetToPosition(n.range[0]),
+ end: codeLines.offsetToPosition(n.range[1]),
+ };
+ if (n.type === 'Template') {
+ n.loc.start = codeLines.offsetToPosition(tpl.templateRange[0]);
+ n.loc.end = codeLines.offsetToPosition(tpl.templateRange[1]);
+ }
+ // split up element node into sub nodes to be able to reference tag name
+ // parts -> nodes for `Foo` and `Bar`
+ if (n.type === 'ElementNode') {
+ n.name = n.tag;
+ n.parts = [];
+ let start = n.range[0];
+ let codeSlice = code.slice(...n.range);
+ for (const part of n.tag.split('.')) {
+ const regex = new RegExp(`\\b${part}\\b`);
+ const match = codeSlice.match(regex);
+ const range = [start + match.index, 0];
+ range[1] = range[0] + part.length;
+ codeSlice = code.slice(range[1], n.range[1]);
+ start = range[1];
+ n.parts.push({
+ type: 'GlimmerElementNodePart',
+ name: part,
+ range,
+ parent: n,
+ loc: {
+ start: codeLines.offsetToPosition(range[0]),
+ end: codeLines.offsetToPosition(range[1]),
+ },
+ });
+ }
+ }
+ // block params do not have location information
+ // add our own nodes so we can reference them
+ if ('blockParams' in n) {
+ n.params = [];
+ }
+ if ('blockParams' in n && n.parent) {
+ let part = code.slice(...n.parent.range);
+ let start = n.parent.range[0];
+ let idx = part.indexOf('|') + 1;
+ start += idx;
+ part = part.slice(idx, -1);
+ idx = part.indexOf('|');
+ part = part.slice(0, idx);
+ for (const param of n.blockParams) {
+ const regex = new RegExp(`\\b${param}\\b`);
+ const match = part.match(regex);
+ const range = [start + match.index, 0];
+ range[1] = range[0] + param.length;
+ n.params.push({
+ type: 'BlockParam',
+ name: param,
+ range,
+ parent: n,
+ loc: {
+ start: codeLines.offsetToPosition(range[0]),
+ end: codeLines.offsetToPosition(range[1]),
+ },
+ });
+ }
+ }
+ n.type = `Glimmer${n.type}`;
+ allNodeTypes.add(n.type);
+ }
+ // ast should not contain comment nodes
+ for (const comment of comments) {
+ const parentBody = comment.parent.body || comment.parent.children;
+ const idx = parentBody.indexOf(comment);
+ parentBody.splice(idx, 1);
+ // comment type can be a block comment or a line comment
+ // mark comments as always block comment, this works for eslint in all cases
+ comment.type = 'Block';
+ }
+ // tokens should not contain tokens of comments
+ ast.tokens = ast.tokens.filter(
+ (t) => !comments.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
+ );
+ // tokens should not contain tokens of text nodes, but represent the whole node
+ // remove existing tokens
+ ast.tokens = ast.tokens.filter(
+ (t) => !textNodes.some((c) => c.range[0] <= t.range[0] && c.range[1] >= t.range[1])
+ );
+ // merge in text nodes
+ let currentTextNode = textNodes.pop();
+ for (let i = ast.tokens.length - 1; i >= 0; i--) {
+ const t = ast.tokens[i];
+ while (currentTextNode && t.range[0] < currentTextNode.range[0]) {
+ ast.tokens.splice(i + 1, 0, currentTextNode);
+ currentTextNode = textNodes.pop();
+ }
+ }
+ ast.contents = template;
+ tpl.ast = ast;
+ }
+ for (const [k, v] of Object.entries(glimmerVisitorKeys)) {
+ templateVisitorKeys[`Glimmer${k}`] = [...v];
+ }
+ return {
+ templateVisitorKeys,
+ templateInfos,
+ comments,
+ };
+};
+
+/**
+ * traverses the AST and replaces the transformed template parts with the Glimmer
+ * AST.
+ * This also creates the scopes for the Glimmer Blocks and registers the block params
+ * in the scope, and also any usages of variables in path expressions
+ * this allows the basic eslint rules no-undef and no-unsused to work also for the
+ * templates without needing any custom rules
+ * @param result
+ * @param preprocessedResult
+ * @param visitorKeys
+ */
+module.exports.convertAst = function convertAst(result, preprocessedResult, visitorKeys) {
+ const templateInfos = preprocessedResult.templateInfos;
+ let counter = 0;
+ result.ast.comments.push(...preprocessedResult.comments);
+
+ for (const ti of templateInfos) {
+ const firstIdx = result.ast.tokens.findIndex((t) => t.range[0] === ti.templateRange[0]);
+ const lastIdx = result.ast.tokens.findIndex((t) => t.range[1] === ti.templateRange[1]);
+ result.ast.tokens.splice(firstIdx, lastIdx - firstIdx + 1, ...ti.ast.tokens);
+ }
+
+ // eslint-disable-next-line complexity
+ traverse(visitorKeys, result.ast, (path) => {
+ const node = path.node;
+ if (
+ node.type === 'ExpressionStatement' ||
+ node.type === 'StaticBlock' ||
+ node.type === 'TemplateLiteral' ||
+ node.type === 'ExportDefaultDeclaration'
+ ) {
+ let range = node.range;
+ if (node.type === 'ExportDefaultDeclaration') {
+ range = [node.declaration.range[0], node.declaration.range[1]];
+ }
+
+ const template = templateInfos.find(
+ (t) => t.templateRange[0] === range[0] && t.templateRange[1] === range[1]
+ );
+ if (!template) {
+ return null;
+ }
+ counter++;
+ const ast = template.ast;
+ Object.assign(node, ast);
+ }
+
+ if (node.type === 'GlimmerPathExpression' && node.head.type === 'VarHead') {
+ const name = node.head.name;
+ if (glimmer.isKeyword(name)) {
+ return null;
+ }
+ const { scope, variable } = findVarInParentScopes(result.scopeManager, path, name) || {};
+ if (scope) {
+ node.head.parent = node;
+ registerNodeInScope(node.head, scope, variable);
+ }
+ }
+ if (node.type === 'GlimmerElementNode') {
+ // always reference first part of tag name, this also has the advantage
+ // that errors regarding this tag will only mark the tag name instead of
+ // the whole tag + children
+ const n = node.parts[0];
+ const { scope, variable } = findVarInParentScopes(result.scopeManager, path, n.name) || {};
+ if (
+ scope &&
+ (variable ||
+ isUpperCase(n.name[0]) ||
+ node.name.includes('.') ||
+ !htmlTags.includes(node.name))
+ ) {
+ registerNodeInScope(n, scope, variable);
+ }
+ }
+
+ if ('blockParams' in node) {
+ const upperScope = findParentScope(result.scopeManager, path);
+ const scope = result.isTypescript
+ ? new TypescriptScope.BlockScope(result.scopeManager, upperScope, node)
+ : new Scope(result.scopeManager, 'block', upperScope, node);
+ for (const [i, b] of node.params.entries()) {
+ const v = new Variable(b.name, scope);
+ v.identifiers.push(b);
+ v.defs.push(new Definition('Parameter', b, node, node, i, 'Block Param'));
+ scope.variables.push(v);
+ scope.set.set(b.name, v);
+ }
+ }
+ return null;
+ });
+
+ if (counter !== templateInfos.length) {
+ throw new Error('failed to process all templates');
+ }
+};
+
+function replaceRange(s, start, end, substitute) {
+ return s.slice(0, start) + substitute + s.slice(end);
+}
+
+const processor = new ContentTag.Preprocessor();
+
+module.exports.transformForLint = function transformForLint(code) {
+ let jsCode = code;
+ /**
+ *
+ * @type {{
+ * type: 'expression' | 'class-member';
+ * tagName: 'template';
+ * contents: string;
+ * range: {
+ * start: number;
+ * end: number;
+ * };
+ * contentRange: {
+ * start: number;
+ * end: number;
+ * };
+ * startRange: {
+ * end: number;
+ * start: number;
+ * };
+ * endRange: {
+ * start: number;
+ * end: number;
+ * };
+ * }[]}
+ */
+ const result = processor.parse(code);
+ for (const tplInfo of result.reverse()) {
+ const lineBreaks = [...tplInfo.contents].reduce(
+ (prev, curr) => prev + (DocumentLines.isLineBreak(curr.codePointAt(0)) ? 1 : 0),
+ 0
+ );
+ if (tplInfo.type === 'class-member') {
+ const tplLength = tplInfo.range.end - tplInfo.range.start;
+ const spaces = tplLength - 'static{`'.length - '`}'.length - lineBreaks;
+ const total = ' '.repeat(spaces) + '\n'.repeat(lineBreaks);
+ const replacementCode = `static{\`${total}\`}`;
+ jsCode = replaceRange(jsCode, tplInfo.range.start, tplInfo.range.end, replacementCode);
+ } else {
+ const tplLength = tplInfo.range.end - tplInfo.range.start;
+ const spaces = tplLength - '`'.length - '`'.length - lineBreaks;
+ const total = ' '.repeat(spaces) + '\n'.repeat(lineBreaks);
+ const replacementCode = `\`${total}\``;
+ jsCode = replaceRange(jsCode, tplInfo.range.start, tplInfo.range.end, replacementCode);
+ }
+ }
+ if (jsCode.length !== code.length) {
+ throw new Error('bad transform');
+ }
+ return {
+ templateInfos: result,
+ output: jsCode,
+ };
+};
diff --git a/lib/parsers/ts-utils.js b/lib/parsers/ts-utils.js
new file mode 100644
index 0000000000..2dae5d3ac4
--- /dev/null
+++ b/lib/parsers/ts-utils.js
@@ -0,0 +1,63 @@
+const fs = require('node:fs');
+const ts = require('typescript');
+const { transformForLint } = require('./transform');
+
+const TsProgramMap = {};
+const ProcessedFiles = new Set();
+
+module.exports.getTsProgram = function getTsProgram(projectFile, filePath, code) {
+ // we need to recreate the program if a file is processed multiple times
+ // this can happen during tests and when eslint is fixing the files
+ // during fixing, eslint can do multiple cycles
+ if (!TsProgramMap[projectFile] || ProcessedFiles.has(filePath)) {
+ ProcessedFiles.add(filePath);
+ const config = ts.getParsedCommandLineOfConfigFile(
+ projectFile,
+ {},
+ {
+ ...ts.sys,
+ readDirectory(dir) {
+ const results = ts.sys.readDirectory(dir);
+ return results.map((f) => f.replace(/\.gts$/, '.ts'));
+ },
+ }
+ );
+
+ const host = ts.createCompilerHost(config.options);
+ host.fileExists = function (fileName) {
+ return fs.existsSync(fileName.replace(/\.ts$/, '.gts')) || fs.existsSync(fileName);
+ };
+
+ const readDirectory = host.readDirectory;
+ host.readDirectory = function (dir) {
+ const results = readDirectory.call(this, dir);
+ return results.map((f) => f.replace(/\.gts$/, '.ts'));
+ };
+ host.readFile = function (fname) {
+ if (fname === filePath.replaceAll('\\', '/')) {
+ return code;
+ }
+ let fileName = fname;
+ let content = '';
+ try {
+ content = fs.readFileSync(fileName).toString();
+ } catch {
+ fileName = fileName.replace(/\.ts$/, '.gts');
+ content = fs.readFileSync(fileName).toString();
+ }
+ if (fileName.endsWith('.gts')) {
+ content = transformForLint(content).output;
+ }
+ content = content.replaceAll(/\.gts(["'])/g, '.ts$1 ');
+ return content;
+ };
+
+ TsProgramMap[projectFile] = ts.createProgram({
+ rootNames: config.fileNames,
+ options: config.options,
+ host,
+ });
+ }
+
+ return TsProgramMap[projectFile];
+};
diff --git a/package.json b/package.json
index ba30193bf8..74b70894e1 100644
--- a/package.json
+++ b/package.json
@@ -83,7 +83,8 @@
"lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1",
"requireindex": "^1.2.0",
- "snake-case": "^3.0.3"
+ "snake-case": "^3.0.3",
+ "typescript": "^5.2.2"
},
"devDependencies": {
"@babel/plugin-proposal-class-properties": "^7.18.6",
@@ -112,8 +113,7 @@
"npm-run-all": "^4.1.5",
"prettier": "^3.0.3",
"release-it": "^16.2.1",
- "sort-package-json": "^2.6.0",
- "typescript": "^5.2.2"
+ "sort-package-json": "^2.6.0"
},
"peerDependencies": {
"eslint": ">= 8"
diff --git a/tests/lib/rules-preprocessor/gjs-gts-parser-test.js b/tests/lib/rules-preprocessor/gjs-gts-parser-test.js
index c80a7784e7..5100c5945a 100644
--- a/tests/lib/rules-preprocessor/gjs-gts-parser-test.js
+++ b/tests/lib/rules-preprocessor/gjs-gts-parser-test.js
@@ -828,8 +828,8 @@ describe('multiple tokens in same file', () => {
const resultErrors = results.flatMap((result) => result.messages);
expect(resultErrors).toHaveLength(3);
- expect(resultErrors[0].line).toBe(6);
expect(resultErrors[0].message).toBe("Use 'String#startsWith' method instead."); // Actual result is "Unsafe member access [0] on an `any` value."
+ expect(resultErrors[0].line).toBe(6);
expect(resultErrors[1].line).toBe(7);
expect(resultErrors[1].message).toBe("Use 'String#startsWith' method instead.");