diff options
Diffstat (limited to 'dev/lib/handlebars/src/visitor.ts')
-rw-r--r-- | dev/lib/handlebars/src/visitor.ts | 778 |
1 files changed, 778 insertions, 0 deletions
diff --git a/dev/lib/handlebars/src/visitor.ts b/dev/lib/handlebars/src/visitor.ts new file mode 100644 index 00000000..1842c8e5 --- /dev/null +++ b/dev/lib/handlebars/src/visitor.ts @@ -0,0 +1,778 @@ +/* + * Elasticsearch B.V licenses this file to you under the MIT License. + * See `packages/kbn-handlebars/LICENSE` for more information. + */ + +import Handlebars from 'handlebars'; +import { + createProtoAccessControl, + resultIsAllowed, + // @ts-expect-error: Could not find a declaration file for module +} from 'handlebars/dist/cjs/handlebars/internal/proto-access'; +// @ts-expect-error: Could not find a declaration file for module +import AST from 'handlebars/dist/cjs/handlebars/compiler/ast'; +// @ts-expect-error: Could not find a declaration file for module +import { indexOf, createFrame } from 'handlebars/dist/cjs/handlebars/utils'; +// @ts-expect-error: Could not find a declaration file for module +import { moveHelperToHooks } from 'handlebars/dist/cjs/handlebars/helpers'; + +import type { + AmbiguousHelperOptions, + CompileOptions, + Container, + DecoratorDelegate, + DecoratorsHash, + HelperOptions, + NodeType, + NonBlockHelperOptions, + ProcessableBlockStatementNode, + ProcessableNode, + ProcessableNodeWithPathParts, + ProcessableNodeWithPathPartsOrLiteral, + ProcessableStatementNode, + ResolvePartialOptions, + RuntimeOptions, + Template, + TemplateDelegate, + VisitorHelper, +} from './types'; +import { kAmbiguous, kHelper, kSimple } from './symbols'; +import { + initData, + isBlock, + isDecorator, + noop, + toDecoratorOptions, + transformLiteralToPath, +} from './utils'; + +export class ElasticHandlebarsVisitor extends Handlebars.Visitor { + private env: typeof Handlebars; + private contexts: any[] = []; + private output: any[] = []; + private template?: string; + private compileOptions: CompileOptions; + private runtimeOptions?: RuntimeOptions; + private blockParamNames: any[][] = []; + private blockParamValues: any[][] = []; + private ast?: hbs.AST.Program; + private container: Container; + private defaultHelperOptions: Pick<NonBlockHelperOptions, 'lookupProperty'>; + private processedRootDecorators = false; // Root decorators should not have access to input arguments. This flag helps us detect them. + private processedDecoratorsForProgram = new Set(); // It's important that a given program node only has its decorators run once, we use this Map to keep track of them + + constructor( + env: typeof Handlebars, + input: string | hbs.AST.Program, + options: CompileOptions = {} + ) { + super(); + + this.env = env; + + if (typeof input !== 'string' && input.type === 'Program') { + this.ast = input; + } else { + this.template = input as string; + } + + this.compileOptions = { data: true, ...options }; + this.compileOptions.knownHelpers = Object.assign( + Object.create(null), + { + helperMissing: true, + blockHelperMissing: true, + each: true, + if: true, + unless: true, + with: true, + log: true, + lookup: true, + }, + this.compileOptions.knownHelpers + ); + + const protoAccessControl = createProtoAccessControl({}); + + const container: Container = (this.container = { + helpers: {}, + partials: {}, + decorators: {}, + strict(obj, name, loc) { + if (!obj || !(name in obj)) { + throw new Handlebars.Exception('"' + name + '" not defined in ' + obj, { + loc, + } as hbs.AST.Node); + } + return container.lookupProperty(obj, name); + }, + // this function is lifted from the handlebars source and slightly modified (lib/handlebars/runtime.js) + lookupProperty(parent, propertyName) { + const result = parent[propertyName]; + if (result == null) { + return result; + } + if (Object.prototype.hasOwnProperty.call(parent, propertyName)) { + return result; + } + + if (resultIsAllowed(result, protoAccessControl, propertyName)) { + return result; + } + return undefined; + }, + // this function is lifted from the handlebars source and slightly modified (lib/handlebars/runtime.js) + lambda(current, context) { + return typeof current === 'function' ? current.call(context) : current; + }, + data(value: any, depth: number) { + while (value && depth--) { + value = value._parent; + } + return value; + }, + hooks: {}, + }); + + this.defaultHelperOptions = { + lookupProperty: container.lookupProperty, + }; + } + + render(context: any, options: RuntimeOptions = {}): string { + this.contexts = [context]; + this.output = []; + this.runtimeOptions = { ...options }; + this.container.helpers = { ...this.env.helpers, ...options.helpers }; + this.container.partials = { ...this.env.partials, ...options.partials }; + this.container.decorators = { + ...(this.env.decorators as DecoratorsHash), + ...options.decorators, + }; + this.container.hooks = {}; + this.processedRootDecorators = false; + this.processedDecoratorsForProgram.clear(); + + if (this.compileOptions.data) { + this.runtimeOptions.data = initData(context, this.runtimeOptions.data); + } + + const keepHelperInHelpers = false; + moveHelperToHooks(this.container, 'helperMissing', keepHelperInHelpers); + moveHelperToHooks(this.container, 'blockHelperMissing', keepHelperInHelpers); + + if (!this.ast) { + this.ast = Handlebars.parse(this.template!); + } + + // The `defaultMain` function contains the default behavior: + // + // Generate a "program" function based on the root `Program` in the AST and + // call it. This will start the processing of all the child nodes in the + // AST. + const defaultMain: TemplateDelegate = (_context) => { + const prog = this.generateProgramFunction(this.ast!); + return prog(_context, this.runtimeOptions); + }; + + // Run any decorators that might exist on the root: + // + // The `defaultMain` function is passed in, and if there are no root + // decorators, or if the decorators chooses to do so, the same function is + // returned from `processDecorators` and the default behavior is retained. + // + // Alternatively any of the root decorators might call the `defaultMain` + // function themselves, process its return value, and return a completely + // different `main` function. + const main = this.processDecorators(this.ast, defaultMain); + this.processedRootDecorators = true; + + // Call the `main` function and add the result to the final output. + const result = main(this.context, options); + + if (main === defaultMain) { + this.output.push(result); + return this.output.join(''); + } else { + // We normally expect the return value of `main` to be a string. However, + // if a decorator is used to override the `defaultMain` function, the + // return value can be any type. To match the upstream handlebars project + // behavior, we want the result of rendering the template to be the + // literal value returned by the decorator. + // + // Since the output array in this case always will be empty, we just + // return that single value instead of attempting to join all the array + // elements as strings. + return result; + } + } + + // ********************************************** // + // *** Visitor AST Traversal Functions *** // + // ********************************************** // + + Program(program: hbs.AST.Program) { + this.blockParamNames.unshift(program.blockParams); + super.Program(program); + this.blockParamNames.shift(); + } + + MustacheStatement(mustache: hbs.AST.MustacheStatement) { + this.processStatementOrExpression(mustache); + } + + BlockStatement(block: hbs.AST.BlockStatement) { + this.processStatementOrExpression(block); + } + + PartialStatement(partial: hbs.AST.PartialStatement) { + this.invokePartial(partial); + } + + PartialBlockStatement(partial: hbs.AST.PartialBlockStatement) { + this.invokePartial(partial); + } + + // This space is intentionally left blank: We want to override the Visitor + // class implementation of this method, but since we handle decorators + // separately before traversing the nodes, we just want to make this a no-op. + DecoratorBlock(decorator: hbs.AST.DecoratorBlock) {} + + // This space is intentionally left blank: We want to override the Visitor + // class implementation of this method, but since we handle decorators + // separately before traversing the nodes, we just want to make this a no-op. + Decorator(decorator: hbs.AST.Decorator) {} + + SubExpression(sexpr: hbs.AST.SubExpression) { + this.processStatementOrExpression(sexpr); + } + + PathExpression(path: hbs.AST.PathExpression) { + const blockParamId = + !path.depth && !AST.helpers.scopedId(path) && this.blockParamIndex(path.parts[0]); + + let result; + if (blockParamId) { + result = this.lookupBlockParam(blockParamId, path); + } else if (path.data) { + result = this.lookupData(this.runtimeOptions!.data, path); + } else { + result = this.resolvePath(this.contexts[path.depth], path); + } + + this.output.push(result); + } + + ContentStatement(content: hbs.AST.ContentStatement) { + this.output.push(content.value); + } + + StringLiteral(string: hbs.AST.StringLiteral) { + this.output.push(string.value); + } + + NumberLiteral(number: hbs.AST.NumberLiteral) { + this.output.push(number.value); + } + + BooleanLiteral(bool: hbs.AST.BooleanLiteral) { + this.output.push(bool.value); + } + + UndefinedLiteral() { + this.output.push(undefined); + } + + NullLiteral() { + this.output.push(null); + } + + // ********************************************** // + // *** Visitor AST Helper Functions *** // + // ********************************************** // + + /** + * Special code for decorators, since they have to be executed ahead of time (before the wrapping program). + * So we have to look into the program AST body and see if it contains any decorators that we have to process + * before we can finish processing of the wrapping program. + */ + private processDecorators(program: hbs.AST.Program, prog: TemplateDelegate) { + if (!this.processedDecoratorsForProgram.has(program)) { + this.processedDecoratorsForProgram.add(program); + const props = {}; + for (const node of program.body) { + if (isDecorator(node)) { + prog = this.processDecorator(node, prog, props); + } + } + } + + return prog; + } + + private processDecorator( + decorator: hbs.AST.DecoratorBlock | hbs.AST.Decorator, + prog: TemplateDelegate, + props: Record<string, any> + ) { + const options = this.setupDecoratorOptions(decorator); + + const result = this.container.lookupProperty<DecoratorDelegate>( + this.container.decorators, + options.name + )(prog, props, this.container, options); + + return Object.assign(result || prog, props); + } + + private processStatementOrExpression(node: ProcessableNodeWithPathPartsOrLiteral) { + // Calling `transformLiteralToPath` has side-effects! + // It converts a node from type `ProcessableNodeWithPathPartsOrLiteral` to `ProcessableNodeWithPathParts` + transformLiteralToPath(node); + + switch (this.classifyNode(node as ProcessableNodeWithPathParts)) { + case kSimple: + this.processSimpleNode(node as ProcessableNodeWithPathParts); + break; + case kHelper: + this.processHelperNode(node as ProcessableNodeWithPathParts); + break; + case kAmbiguous: + this.processAmbiguousNode(node as ProcessableNodeWithPathParts); + break; + } + } + + // Liftet from lib/handlebars/compiler/compiler.js (original name: classifySexpr) + private classifyNode(node: { path: hbs.AST.PathExpression }): NodeType { + const isSimple = AST.helpers.simpleId(node.path); + const isBlockParam = isSimple && !!this.blockParamIndex(node.path.parts[0]); + + // a mustache is an eligible helper if: + // * its id is simple (a single part, not `this` or `..`) + let isHelper = !isBlockParam && AST.helpers.helperExpression(node); + + // if a mustache is an eligible helper but not a definite + // helper, it is ambiguous, and will be resolved in a later + // pass or at runtime. + let isEligible = !isBlockParam && (isHelper || isSimple); + + // if ambiguous, we can possibly resolve the ambiguity now + // An eligible helper is one that does not have a complex path, i.e. `this.foo`, `../foo` etc. + if (isEligible && !isHelper) { + const name = node.path.parts[0]; + const options = this.compileOptions; + if (options.knownHelpers && options.knownHelpers[name]) { + isHelper = true; + } else if (options.knownHelpersOnly) { + isEligible = false; + } + } + + if (isHelper) { + return kHelper; + } else if (isEligible) { + return kAmbiguous; + } else { + return kSimple; + } + } + + // Liftet from lib/handlebars/compiler/compiler.js + private blockParamIndex(name: string): [number, any] | undefined { + for (let depth = 0, len = this.blockParamNames.length; depth < len; depth++) { + const blockParams = this.blockParamNames[depth]; + const param = blockParams && indexOf(blockParams, name); + if (blockParams && param >= 0) { + return [depth, param]; + } + } + } + + // Looks up the value of `parts` on the given block param and pushes + // it onto the stack. + private lookupBlockParam(blockParamId: [number, any], path: hbs.AST.PathExpression) { + const value = this.blockParamValues[blockParamId[0]][blockParamId[1]]; + return this.resolvePath(value, path, 1); + } + + // Push the data lookup operator + private lookupData(data: any, path: hbs.AST.PathExpression) { + if (path.depth) { + data = this.container.data(data, path.depth); + } + + return this.resolvePath(data, path); + } + + private processSimpleNode(node: ProcessableNodeWithPathParts) { + const path = node.path; + // @ts-expect-error strict is not a valid property on PathExpression, but we used in the same way it's also used in the original handlebars + path.strict = true; + const result = this.resolveNodes(path)[0]; + const lambdaResult = this.container.lambda(result, this.context); + + if (isBlock(node)) { + this.blockValue(node, lambdaResult); + } else { + this.output.push(lambdaResult); + } + } + + // The purpose of this opcode is to take a block of the form + // `{{#this.foo}}...{{/this.foo}}`, resolve the value of `foo`, and + // replace it on the stack with the result of properly + // invoking blockHelperMissing. + private blockValue(node: hbs.AST.BlockStatement, value: any) { + const name = node.path.original; + const options = this.setupParams(node, name); + + const result = this.container.hooks.blockHelperMissing!.call(this.context, value, options); + + this.output.push(result); + } + + private processHelperNode(node: ProcessableNodeWithPathParts) { + const path = node.path; + const name = path.parts[0]; + + if (this.compileOptions.knownHelpers && this.compileOptions.knownHelpers[name]) { + this.invokeKnownHelper(node); + } else if (this.compileOptions.knownHelpersOnly) { + throw new Handlebars.Exception( + 'You specified knownHelpersOnly, but used the unknown helper ' + name, + node + ); + } else { + this.invokeHelper(node); + } + } + + // This operation is used when the helper is known to exist, + // so a `helperMissing` fallback is not required. + private invokeKnownHelper(node: ProcessableNodeWithPathParts) { + const name = node.path.parts[0]; + const helper = this.setupHelper(node, name); + // TypeScript: `helper.fn` might be `undefined` at this point, but to match the upstream behavior we call it without any guards + const result = helper.fn!.call(helper.context, ...helper.params, helper.options); + this.output.push(result); + } + + // Pops off the helper's parameters, invokes the helper, + // and pushes the helper's return value onto the stack. + // + // If the helper is not found, `helperMissing` is called. + private invokeHelper(node: ProcessableNodeWithPathParts) { + const path = node.path; + const name = path.original; + const isSimple = AST.helpers.simpleId(path); + const helper = this.setupHelper(node, name); + + const loc = isSimple && helper.fn ? node.loc : path.loc; + helper.fn = (isSimple && helper.fn) || this.resolveNodes(path)[0]; + + if (!helper.fn) { + if (this.compileOptions.strict) { + helper.fn = this.container.strict(helper.context, name, loc); + } else { + helper.fn = this.container.hooks.helperMissing; + } + } + + // TypeScript: `helper.fn` might be `undefined` at this point, but to match the upstream behavior we call it without any guards + const result = helper.fn!.call(helper.context, ...helper.params, helper.options); + + this.output.push(result); + } + + private invokePartial(partial: hbs.AST.PartialStatement | hbs.AST.PartialBlockStatement) { + const { params } = partial; + if (params.length > 1) { + throw new Handlebars.Exception( + `Unsupported number of partial arguments: ${params.length}`, + partial + ); + } + + const isDynamic = partial.name.type === 'SubExpression'; + const name = isDynamic + ? this.resolveNodes(partial.name).join('') + : (partial.name as hbs.AST.PathExpression).original; + + const options: AmbiguousHelperOptions & ResolvePartialOptions = this.setupParams(partial, name); + options.helpers = this.container.helpers; + options.partials = this.container.partials; + options.decorators = this.container.decorators; + + let partialBlock; + if ('fn' in options && options.fn !== noop) { + const { fn } = options; + const currentPartialBlock = options.data?.['partial-block']; + options.data = createFrame(options.data); + + // Wrapper function to get access to currentPartialBlock from the closure + partialBlock = options.data['partial-block'] = function partialBlockWrapper( + context: any, + wrapperOptions: { data?: HelperOptions['data'] } = {} + ) { + // Restore the partial-block from the closure for the execution of the block + // i.e. the part inside the block of the partial call. + wrapperOptions.data = createFrame(wrapperOptions.data); + wrapperOptions.data['partial-block'] = currentPartialBlock; + return fn(context, wrapperOptions); + }; + + if (fn.partials) { + options.partials = { ...options.partials, ...fn.partials }; + } + } + + let context = {}; + if (params.length === 0 && !this.compileOptions.explicitPartialContext) { + context = this.context; + } else if (params.length === 1) { + context = this.resolveNodes(params[0])[0]; + } + + if (Object.keys(options.hash).length > 0) { + // TODO: context can be an array, but maybe never when we have a hash??? + context = Object.assign({}, context, options.hash); + } + + const partialTemplate: Template | undefined = + this.container.partials[name] ?? + partialBlock ?? + // TypeScript note: We extend ResolvePartialOptions in our types.ts file + // to fix an error in the upstream type. When calling back into the + // upstream code, we just cast back to the non-extended type + Handlebars.VM.resolvePartial( + undefined, + undefined, + options as Handlebars.ResolvePartialOptions + ); + + if (partialTemplate === undefined) { + throw new Handlebars.Exception(`The partial ${name} could not be found`); + } + + let render; + if (typeof partialTemplate === 'string') { + render = this.env.compileAST(partialTemplate, this.compileOptions); + if (name in this.container.partials) { + this.container.partials[name] = render; + } + } else { + render = partialTemplate; + } + + let result = render(context, options); + + if ('indent' in partial) { + result = + partial.indent + + (this.compileOptions.preventIndent + ? result + : result.replace(/\n(?!$)/g, `\n${partial.indent}`)); // indent each line, ignoring any trailing linebreak + } + + this.output.push(result); + } + + private processAmbiguousNode(node: ProcessableNodeWithPathParts) { + const name = node.path.parts[0]; + const helper = this.setupHelper(node, name); + let { fn: helperFn } = helper; + + const loc = helperFn ? node.loc : node.path.loc; + helperFn = helperFn ?? this.resolveNodes(node.path)[0]; + + if (helperFn === undefined) { + if (this.compileOptions.strict) { + helperFn = this.container.strict(helper.context, name, loc); + } else { + helperFn = + helper.context != null + ? this.container.lookupProperty(helper.context, name) + : helper.context; + if (helperFn == null) helperFn = this.container.hooks.helperMissing; + } + } + + const helperResult = + typeof helperFn === 'function' + ? helperFn.call(helper.context, ...helper.params, helper.options) + : helperFn; + + if (isBlock(node)) { + const result = helper.fn + ? helperResult + : this.container.hooks.blockHelperMissing!.call(this.context, helperResult, helper.options); + if (result != null) { + this.output.push(result); + } + } else { + if ( + (node as hbs.AST.MustacheStatement).escaped === false || + this.compileOptions.noEscape === true || + typeof helperResult !== 'string' + ) { + this.output.push(helperResult); + } else { + this.output.push(Handlebars.escapeExpression(helperResult)); + } + } + } + + private setupHelper(node: ProcessableNode, helperName: string): VisitorHelper { + return { + fn: this.container.lookupProperty(this.container.helpers, helperName), + context: this.context, + params: this.resolveNodes(node.params), + options: this.setupParams(node, helperName), + }; + } + + private setupDecoratorOptions(decorator: hbs.AST.Decorator | hbs.AST.DecoratorBlock) { + // TypeScript: The types indicate that `decorator.path` technically can be an `hbs.AST.Literal`. However, the upstream codebase always treats it as an `hbs.AST.PathExpression`, so we do too. + const name = (decorator.path as hbs.AST.PathExpression).original; + const options = toDecoratorOptions(this.setupParams(decorator, name)); + + if (decorator.params.length > 0) { + if (!this.processedRootDecorators) { + // When processing the root decorators, temporarily remove the root context so it's not accessible to the decorator + const context = this.contexts.shift(); + options.args = this.resolveNodes(decorator.params); + this.contexts.unshift(context); + } else { + options.args = this.resolveNodes(decorator.params); + } + } else { + options.args = []; + } + + return options; + } + + private setupParams(node: ProcessableBlockStatementNode, name: string): HelperOptions; + private setupParams(node: ProcessableStatementNode, name: string): NonBlockHelperOptions; + private setupParams(node: ProcessableNode, name: string): AmbiguousHelperOptions; + private setupParams(node: ProcessableNode, name: string) { + const options: AmbiguousHelperOptions = { + name, + hash: this.getHash(node), + data: this.runtimeOptions!.data, + loc: { start: node.loc.start, end: node.loc.end }, + ...this.defaultHelperOptions, + }; + + if (isBlock(node)) { + (options as HelperOptions).fn = node.program + ? this.processDecorators(node.program, this.generateProgramFunction(node.program)) + : noop; + (options as HelperOptions).inverse = node.inverse + ? this.processDecorators(node.inverse, this.generateProgramFunction(node.inverse)) + : noop; + } + + return options; + } + + private generateProgramFunction(program: hbs.AST.Program) { + if (!program) return noop; + + const prog: TemplateDelegate = (nextContext: any, runtimeOptions: RuntimeOptions = {}) => { + runtimeOptions = { ...runtimeOptions }; + + // inherit data in blockParams from parent program + runtimeOptions.data = runtimeOptions.data || this.runtimeOptions!.data; + if (runtimeOptions.blockParams) { + runtimeOptions.blockParams = runtimeOptions.blockParams.concat( + this.runtimeOptions!.blockParams + ); + } + + // inherit partials from parent program + runtimeOptions.partials = runtimeOptions.partials || this.runtimeOptions!.partials; + + // stash parent program data + const tmpRuntimeOptions = this.runtimeOptions; + this.runtimeOptions = runtimeOptions; + const shiftContext = nextContext !== this.context; + if (shiftContext) this.contexts.unshift(nextContext); + this.blockParamValues.unshift(runtimeOptions.blockParams || []); + + // execute child program + const result = this.resolveNodes(program).join(''); + + // unstash parent program data + this.blockParamValues.shift(); + if (shiftContext) this.contexts.shift(); + this.runtimeOptions = tmpRuntimeOptions; + + // return result of child program + return result; + }; + + prog.blockParams = program.blockParams?.length ?? 0; + return prog; + } + + private getHash(statement: { hash?: hbs.AST.Hash }) { + const result: { [key: string]: any } = {}; + if (!statement.hash) return result; + for (const { key, value } of statement.hash.pairs) { + result[key] = this.resolveNodes(value)[0]; + } + return result; + } + + private resolvePath(obj: any, path: hbs.AST.PathExpression, index = 0) { + if (this.compileOptions.strict || this.compileOptions.assumeObjects) { + return this.strictLookup(obj, path); + } + + for (; index < path.parts.length; index++) { + if (obj == null) return; + obj = this.container.lookupProperty(obj, path.parts[index]); + } + + return obj; + } + + private strictLookup(obj: any, path: hbs.AST.PathExpression) { + // @ts-expect-error strict is not a valid property on PathExpression, but we used in the same way it's also used in the original handlebars + const requireTerminal = this.compileOptions.strict && path.strict; + const len = path.parts.length - (requireTerminal ? 1 : 0); + + for (let i = 0; i < len; i++) { + obj = this.container.lookupProperty(obj, path.parts[i]); + } + + if (requireTerminal) { + return this.container.strict(obj, path.parts[len], path.loc); + } else { + return obj; + } + } + + private resolveNodes(nodes: hbs.AST.Node | hbs.AST.Node[]): any[] { + const currentOutput = this.output; + this.output = []; + + if (Array.isArray(nodes)) { + this.acceptArray(nodes); + } else { + this.accept(nodes); + } + + const result = this.output; + + this.output = currentOutput; + + return result; + } + + private get context() { + return this.contexts[0]; + } +} |