Skip to content

Commit

Permalink
use custom parser for gts/gjs
Browse files Browse the repository at this point in the history
bonus:
 * prettier eslint plugin (with template tag prettier plugin) will just work for gts/gjs
 * can detect unused block params in templates
 * can detect undef vars in PathExpression
 * can add eslint directive comments in mustache or html
disadvantage:
* prettier will not work without template tag prettier plugin for gts/gjs files
  • Loading branch information
patricklx committed Aug 1, 2023
1 parent 900e002 commit 0ce038c
Show file tree
Hide file tree
Showing 12 changed files with 1,749 additions and 2,282 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
strategy:
matrix:
os: [ ubuntu, windows ]
node-version: [14.x, 16.x, 18.x]
node-version: [16.x, 18.x]

steps:
- uses: actions/checkout@v3
Expand Down
12 changes: 6 additions & 6 deletions lib/config/recommended.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const rules = require('../recommended-rules');
const util = require('ember-template-imports/src/util');

module.exports = {
root: true,
Expand Down Expand Up @@ -29,11 +28,12 @@ module.exports = {
* on -- and isn't relevant to user-land code.
*/
{
files: ['**/*.gjs', '**/*.gts'],
processor: 'ember/<template>',
globals: {
[util.TEMPLATE_TAG_PLACEHOLDER]: 'readonly',
},
files: ['**/*.gts'],
parser: require.resolve('../parsers/gts-parser'),
},
{
files: ['**/*.gjs'],
parser: require.resolve('../parsers/gjs-parser'),
},
],
};
8 changes: 0 additions & 8 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,10 @@

const requireIndex = require('requireindex');

const gjs = require('./preprocessors/glimmer');

module.exports = {
rules: requireIndex(`${__dirname}/rules`),
configs: requireIndex(`${__dirname}/config`),
utils: {
ember: require('./utils/ember'),
},
processors: {
// https://eslint.org/docs/developer-guide/working-with-plugins#file-extension-named-processor
'.gjs': gjs,
'.gts': gjs,
'<template>': gjs,
},
};
278 changes: 278 additions & 0 deletions lib/parsers/gjs-parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
const gts = require('ember-template-tag');
const glimmer = require('@glimmer/syntax');
const DocumentLines = require('../utils/document');
const path = require('path');
// eslint-disable-next-line import/no-dynamic-require
const glimmerVisitorKeys = require(path.join(
path.dirname(require.resolve('@glimmer/syntax')),
'lib/v1/visitor-keys'
)).default;
const babelParser = require('@babel/eslint-parser');
const typescriptParser = require('@typescript-eslint/parser');
// eslint-disable-next-line node/no-missing-require
const TypescriptScope = require('@typescript-eslint/scope-manager');
const { Reference, Scope, Variable, Definition } = require('eslint-scope');

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;
}

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;
}
if (!scope) {
return { scope: findParentScope(scopeManager, nodePath) };
}
return { scope, variable: scope.set.get(name) };
}

function registerNodeInScope(node, scope, variable) {
const ref = new Reference(node, scope, Reference.READ);
if (variable) {
variable.references.push(ref);
ref.resolved = variable;
} else {
scope.through.push(ref);
scope.upper.through.push(ref);
}
scope.references.push(ref);
}

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 preprocessGlimmerTemplates(info, code) {
const templateInfos = info.replacements.map((r) => ({
range: r.original.contentRange,
templateRange: r.original.range,
replacedRange: r.replaced.range,
}));
const templateVisitorKeys = {};
const codeLines = new DocumentLines(code);
const comments = [];
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' });
const allNodes = [];
glimmer.traverse(ast, {
All(node) {
allNodes.push(node);
if (node.type === 'CommentStatement' || node.type === 'MustacheCommentStatement') {
comments.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.replacedRange[0], tpl.replacedRange[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]);
}
n.type = `Glimmer__${n.type}`;
allNodeTypes.add(n.type);
}
ast.contents = template;
tpl.ast = ast;
}
for (const [k, v] of Object.entries(glimmerVisitorKeys)) {
templateVisitorKeys[`Glimmer__${k}`] = [...v];
}
templateVisitorKeys['Glimmer__PathExpression']?.push('identifier', 'member');
return {
templateVisitorKeys,
templateInfos,
comments,
};
}

function convertAst(result, preprocessedResult, visitorKeys) {
const templateInfos = preprocessedResult.templateInfos;
let counter = 0;
result.ast.comments.push(...preprocessedResult.comments);
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.replacedRange[0] === range[0] && t.replacedRange[1] === range[1]
);
if (!template) {
return null;
}
counter++;
const ast = template.ast;
Object.assign(node, ast);
}

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.blockParams.entries()) {
const v = new Variable(b, scope);
const nameNode = {
type: 'BlockParam',
loc: node.loc,
parent: node,
name: b,
};
v.identifiers.push(nameNode);
v.defs.push(new Definition('Parameter', nameNode, node, node, i, 'Block Param'));
scope.variables.push(v);
scope.set.set(b, v);
}
}

if (node.type === 'Glimmer__PathExpression' && 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 === 'Glimmer__ElementNode' && isUpperCase(node.tag[0])) {
node.name = node.tag;
const { scope, variable } = findVarInParentScopes(result.scopeManager, path, node.tag) || {};
if (scope) {
registerNodeInScope(node, scope, variable);
}
}
return null;
});

if (counter !== templateInfos.length) {
throw new Error('failed to process all templates');
}
}

module.exports = {
parseForESLint(code, options, isTypescript) {
let jsCode = code;
const info = gts.transformForLint({
input: jsCode,
templateTag: 'template',
explicitMode: true,
linterMode: true,
});
jsCode = info.output;

let result = null;
result = isTypescript
? typescriptParser.parseForESLint(jsCode, { ...options, ranges: true })
: babelParser.parseForESLint(jsCode, { ...options, ranges: true });
if (!info.replacements?.length) {
return result;
}
const preprocessedResult = preprocessGlimmerTemplates(info, code);
const { templateVisitorKeys } = preprocessedResult;
const visitorKeys = { ...result.visitorKeys, ...templateVisitorKeys };
result.isTypescript = isTypescript;
convertAst(result, preprocessedResult, visitorKeys);
return { ...result, visitorKeys };
},
};
7 changes: 7 additions & 0 deletions lib/parsers/gts-parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const gjsParser = require('./gjs-parser');

module.exports = {
parseForESLint(code, options) {
return gjsParser.parseForESLint(code, options, true);
},
};

0 comments on commit 0ce038c

Please sign in to comment.