UNPKG

@sap/eslint-plugin-cds

Version:

ESLint plugin including recommended SAP Cloud Application Programming model and environment rules

293 lines (262 loc) 9.14 kB
// TODO: // - class extends require('@sap/cds').ApplicationService // - class extends await import('@sap/cds').ApplicationService /** @typedef {import('./types').CdsContextTracker.Scope} Scope */ /** @typedef {import('./types').CdsContextTracker.Variable} Variable */ /** @typedef {import('./types^').CdsContextTracker.VariableType} VariableType */ 'use strict' /** * @param {string} name - name of the scope * @returns {Scope} */ const produceScope = name => ({ name, variables: [] }) const produceHandlerRegistration = ({call, handler}) => ({ call, handler }) // matches: @sap/cds, "@sap/cds", '@sap/cds', `@sap/cds`, but not @sap/cds-compiler etc const isSapCds = name => Boolean(name?.match(/^\W*@sap\/cds\W*$/)) /** * @param {VariableType} type - type of the variable */ const produceVariable = ({name, type, isCdsVariable, original}) => ({ name, type, original, isCdsVariable }) // like: require('@sap/cds') const isCdsRequire = node => Boolean(node?.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'require' && node.arguments.length === 1 && node.arguments[0].type === 'Literal' && isSapCds(node.arguments[0].value)) // like: import ... from '@sap/cds' const isCdsImport = node => isSapCds(node?.source?.value) const isFunctionBody = node => ['FunctionExpression', 'FunctionDeclaration', 'ArrowFunctionExpression'] .includes(node.parent.type) class CdsHandlerRule { get isInsideCapService() { return this.capServiceStack.length > 0 } get isInsideCapHandlerRegistration() { return this.capHandlerRegistrationStack.length > 0 } constructor (context) { /** @type {import('eslint').Rule.RuleContext} */ this.context = context /** @type {Scope[]} */ this.functionScopes = [ produceScope('<global>') ] this.capServiceStack = [] // stack of class bodies. Should probably never be more than one... this.capHandlerRegistrationStack = [] // stack of handler registration calls. Should probably never be more than one... } /** * @param {string} varName - name of the variable */ findDefinitionScope (varName) { const scopes = this.functionScopes for (let i = scopes.length - 1; i >= 0; i--) { const scope = scopes[i] const variable = scope.variables.find(variable => variable.name === varName) if (variable) return { scope, variable, isLocal: i === scopes.length - 1, isGlobal: i === 0 } } return undefined } // ClassExpression or ClassDeclaration isCdsServiceClass (node) { const superClass = node.superClass if (!superClass) return false // no extends clause let name switch (superClass.type) { case 'MemberExpression': { // like: class X extends cds.ApplicationService name = superClass.object.name // TODO: && is *Service? break } case 'Identifier': { // like: class X extends ApplicationService name = superClass.name break } } const info = this.findDefinitionScope(name) return info?.variable.isCdsVariable } /** * @param {ReturnType<typeof produceHandlerRegistration>} registration - the handler registration to add */ addCapHandlerRegistration (registration) { this.capHandlerRegistrationStack.push(registration) } removeCapHandlerRegistration () { this.capHandlerRegistrationStack.pop() } /** * @param {Variable} variable */ addScopeVariable (variable) { this.functionScopes.at(-1).variables.push(variable) } /** * @param {Scope} scope - the scope to add */ enterFunctionScope (scope) { this.functionScopes.push(scope) } leaveFunctionScope () { this.functionScopes.pop() } /** * @abstract */ // eslint-disable-next-line no-unused-vars CAPHandlerRegistration (node) { /* abstract */ } ClassBody (node) { // by hooking into ClassBody and ascending to .parent, we capture declarations and expressions: // like: module.exports = class X extends cds.ApplicationService (ClassExpression) // like: class X extends cds.ApplicationService (ClassDeclaration) if (this.isCdsServiceClass(node.parent)) { this.capServiceStack.push(node) } } 'ClassBody:exit'(node) { if (this.capServiceStack.at(-1) === node) { this.capServiceStack.pop() } } CallExpression(node) { if (!this.isInsideCapService) return const { type, object, property } = node.callee if (type === 'MemberExpression' && object.type === 'ThisExpression') { // like: this.on('submitOrder', bar) if (['before', 'on', 'after'].some(method => method === property.name)) { const handler = node.arguments.at(-1) this.addCapHandlerRegistration(produceHandlerRegistration({ call: node, handler })) // TODO: named references this.CAPHandlerRegistration(handler) } } } 'CallExpression:exit'(node) { if (this.capHandlerRegistrationStack.at(-1)?.call === node) { this.removeCapHandlerRegistration() } } BlockStatement(node) { if(isFunctionBody(node)) { this.enterFunctionScope(produceScope( node.parent?.key?.name ?? node.parent?.id?.name ?? node.parent?.parent?.key?.name // const f = function() { ... } ?? (node.parent?.parent?.type === 'VariableDeclarator' ? node.parent.parent.id.name : undefined) ?? '<anonymous>')) } } 'BlockStatement:exit'(node) { if(isFunctionBody(node)) { this.leaveFunctionScope() } } // the following visitors handle arrow functions, which can have a BlockStatement as body, // OR just any single expression, like an Assignment, etc. This makes it very hard to // determine when we are inside the body ArrowFunctionExpression from looking at the body, // as we'd have to add a visitor for every expression type and check if it's a child of an ArrowFunctionExpression. 'ArrowFunctionExpression > :not(BlockStatement)'() { this.enterFunctionScope(produceScope('<anonymous>')) } 'ArrowFunctionExpression > :not(BlockStatement):exit'() { this.leaveFunctionScope() } ImportDeclaration(node) { const { specifiers } = node const isCdsVariable = isCdsImport(node) for (const specifier of specifiers) { switch (specifier.type) { case 'ImportNamespaceSpecifier': // like: import * as x from y // fallthrough case 'ImportDefaultSpecifier': // like: import x from y this.addScopeVariable(produceVariable({ original: specifier.local.name, name: specifier.local.name, type: 'import', isCdsVariable })) break case 'ImportSpecifier': // like: import { x, y as foo } from z this.addScopeVariable(produceVariable({ original: specifier.imported.name, name: specifier.local.name, type: 'import', isCdsVariable })) break default: throw new Error(`Unexpected specifier type: ${specifier.type}`) } } } VariableDeclarator({id, init, parent}) { // like: const ... = require('@sap/cds') const isCdsVariable = isCdsRequire(init) switch (id.type) { case 'Identifier': // like: const x = y this.addScopeVariable(produceVariable({ original: id.name, name: id.name, type: parent.kind, isCdsVariable })) break case 'ObjectPattern': // like: const { x, y } = z for (const { key, type, value } of id.properties) { if (type === 'Property' && key.type === 'Identifier') { this.addScopeVariable(produceVariable({ original: key.name, type: parent.kind, isCdsVariable, name: value?.name })) } } break } } /** * ESLint expects an object literal with functions members when registering * the visitor in create(context). * This method transforms the class instance into such an object, retaining * the base methods, as well as all methods defined in subclasses. * Visitors are distinguished by their name starting with an uppercase letter. * So you should only ever * ```js * return new MyCdsRule(context).asESLintVisitor() * ``` * and not * ```js * return new MyCdsRule(context) * ``` * in your rule definition, or else the visitor methods will not be called by ESL. */ asESLintVisitor () { let proto = Object.getPrototypeOf(this) const visitors = {} while (proto && proto !== Object.prototype) { Object.getOwnPropertyNames(proto) .filter(key => typeof this[key] === 'function' && /^[A-Z]/.test(key)) .forEach(key => { visitors[key] = this[key].bind(this) }) proto = Object.getPrototypeOf(proto) // Move up the prototype chain } return visitors } } module.exports = { CdsHandlerRule }