Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Utility class to detect Typescript functions that can possibly be used as Lambda Function Handlers. #142

Merged
merged 8 commits into from
Oct 29, 2018
3,405 changes: 1,702 additions & 1,703 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,6 @@
"tslint": "^5.11.0",
"tslint-eslint-rules": "^5.4.0",
"tslint-no-circular-imports": "^0.6.1",
"typescript": "^3.0.1",
"vsce": "^1.51.1",
"vscode": "^1.1.21",
"vscode-nls-dev": "^3.2.2"
Expand All @@ -233,6 +232,7 @@
"npm": "^6.1.0",
"opn": "^5.4.0",
"request": "^2.88.0",
"typescript": "^3.1.3",
"vscode-nls": "^3.2.4",
"vue": "^2.5.16",
"xml2js": "^0.4.19"
Expand Down
23 changes: 23 additions & 0 deletions src/shared/lambdaHandlerSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*!
* Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

export interface LambdaHandlerCandidate {
handlerName: string,
filename: string,
positionStart: number,
positionEnd: number,
}

export interface LambdaHandlerSearch {

/**
* @description Looks for functions that appear to be valid Lambda Function Handlers.
* @returns A collection of information for each detected candidate.
*/
findCandidateLambdaHandlers(): Promise<LambdaHandlerCandidate[]>

}
324 changes: 324 additions & 0 deletions src/shared/typescriptLambdaHandlerSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
/*!
* Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

import * as path from 'path'
import * as ts from 'typescript'
import * as vscode from 'vscode'
import * as filesystem from './filesystem'
import { LambdaHandlerCandidate, LambdaHandlerSearch } from './lambdaHandlerSearch'

/**
* Detects functions that could possibly be used as Lambda Function Handlers from a Typescript file.
*/
export class TypescriptLambdaHandlerSearch implements LambdaHandlerSearch {

public static readonly MAXIMUM_FUNCTION_PARAMETERS: number = 3

private readonly _uri!: vscode.Uri
private readonly _filename: string
private readonly _baseFilename: string
// _candidateDeclaredFunctionNames - names of functions that could be lambda handlers
private readonly _candidateDeclaredFunctionNames: Set<string> = new Set()
// _candidateModuleExportsExpressions - all statements like "exports.handler = ..."
private _candidateModuleExportsExpressions: ts.ExpressionStatement[] = []
// _candidateExportDeclarations - all "export { xyz }"
private _candidateExportDeclarations: ts.ExportDeclaration[] = []
// _candidateExportNodes - all "export function Xyz()" / "export const Xyz = () => {}"
private _candidateExportNodes: ts.Node[] = []

public constructor(uri: vscode.Uri) {
this._uri = uri
this._filename = this._uri.fsPath
this._baseFilename = path.parse(this._filename).name
}

/**
* @description Looks for functions that appear to be valid Lambda Function Handlers.
* @returns A collection of information for each detected candidate.
*/
public async findCandidateLambdaHandlers(): Promise<LambdaHandlerCandidate[]> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • What's the performance on very large (i.e. 10,000+ lines, many functions) files?
  • Will this be called from the UI thread?
  • Is it feasible to only parse code that's currently in the user's viewport (i.e., not scrolled out of sight)?
  • Is it feasible to return a Promise<LambdaHandlerCandidate | undefined>[] (or a Promise<Promise<LambdaHandlerCandidate | undefined>[]>) instead? It would be awesome if we could update the view for each function independently.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I've seen of CodeLens, extensions are given the file as a whole, not for a viewport.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've put the performance investigation into #145

this._candidateDeclaredFunctionNames.clear()
this._candidateModuleExportsExpressions = []
this._candidateExportDeclarations = []
this._candidateExportNodes = []

return await this.getCandidateHandlers()
}

private async getCandidateHandlers(): Promise<LambdaHandlerCandidate[]> {
const fileContents = await filesystem.readFileAsyncAsString(this._filename)

const sourceFile = ts.createSourceFile(this._filename, fileContents, ts.ScriptTarget.Latest, true)

const handlers: LambdaHandlerCandidate[] = this.processSourceFile(sourceFile)

return handlers
}

/**
* @description looks for Lambda Handler candidates in the given source file
* Lambda Handler candidates are top level exported methods/functions.
*
* @param sourceFile SourceFile child node to process
* @returns Collection of candidate Lambda handler information, empty array otherwise
*/
private processSourceFile(sourceFile: ts.SourceFile): LambdaHandlerCandidate[] {
this.scanSourceFile(sourceFile)

const handlers: LambdaHandlerCandidate[] = []

handlers.push(...this.findCandidateHandlersInModuleExports())
handlers.push(...this.findCandidateHandlersInExportedFunctions())
handlers.push(...this.findCandidateHandlersInExportDeclarations())

return handlers
}

