diff --git a/lib/parsers/gjs-gts-parser.js b/lib/parsers/gjs-gts-parser.js index fdf0d0747d..18f61a283e 100644 --- a/lib/parsers/gjs-gts-parser.js +++ b/lib/parsers/gjs-gts-parser.js @@ -1,522 +1,14 @@ -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 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 { + patchTs, + replaceExtensions, + syncMtsGtsSourceFiles, + typescriptParser, +} = require('./ts-utils'); +const { transformForLint, preprocessGlimmerTemplates, convertAst } = require('./transform'); -/** - * 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 === 'TaggedTemplateExpression' || - 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] || t.templateRange[1] === range[1] + 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); -} - -function transformForLint(code) { - let jsCode = code; - const processor = new ContentTag.Preprocessor(); - /** - * - * @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, - }; -} +patchTs(); /** * implements https://eslint.org/docs/latest/extend/custom-parsers @@ -539,8 +31,17 @@ module.exports = { const isTypescript = options.filePath.endsWith('.gts'); let result = null; + const filePath = options.filePath; + if (options.project) { + jsCode = replaceExtensions(jsCode); + } + + if (isTypescript && !typescriptParser) { + throw new Error('Please install typescript to process gts'); + } + result = isTypescript - ? typescriptParser.parseForESLint(jsCode, { ...options, ranges: true }) + ? typescriptParser.parseForESLint(jsCode, { ...options, ranges: true, filePath }) : babelParser.parseForESLint(jsCode, { ...options, ranges: true }); if (!info.templateInfos?.length) { return result; @@ -550,6 +51,9 @@ module.exports = { const visitorKeys = { ...result.visitorKeys, ...templateVisitorKeys }; result.isTypescript = isTypescript; convertAst(result, preprocessedResult, visitorKeys); + if (result.services?.program) { + syncMtsGtsSourceFiles(result.services?.program); + } return { ...result, visitorKeys }; }, }; diff --git a/lib/parsers/transform.js b/lib/parsers/transform.js new file mode 100644 index 0000000000..61b62aebed --- /dev/null +++ b/lib/parsers/transform.js @@ -0,0 +1,519 @@ +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 === 'TaggedTemplateExpression' || + 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] || t.templateRange[1] === range[1] + 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'); + } +}; + +const replaceRange = function replaceRange(s, start, end, substitute) { + return s.slice(0, start) + substitute + s.slice(end); +}; +module.exports.replaceRange = replaceRange; + +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); + } + } + /* istanbul ignore next */ + 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..027c1fa4f1 --- /dev/null +++ b/lib/parsers/ts-utils.js @@ -0,0 +1,117 @@ +const fs = require('node:fs'); +const { transformForLint } = require('./transform'); +const babel = require('@babel/core'); +const { replaceRange } = require('./transform'); + +let patchTs, replaceExtensions, syncMtsGtsSourceFiles, typescriptParser; + +try { + const ts = require('typescript'); + typescriptParser = require('@typescript-eslint/parser'); + patchTs = function patchTs() { + const sys = { ...ts.sys }; + const newSys = { + ...ts.sys, + readDirectory(...args) { + const results = sys.readDirectory.call(this, ...args); + return [ + ...results, + ...results.filter((x) => x.endsWith('.gts')).map((f) => f.replace(/\.gts$/, '.mts')), + ]; + }, + fileExists(fileName) { + return fs.existsSync(fileName.replace(/\.mts$/, '.gts')) || fs.existsSync(fileName); + }, + readFile(fname) { + let fileName = fname; + let content = ''; + try { + content = fs.readFileSync(fileName).toString(); + } catch { + fileName = fileName.replace(/\.mts$/, '.gts'); + content = fs.readFileSync(fileName).toString(); + } + if (fileName.endsWith('.gts')) { + content = transformForLint(content).output; + } + if ( + (!fileName.endsWith('.d.ts') && fileName.endsWith('.ts')) || + fileName.endsWith('.gts') + ) { + content = replaceExtensions(content); + } + return content; + }, + }; + ts.setSys(newSys); + }; + + replaceExtensions = function replaceExtensions(code) { + let jsCode = code; + const babelParseResult = babel.parse(jsCode, { + parserOpts: { ranges: true, plugins: ['typescript'] }, + }); + const length = jsCode.length; + for (const b of babelParseResult.program.body) { + if (b.type === 'ImportDeclaration' && b.source.value.endsWith('.gts')) { + const value = b.source.value.replace(/\.gts$/, '.mts'); + const strWrapper = jsCode[b.source.start]; + jsCode = replaceRange( + jsCode, + b.source.start, + b.source.end, + strWrapper + value + strWrapper + ); + } + } + if (length !== jsCode.length) { + throw new Error('bad replacement'); + } + return jsCode; + }; + + /** + * + * @param program {ts.Program} + */ + syncMtsGtsSourceFiles = function syncMtsGtsSourceFiles(program) { + const sourceFiles = program.getSourceFiles(); + for (const sourceFile of sourceFiles) { + // check for deleted gts files, need to remove mts as well + if (sourceFile.path.endsWith('.mts') && sourceFile.isVirtualGts) { + const gtsFile = program.getSourceFile(sourceFile.path.replace(/\.mts$/, '.gts')); + if (!gtsFile) { + sourceFile.version = null; + } + } + if (sourceFile.path.endsWith('.gts')) { + /** + * @type {ts.SourceFile} + */ + const mtsSourceFile = program.getSourceFile(sourceFile.path.replace(/\.gts$/, '.mts')); + if (mtsSourceFile) { + const keep = { + fileName: mtsSourceFile.fileName, + path: mtsSourceFile.path, + originalFileName: mtsSourceFile.originalFileName, + resolvedPath: mtsSourceFile.resolvedPath, + }; + Object.assign(mtsSourceFile, sourceFile, keep); + mtsSourceFile.isVirtualGts = true; + } + } + } + }; +} catch /* istanbul ignore next */ { + // typescript not available + patchTs = () => null; + replaceExtensions = (code) => code; + syncMtsGtsSourceFiles = () => null; +} + +module.exports = { + patchTs, + replaceExtensions, + syncMtsGtsSourceFiles, + typescriptParser, +}; diff --git a/package.json b/package.json index ba30193bf8..01f037bcdc 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ ] }, "dependencies": { + "@babel/core": "^7.23.3", "@babel/eslint-parser": "^7.22.15", "@ember-data/rfc395-data": "^0.0.4", "@glimmer/syntax": "^0.85.12", @@ -116,7 +117,13 @@ "typescript": "^5.2.2" }, "peerDependencies": { - "eslint": ">= 8" + "eslint": ">= 8", + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } }, "engines": { "node": "18.* || 20.* || >= 21" diff --git a/tests/lib/rules-preprocessor/ember_ts/bar.gts b/tests/lib/rules-preprocessor/ember_ts/bar.gts new file mode 100644 index 0000000000..32a5baebb3 --- /dev/null +++ b/tests/lib/rules-preprocessor/ember_ts/bar.gts @@ -0,0 +1,5 @@ +export const fortyTwoFromGTS = '42'; + + diff --git a/tests/lib/rules-preprocessor/ember_ts/baz.ts b/tests/lib/rules-preprocessor/ember_ts/baz.ts new file mode 100644 index 0000000000..84e700e1a6 --- /dev/null +++ b/tests/lib/rules-preprocessor/ember_ts/baz.ts @@ -0,0 +1 @@ +export const fortyTwoFromTS = '42'; diff --git a/tests/lib/rules-preprocessor/ember_ts/foo.gts b/tests/lib/rules-preprocessor/ember_ts/foo.gts new file mode 100644 index 0000000000..35aaba7c6b --- /dev/null +++ b/tests/lib/rules-preprocessor/ember_ts/foo.gts @@ -0,0 +1,14 @@ +import { fortyTwoFromGTS } from './bar.gts'; +import { fortyTwoFromTS } from './baz.ts'; + +export const fortyTwoLocal = '42'; + +const helloWorldFromTS = fortyTwoFromTS[0] === '4' ? 'hello' : 'world'; +const helloWorldFromGTS = fortyTwoFromGTS[0] === '4' ? 'hello' : 'world'; +const helloWorld = fortyTwoLocal[0] === '4' ? 'hello' : 'world'; +// + diff --git a/tests/lib/rules-preprocessor/gjs-gts-parser-test.js b/tests/lib/rules-preprocessor/gjs-gts-parser-test.js index fddf56b539..a98e296bd7 100644 --- a/tests/lib/rules-preprocessor/gjs-gts-parser-test.js +++ b/tests/lib/rules-preprocessor/gjs-gts-parser-test.js @@ -9,6 +9,8 @@ const { ESLint } = require('eslint'); const plugin = require('../../../lib'); +const { writeFileSync, readFileSync } = require('node:fs'); +const { join } = require('node:path'); const gjsGtsParser = require.resolve('../../../lib/parsers/gjs-gts-parser'); @@ -388,18 +390,28 @@ const invalid = [ code: ` import Component from '@glimmer/component'; + const foo: any = ''; + export default class MyComponent extends Component { foo = 'bar'; }`, errors: [ + { + message: 'Unexpected any. Specify a different type.', + line: 4, + endLine: 4, + column: 18, + endColumn: 21, + }, { message: 'Trailing spaces not allowed.', - line: 8, - endLine: 8, + line: 10, + endLine: 10, column: 22, endColumn: 24, }, @@ -765,4 +777,113 @@ describe('multiple tokens in same file', () => { expect(resultErrors[2].message).toBe("'bar' is not defined."); expect(resultErrors[2].line).toBe(17); }); + + it('lints while being type aware', async () => { + const eslint = new ESLint({ + ignore: false, + useEslintrc: false, + plugins: { ember: plugin }, + overrideConfig: { + root: true, + env: { + browser: true, + }, + plugins: ['ember'], + extends: ['plugin:ember/recommended'], + overrides: [ + { + files: ['**/*.gts'], + parser: 'eslint-plugin-ember/gjs-gts-parser', + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname, + extraFileExtensions: ['.gts'], + }, + extends: [ + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:ember/recommended', + ], + rules: { + 'no-trailing-spaces': 'error', + '@typescript-eslint/prefer-string-starts-ends-with': 'error', + }, + }, + { + files: ['**/*.ts'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.eslint.json', + tsconfigRootDir: __dirname, + extraFileExtensions: ['.gts'], + }, + extends: [ + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:ember/recommended', + ], + rules: { + 'no-trailing-spaces': 'error', + }, + }, + ], + rules: { + quotes: ['error', 'single'], + semi: ['error', 'always'], + 'object-curly-spacing': ['error', 'always'], + 'lines-between-class-members': 'error', + 'no-undef': 'error', + 'no-unused-vars': 'error', + 'ember/no-get': 'off', + 'ember/no-array-prototype-extensions': 'error', + 'ember/no-unused-services': 'error', + }, + }, + }); + + let results = await eslint.lintFiles(['**/*.gts', '**/*.ts']); + + let resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(3); + + expect(resultErrors[0].message).toBe("Use 'String#startsWith' method instead."); + expect(resultErrors[0].line).toBe(6); + + expect(resultErrors[1].line).toBe(7); + expect(resultErrors[1].message).toBe("Use 'String#startsWith' method instead."); + + expect(resultErrors[2].line).toBe(8); + expect(resultErrors[2].message).toBe("Use 'String#startsWith' method instead."); + + const filePath = join(__dirname, 'ember_ts', 'bar.gts'); + const content = readFileSync(filePath).toString(); + try { + writeFileSync(filePath, content.replace("'42'", '42')); + + results = await eslint.lintFiles(['**/*.gts', '**/*.ts']); + + resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(2); + + expect(resultErrors[0].message).toBe("Use 'String#startsWith' method instead."); + expect(resultErrors[0].line).toBe(6); + + expect(resultErrors[1].line).toBe(8); + expect(resultErrors[1].message).toBe("Use 'String#startsWith' method instead."); + } finally { + writeFileSync(filePath, content); + } + + results = await eslint.lintFiles(['**/*.gts', '**/*.ts']); + + resultErrors = results.flatMap((result) => result.messages); + expect(resultErrors).toHaveLength(3); + + expect(resultErrors[0].message).toBe("Use 'String#startsWith' method instead."); + expect(resultErrors[0].line).toBe(6); + + expect(resultErrors[1].message).toBe("Use 'String#startsWith' method instead."); + expect(resultErrors[1].line).toBe(7); + + expect(resultErrors[2].line).toBe(8); + expect(resultErrors[2].message).toBe("Use 'String#startsWith' method instead."); + }); }); diff --git a/tests/lib/rules-preprocessor/tsconfig.eslint.json b/tests/lib/rules-preprocessor/tsconfig.eslint.json index 2767865654..b58959351c 100644 --- a/tests/lib/rules-preprocessor/tsconfig.eslint.json +++ b/tests/lib/rules-preprocessor/tsconfig.eslint.json @@ -5,6 +5,7 @@ "strictNullChecks": true }, "include": [ - "*" - ] + "**/*.ts", + "**/*.gts" + ], } diff --git a/yarn.lock b/yarn.lock index 7635799ce3..af1be9943c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -49,6 +49,27 @@ json5 "^2.2.3" semver "^6.3.1" +"@babel/core@^7.23.3": + version "7.23.3" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.23.3.tgz#5ec09c8803b91f51cc887dedc2654a35852849c9" + integrity sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.22.13" + "@babel/generator" "^7.23.3" + "@babel/helper-compilation-targets" "^7.22.15" + "@babel/helper-module-transforms" "^7.23.3" + "@babel/helpers" "^7.23.2" + "@babel/parser" "^7.23.3" + "@babel/template" "^7.22.15" + "@babel/traverse" "^7.23.3" + "@babel/types" "^7.23.3" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + "@babel/eslint-parser@^7.22.15": version "7.22.15" resolved "https://registry.yarnpkg.com/@babel/eslint-parser/-/eslint-parser-7.22.15.tgz#263f059c476e29ca4972481a17b8b660cb025a34" @@ -68,6 +89,16 @@ "@jridgewell/trace-mapping" "^0.3.17" jsesc "^2.5.1" +"@babel/generator@^7.23.3": + version "7.23.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.3.tgz#86e6e83d95903fbe7613f448613b8b319f330a8e" + integrity sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg== + dependencies: + "@babel/types" "^7.23.3" + "@jridgewell/gen-mapping" "^0.3.2" + "@jridgewell/trace-mapping" "^0.3.17" + jsesc "^2.5.1" + "@babel/helper-annotate-as-pure@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz#e7f06737b197d580a01edf75d97e2c8be99d3882" @@ -146,6 +177,17 @@ "@babel/helper-split-export-declaration" "^7.22.6" "@babel/helper-validator-identifier" "^7.22.20" +"@babel/helper-module-transforms@^7.23.3": + version "7.23.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz#d7d12c3c5d30af5b3c0fcab2a6d5217773e2d0f1" + integrity sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ== + dependencies: + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-module-imports" "^7.22.15" + "@babel/helper-simple-access" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.22.6" + "@babel/helper-validator-identifier" "^7.22.20" + "@babel/helper-optimise-call-expression@^7.22.5": version "7.22.5" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz#f21531a9ccbff644fdd156b4077c16ff0c3f609e" @@ -226,6 +268,11 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719" integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw== +"@babel/parser@^7.23.3": + version "7.23.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.3.tgz#0ce0be31a4ca4f1884b5786057cadcb6c3be58f9" + integrity sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw== + "@babel/plugin-proposal-class-properties@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3" @@ -375,6 +422,22 @@ debug "^4.1.0" globals "^11.1.0" +"@babel/traverse@^7.23.3": + version "7.23.3" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.3.tgz#26ee5f252e725aa7aca3474aa5b324eaf7908b5b" + integrity sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ== + dependencies: + "@babel/code-frame" "^7.22.13" + "@babel/generator" "^7.23.3" + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-function-name" "^7.23.0" + "@babel/helper-hoist-variables" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.22.6" + "@babel/parser" "^7.23.3" + "@babel/types" "^7.23.3" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.15", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.3.3": version "7.23.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb" @@ -384,6 +447,15 @@ "@babel/helper-validator-identifier" "^7.22.20" to-fast-properties "^2.0.0" +"@babel/types@^7.23.3": + version "7.23.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.3.tgz#d5ea892c07f2ec371ac704420f4dcdb07b5f9598" + integrity sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw== + dependencies: + "@babel/helper-string-parser" "^7.22.5" + "@babel/helper-validator-identifier" "^7.22.20" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"