UNPKG

@sanity/codegen

Version:

Codegen toolkit for Sanity.io

514 lines (471 loc) • 12.2 kB
import fs from 'node:fs' import path from 'node:path' import {type TransformOptions} from '@babel/core' import traverse, {Scope} from '@babel/traverse' import * as babelTypes from '@babel/types' import createDebug from 'debug' import {parseSourceFile} from './parseSource' const debug = createDebug('sanity:codegen:findQueries:debug') type resolveExpressionReturnType = string /** * NamedQueryResult is a result of a named query */ export interface NamedQueryResult { /** name is the name of the query */ name: string /** result is a groq query */ result: resolveExpressionReturnType /** location is the location of the query in the source */ location: { start?: { line: number column: number index: number } end?: { line: number column: number index: number } } } const TAGGED_TEMPLATE_ALLOW_LIST = ['groq'] const FUNCTION_WRAPPER_ALLOW_LIST = ['defineQuery'] /** * resolveExpression takes a node and returns the resolved value of the expression. * @beta * @internal */ export function resolveExpression({ node, file, scope, filename, resolver, babelConfig, params = [], fnArguments = [], }: { node: babelTypes.Node file: babelTypes.File scope: Scope filename: string resolver: NodeJS.RequireResolve babelConfig: TransformOptions params?: babelTypes.Node[] fnArguments?: babelTypes.Node[] }): resolveExpressionReturnType { debug( `Resolving node ${node.type} in ${filename}:${node.loc?.start.line}:${node.loc?.start.column}`, ) if ( babelTypes.isTaggedTemplateExpression(node) && babelTypes.isIdentifier(node.tag) && TAGGED_TEMPLATE_ALLOW_LIST.includes(node.tag.name) ) { return resolveExpression({ node: node.quasi, scope, filename, file, resolver, params, babelConfig, fnArguments, }) } if (babelTypes.isTemplateLiteral(node)) { const resolvedExpressions = node.expressions.map((expression) => resolveExpression({ node: expression, scope, filename, file, resolver, params, babelConfig, fnArguments, }), ) return node.quasis .map((quasi, idx) => { return (quasi.value.cooked || '') + (resolvedExpressions[idx] || '') }) .join('') } if (babelTypes.isLiteral(node)) { if (node.type === 'NullLiteral' || node.type === 'RegExpLiteral') { throw new Error(`Unsupported literal type: ${node.type}`) } return node.value.toString() } if (babelTypes.isIdentifier(node)) { return resolveIdentifier({ node, scope, filename, file, resolver, fnArguments, babelConfig, params, }) } if (babelTypes.isVariableDeclarator(node)) { const init = node.init ?? (babelTypes.isAssignmentPattern(node.id) && node.id.right) if (!init) { throw new Error(`Unsupported variable declarator`) } return resolveExpression({ node: init, fnArguments, scope, filename, file, babelConfig, resolver, }) } if ( babelTypes.isCallExpression(node) && babelTypes.isIdentifier(node.callee) && FUNCTION_WRAPPER_ALLOW_LIST.includes(node.callee.name) ) { return resolveExpression({ node: node.arguments[0], scope, filename, file, resolver, babelConfig, params, }) } if (babelTypes.isCallExpression(node)) { return resolveCallExpression({ node, scope, filename, file, resolver, babelConfig, params, fnArguments, }) } if ( babelTypes.isArrowFunctionExpression(node) || babelTypes.isFunctionDeclaration(node) || babelTypes.isFunctionExpression(node) ) { const newScope = new Scope(scope.path, scope) params.forEach((param, i) => { newScope.push({ id: param as babelTypes.LVal, init: fnArguments[i] as babelTypes.Expression | undefined, }) }) return resolveExpression({ node: node.body, params: node.params, fnArguments, scope: newScope, filename, file, babelConfig, resolver, }) } if (babelTypes.isNewExpression(node)) { return resolveExpression({ node: node.callee, scope, filename, file, babelConfig, resolver, }) } if (babelTypes.isImportDefaultSpecifier(node) || babelTypes.isImportSpecifier(node)) { return resolveImportSpecifier({node, file, scope, filename, fnArguments, resolver, babelConfig}) } if (babelTypes.isAssignmentPattern(node)) { return resolveExpression({ node: node.right, scope, filename, file, resolver, params, babelConfig, fnArguments, }) } throw new Error( `Unsupported expression type: ${node.type} in ${filename}:${node.loc?.start.line}:${node.loc?.start.column}`, ) } function resolveIdentifier({ node, scope, filename, file, resolver, babelConfig, fnArguments, params, }: { node: babelTypes.Identifier file: babelTypes.File scope: Scope filename: string resolver: NodeJS.RequireResolve babelConfig: TransformOptions fnArguments: babelTypes.Node[] params: babelTypes.Node[] }): resolveExpressionReturnType { const paramIndex = params.findIndex( (param) => (babelTypes.isIdentifier(param) && node.name === param.name) || (babelTypes.isAssignmentPattern(param) && babelTypes.isIdentifier(param.left) && node.name === param.left.name), ) let argument = fnArguments[paramIndex] if (!argument && paramIndex >= 0 && babelTypes.isAssignmentPattern(params[paramIndex])) { argument = params[paramIndex].right } if (argument && babelTypes.isLiteral(argument)) { return resolveExpression({ node: argument, scope, filename, file, resolver, params, babelConfig, fnArguments, }) } const binding = scope.getBinding(node.name) if (binding) { if (babelTypes.isIdentifier(binding.path.node)) { const isSame = binding.path.node.name === node.name if (isSame) { throw new Error( `Could not resolve same identifier "${node.name}" in "${filename}:${node.loc?.start.line}:${node.loc?.start.column}"`, ) } } return resolveExpression({ node: binding.path.node, params, fnArguments, scope, filename, babelConfig, file, resolver, }) } throw new Error( `Could not find binding for node "${node.name}" in ${filename}:${node.loc?.start.line}:${node.loc?.start.column}`, ) } function resolveCallExpression({ node, scope, filename, file, resolver, babelConfig, params, }: { node: babelTypes.CallExpression file: babelTypes.File scope: Scope filename: string resolver: NodeJS.RequireResolve babelConfig: TransformOptions fnArguments: babelTypes.Node[] params: babelTypes.Node[] }): resolveExpressionReturnType { const {callee} = node return resolveExpression({ node: callee, scope, filename, file, resolver, babelConfig, params, fnArguments: node.arguments, }) } function resolveImportSpecifier({ node, file, filename, fnArguments, resolver, babelConfig, }: { node: babelTypes.ImportDefaultSpecifier | babelTypes.ImportSpecifier | babelTypes.ExportSpecifier file: babelTypes.File scope: Scope filename: string fnArguments: babelTypes.Node[] resolver: NodeJS.RequireResolve babelConfig: TransformOptions }): resolveExpressionReturnType { let importDeclaration: babelTypes.ImportDeclaration | undefined traverse(file, { ImportDeclaration(n) { if (!babelTypes.isImportDeclaration(n.node)) { return } for (const specifier of n.node.specifiers) { if (babelTypes.isImportDefaultSpecifier(specifier)) { if (specifier.local.loc?.identifierName === node.local.name) { importDeclaration = n.node break } } if (specifier.local.name === node.local.name) { importDeclaration = n.node } } }, }) if (!importDeclaration) { throw new Error(`Could not find import declaration for ${node.local.name}`) } const importName = node.local.name // the name of the variable to import const importFileName = importDeclaration.source.value // the file to import from const importPath = importFileName.startsWith('./') || importFileName.startsWith('../') ? path.resolve(path.dirname(filename), importFileName) : importFileName const resolvedFile = resolver(importPath) const source = fs.readFileSync(resolvedFile) const tree = parseSourceFile(source.toString(), resolvedFile, babelConfig) let newScope: Scope | undefined traverse(tree, { Program(p) { newScope = p.scope }, }) if (!newScope) { throw new Error(`Could not find scope for ${filename}`) } const binding = newScope.getBinding(importName) if (binding) { return resolveExpression({ node: binding.path.node, file: tree, scope: newScope, fnArguments, babelConfig, filename: resolvedFile, resolver, }) } // It's not a global binding, but it might be a named export let namedExport: babelTypes.ExportNamedDeclaration | undefined let newImportName: string | undefined traverse(tree, { ExportDeclaration(p) { if (p.node.type === 'ExportNamedDeclaration') { for (const specifier of p.node.specifiers) { if ( specifier.type === 'ExportSpecifier' && specifier.exported.type === 'Identifier' && specifier.exported.name === importName ) { namedExport = p.node newImportName = specifier.exported.name } } } }, }) if (namedExport && newImportName) { return resolveExportSpecifier({ node: namedExport, importName: newImportName, filename: resolvedFile, fnArguments, resolver, babelConfig, }) } let result: resolveExpressionReturnType | undefined traverse(tree, { ExportDeclaration(p) { if (p.node.type === 'ExportAllDeclaration') { try { result = resolveExportSpecifier({ node: p.node, importName, filename: resolvedFile, fnArguments, resolver, babelConfig, }) } catch (e) { if (e.cause !== `noBinding:${importName}`) throw e } } }, }) if (result) return result throw new Error(`Could not find binding for import "${importName}" in ${importFileName}`) } function resolveExportSpecifier({ node, importName, filename, fnArguments, babelConfig, resolver, }: { node: babelTypes.ExportNamedDeclaration | babelTypes.ExportAllDeclaration importName: string filename: string fnArguments: babelTypes.Node[] babelConfig: TransformOptions resolver: NodeJS.RequireResolve }): resolveExpressionReturnType { if (!node.source) { throw new Error(`Could not find source for export "${importName}" in ${filename}`) } const importFileName = node.source.value const importPath = path.resolve(path.dirname(filename), importFileName) const resolvedFile = resolver(importPath) const source = fs.readFileSync(resolvedFile) const tree = parseSourceFile(source.toString(), resolvedFile, babelConfig) let newScope: Scope | undefined traverse(tree, { Program(p) { newScope = p.scope }, }) if (!newScope) { throw new Error(`Could not find scope for ${filename}`) } const binding = newScope.getBinding(importName) if (binding) { return resolveExpression({ node: binding.path.node, file: tree, scope: newScope, filename: importFileName, babelConfig, resolver, fnArguments, }) } throw new Error(`Could not find binding for export "${importName}" in ${importFileName}`, { cause: `noBinding:${importName}`, }) }