@sap/eslint-plugin-cds
Version:
ESLint plugin including recommended SAP Cloud Application Programming model and environment rules
293 lines (262 loc) • 9.14 kB
JavaScript
// 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* \/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
}