/**
* @description looks through a file's nodes, looking for data to support finding handler candidates
*/
private scanSourceFile(sourceFile: ts.SourceFile): void {
sourceFile.forEachChild((node: ts.Node) => {

// Function declarations
if (ts.isFunctionLike(node)) {
if (TypescriptLambdaHandlerSearch.isFunctionLambdaHandlerCandidate(node)) {
this._candidateDeclaredFunctionNames.add(node.name!.getText())
}
}

// Arrow Function declarations "const foo = (arg) => { }"
if (ts.isVariableStatement(node)) {
node.declarationList.forEachChild(declaration => {
if (ts.isVariableDeclaration(declaration)) {
const declarationName: string = declaration.name.getText()

if (declarationName.length > 0
&& declaration.initializer
&& ts.isFunctionLike(declaration.initializer)
&& TypescriptLambdaHandlerSearch.isFunctionLambdaHandlerCandidate(
declaration.initializer,
false // initializers do not have a name value, it is up in declaration.name
)
) {
this._candidateDeclaredFunctionNames.add(declarationName)
}
}
})
}

// export function xxx / "export const xxx = () => {}"
// We grab all of these and filter them later on in order to better deal with the VariableStatement entries
if (TypescriptLambdaHandlerSearch.isNodeExported(node)) {
this._candidateExportNodes.push(node)
}

// Things like "exports.handler = ..."
// Grab all, cull after we've found all valid functions that can be referenced on rhs
if (ts.isExpressionStatement(node)) {
if (TypescriptLambdaHandlerSearch.isModuleExportsAssignment(node)) {
this._candidateModuleExportsExpressions.push(node)
}
}

// Things like "export { xxx }"
// Grab all, cull after we've found all valid functions that can be referenced in brackets
if (ts.isExportDeclaration(node)) {
this._candidateExportDeclarations.push(node)
}
})
}

/**
* @description Looks at module.exports assignments to find candidate Lamdba handlers
*/
private findCandidateHandlersInModuleExports(): LambdaHandlerCandidate[] {
return this._candidateModuleExportsExpressions
.filter(expression => {
return TypescriptLambdaHandlerSearch.isEligibleLambdaHandlerAssignment(
expression,
this._candidateDeclaredFunctionNames
)
}).map(candidate => {
// 'module.exports.xyz' => ['module', 'exports', 'xyz']
const lhsComponents: string[] = (candidate.expression as ts.BinaryExpression)
.left.getText().split('.').map(x => x.trim())

const exportsTarget: string = lhsComponents[0] === 'exports' ? lhsComponents[1] : lhsComponents[2]

return {
filename: this._filename,
handlerName: `${this._baseFilename}.${exportsTarget}`,
positionStart: candidate.pos,
positionEnd: candidate.end,
}
})
}

/**
* @description Looks at "export { xyz }" statements to find candidate Lambda handlers
*/
private findCandidateHandlersInExportDeclarations(): LambdaHandlerCandidate[] {
const handlers: LambdaHandlerCandidate[] = []

this._candidateExportDeclarations.forEach(exportDeclaration => {
if (exportDeclaration.exportClause) {
exportDeclaration.exportClause.forEachChild(clause => {
if (ts.isExportSpecifier(clause)) {
const exportedFunction: string = clause.name.getText()

if (this._candidateDeclaredFunctionNames.has(exportedFunction)) {
handlers.push({
filename: this._filename,
handlerName: `${this._baseFilename}.${exportedFunction}`,
positionStart: clause.pos,
positionEnd: clause.end,
})
}
}
})
}
})

return handlers
mpiroc marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* @description Looks at export function declarations to find candidate Lamdba handlers
*/
private findCandidateHandlersInExportedFunctions(): LambdaHandlerCandidate[] {
const handlers: LambdaHandlerCandidate[] = []

this._candidateExportNodes.forEach(exportNode => {
if (ts.isFunctionLike(exportNode)
&& (TypescriptLambdaHandlerSearch.isFunctionLambdaHandlerCandidate(exportNode))
&& !!exportNode.name
) {
handlers.push({
filename: this._filename,
handlerName: `${this._baseFilename}.${exportNode.name.getText()}`,
positionStart: exportNode.pos,
positionEnd: exportNode.end,
})
} else if (ts.isVariableStatement(exportNode)) {
exportNode.declarationList.forEachChild(declaration => {
if (ts.isVariableDeclaration(declaration)
&& !!declaration.initializer
&& ts.isFunctionLike(declaration.initializer)
&& TypescriptLambdaHandlerSearch.isFunctionLambdaHandlerCandidate(
declaration.initializer,
false
)
) {
handlers.push({
filename: this._filename,
handlerName: `${this._baseFilename}.${declaration.name.getText()}`,
positionStart: declaration.pos,
positionEnd: declaration.end,
})
}
})
}
})

return handlers
}

/**
* @description Whether or not the given expression is attempting to assign to '[module.]exports.foo'
* @param expressionStatement Expression node to evaluate
*/
private static isModuleExportsAssignment(expressionStatement: ts.ExpressionStatement): boolean {
mpiroc marked this conversation as resolved.
Show resolved Hide resolved
if (ts.isBinaryExpression(expressionStatement.expression)) {
const lhsComponents: string[] = expressionStatement.expression.left.getText().split('.').map(x => x.trim())

return (lhsComponents.length === 3 && lhsComponents[0] === 'module' && lhsComponents[1] === 'exports')
|| (lhsComponents.length === 2 && lhsComponents[0] === 'exports')
}

return false
}

/**
* @description Whether or not the given expression appears to be assigning a candidate Lambda Handler
* Expression could be one of:
* [module.]exports.foo = alreadyDeclaredFunction
* [module.]exports.foo = (event, context) => { ... }
* @param expressionStatement Expression node to evaluate
* @param functionHandlerNames Names of declared functions considered to be Handler Candidates
*/
private static isEligibleLambdaHandlerAssignment(
expressionStatement: ts.ExpressionStatement,
functionHandlerNames: Set<string>
): boolean {
if (ts.isBinaryExpression(expressionStatement.expression)) {
return this.isTargetFunctionReference(expressionStatement.expression.right, functionHandlerNames)
|| this.isValidFunctionAssignment(expressionStatement.expression.right)
}

return false
}

/**
* @description Whether or not the given expression appears to contain a function of interest on the right hand side
*
* Example expression:
* something = alreadyDeclaredFunction
* @param expression Expression node to evaluate
* @param targetFunctionNames Names of functions of interest
*/
private static isTargetFunctionReference(expression: ts.Expression, targetFunctionNames: Set<string>): boolean {
if (ts.isIdentifier(expression)) {
return (targetFunctionNames.has(expression.text))
}

return false
}

/**
* @description Whether or not the given expression appears to have a function that could be a valid Lambda handler
* on the right hand side.
*
* Example expression:
* something = (event, context) => { }
* @param expression Expression node to evaluate
* @param targetFunctionNames Names of functions of interest
*/
private static isValidFunctionAssignment(expression: ts.Expression): boolean {
if (ts.isFunctionLike(expression)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify, does isFunctionLike support cases like = function() { /* ... */ }? The whole API is undocumented, which makes it hard to review 😢

return expression.parameters.length <= TypescriptLambdaHandlerSearch.MAXIMUM_FUNCTION_PARAMETERS
}

return false
}

/**
* @description Indicates whether or not a node is marked as visible outside this file
* @param node Node to check
* @returns true if node is exported, false otherwise
*/
private static isNodeExported(node: ts.Node): boolean {
const flags: ts.ModifierFlags = ts.getCombinedModifierFlags(node as ts.Declaration)

// tslint:disable-next-line:no-bitwise
return ((flags & ts.ModifierFlags.Export) === ts.ModifierFlags.Export)
}

/**
* @description Indicates whether or not a function/method could be a Lambda Handler
* @param node Signature Declaration Node to check
* @param validateName whether or not to check the name property
*/
private static isFunctionLambdaHandlerCandidate(
node: ts.SignatureDeclaration,
validateName: boolean = true
): boolean {
const nameIsValid: boolean = (!validateName || !!node.name)

return node.parameters.length <= TypescriptLambdaHandlerSearch.MAXIMUM_FUNCTION_PARAMETERS && nameIsValid
}
}
22 changes: 22 additions & 0 deletions src/test/samples/javascript/sampleClasses.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*!
* Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/

'use strict'

class NonExportedClass {
publicMethod() { }
mpiroc marked this conversation as resolved.
Show resolved Hide resolved
}

class ExportedClass {
publicMethod() { }

static publicStaticMethod() { }
}
exports.ExportedClass = ExportedClass

function functionWithNoArgs() { }

function exportedFunctionWithNoArgs() { }
exports.exportedFunctionWithNoArgs = exportedFunctionWithNoArgs
Loading