diff --git a/integration/examples_nodejs_test.go b/integration/examples_nodejs_test.go index 77a8262..d367be5 100644 --- a/integration/examples_nodejs_test.go +++ b/integration/examples_nodejs_test.go @@ -264,3 +264,16 @@ func bucketExists(ctx context.Context, client *s3.Client, bucketName string) (bo } return true, nil } + +func TestKinesis(t *testing.T) { + test := getJSBaseOptions(t). + With(integration.ProgramTestOptions{ + Dir: filepath.Join(getCwd(t), "kinesis"), + ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) { + kinesisStreamName := stack.Outputs["kinesisStreamName"].(string) + assert.Containsf(t, kinesisStreamName, "mystream", "Kinesis stream name should contain 'mystream'") + }, + }) + + integration.ProgramTest(t, &test) +} diff --git a/integration/kinesis/Pulumi.yaml b/integration/kinesis/Pulumi.yaml new file mode 100644 index 0000000..0767e2a --- /dev/null +++ b/integration/kinesis/Pulumi.yaml @@ -0,0 +1,3 @@ +name: pulumi-kinesis +runtime: nodejs +description: kinesis integration test diff --git a/integration/kinesis/index.ts b/integration/kinesis/index.ts new file mode 100644 index 0000000..0289a73 --- /dev/null +++ b/integration/kinesis/index.ts @@ -0,0 +1,28 @@ +import * as pulumi from '@pulumi/pulumi'; +import * as kinesis from 'aws-cdk-lib/aws-kinesis'; +import * as pulumicdk from '@pulumi/cdk'; +import { Duration } from 'aws-cdk-lib/core'; + +class KinesisStack extends pulumicdk.Stack { + kinesisStreamName: pulumi.Output; + + constructor(app: pulumicdk.App, id: string, options?: pulumicdk.StackOptions) { + super(app, id, options); + + const kStream = new kinesis.Stream(this, 'my-stream', { + shardCount: 3, + retentionPeriod: Duration.hours(24), + }) + + this.kinesisStreamName = this.asOutput(kStream.streamName); + } +} + +const app = new pulumicdk.App('app', (scope: pulumicdk.App) => { + const stack = new KinesisStack(scope, 'teststack'); + return { + kinesisStreamName: stack.kinesisStreamName, + }; +}); + +export const kinesisStreamName = app.outputs['kinesisStreamName']; diff --git a/integration/kinesis/package.json b/integration/kinesis/package.json new file mode 100644 index 0000000..18e6e26 --- /dev/null +++ b/integration/kinesis/package.json @@ -0,0 +1,15 @@ +{ + "name": "pulumi-kinesis", + "devDependencies": { + "@types/node": "^10.0.0" + }, + "dependencies": { + "@pulumi/aws": "^6.0.0", + "@pulumi/aws-native": "^1.8.0", + "@pulumi/cdk": "^0.5.0", + "@pulumi/pulumi": "^3.0.0", + "aws-cdk-lib": "2.156.0", + "constructs": "10.3.0", + "esbuild": "^0.24.0" + } +} diff --git a/integration/kinesis/tsconfig.json b/integration/kinesis/tsconfig.json new file mode 100644 index 0000000..eac442c --- /dev/null +++ b/integration/kinesis/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2019", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "./*.ts" + ] +} diff --git a/package.json b/package.json index a1ff75e..2a51545 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ "@types/glob": "^8.1.0", "archiver": "^7.0.1", "cdk-assets": "^2.154.8", - "fs-extra": "^11.2.0" + "fs-extra": "^11.2.0", + "fast-deep-equal": "^3.1.3" }, "scripts": { "set-version": "sed -i.bak -e \"s/\\${VERSION}/$(pulumictl get version --language javascript)/g\" package.json && rm package.json.bak", diff --git a/src/assembly/stack.ts b/src/assembly/stack.ts index f3e5538..6352f12 100644 --- a/src/assembly/stack.ts +++ b/src/assembly/stack.ts @@ -1,6 +1,12 @@ import * as path from 'path'; import { DestinationIdentifier, FileManifestEntry } from 'cdk-assets'; -import { CloudFormationMapping, CloudFormationParameter, CloudFormationResource, CloudFormationTemplate } from '../cfn'; +import { + CloudFormationMapping, + CloudFormationParameter, + CloudFormationResource, + CloudFormationTemplate, + CloudFormationCondition, +} from '../cfn'; import { ConstructTree, StackMetadata } from './types'; import { FileAssetPackaging, FileDestination } from 'aws-cdk-lib/cloud-assembly-schema'; @@ -116,10 +122,15 @@ export class StackManifest { public readonly mappings?: CloudFormationMapping; /** + * CloudFormation conditions from the template. * + * @internal */ + public readonly conditions?: { [id: string]: CloudFormationCondition }; + private readonly metadata: StackMetadata; public readonly dependencies: string[]; + constructor(props: StackManifestProps) { this.dependencies = props.dependencies; this.outputs = props.template.Outputs; @@ -133,6 +144,7 @@ export class StackManifest { throw new Error('CloudFormation template has no resources!'); } this.resources = props.template.Resources; + this.conditions = props.template.Conditions; } /** diff --git a/src/cfn.ts b/src/cfn.ts index 95b8ff3..544b608 100644 --- a/src/cfn.ts +++ b/src/cfn.ts @@ -32,10 +32,21 @@ export type CloudFormationMappingValue = string | string[]; export type TopLevelMapping = { [key: string]: SecondLevelMapping }; export type SecondLevelMapping = { [key: string]: CloudFormationMappingValue }; +/** + * Models CF conditions. These are possibly nested expressions evaluating to a boolean. + * + * Example value: + * + * {"Fn::Equals": [{"Ref": "EnvType"}, "prod"]} + * + * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/conditions-section-structure.html + */ +export interface CloudFormationCondition {} + export interface CloudFormationTemplate { Parameters?: { [id: string]: CloudFormationParameter }; Resources?: { [id: string]: CloudFormationResource }; - Conditions?: { [id: string]: any }; + Conditions?: { [id: string]: CloudFormationCondition }; Mappings?: CloudFormationMapping; Outputs?: { [id: string]: any }; } diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts index 22cc65f..08b4656 100644 --- a/src/converters/app-converter.ts +++ b/src/converters/app-converter.ts @@ -24,6 +24,7 @@ import { parseSub } from '../sub'; import { getPartition } from '@pulumi/aws-native/getPartition'; import { mapToCustomResource } from '../custom-resource-mapping'; import { processSecretsManagerReferenceValue } from './secrets-manager-dynamic'; +import * as intrinsics from "./intrinsics"; /** * AppConverter will convert all CDK resources into Pulumi resources. @@ -90,7 +91,7 @@ export class AppConverter { /** * StackConverter converts all of the resources in a CDK stack to Pulumi resources */ -export class StackConverter extends ArtifactConverter { +export class StackConverter extends ArtifactConverter implements intrinsics.IntrinsicContext { readonly parameters = new Map(); readonly resources = new Map>(); readonly constructs = new Map(); @@ -543,6 +544,18 @@ export class StackConverter extends ArtifactConverter { }, this.processIntrinsics(params)); } + case 'Fn::Equals': { + return intrinsics.fnEquals.evaluate(this, params); + } + + case 'Fn::If': { + return intrinsics.fnIf.evaluate(this, params); + } + + case 'Fn::Or': { + return intrinsics.fnOr.evaluate(this, params); + } + default: throw new Error(`unsupported intrinsic function ${fn} (params: ${JSON.stringify(params)})`); } @@ -633,4 +646,28 @@ export class StackConverter extends ArtifactConverter { } return d.value; } + + findCondition(conditionName: string): intrinsics.Expression|undefined { + if (conditionName in (this.stack.conditions||{})) { + return this.stack.conditions![conditionName]; + } else { + return undefined; + } + } + + evaluate(expression: intrinsics.Expression): intrinsics.Result { + return this.processIntrinsics(expression); + } + + fail(msg: string): intrinsics.Result { + throw new Error(msg); + } + + succeed(r: T): intrinsics.Result { + return r; + } + + apply(result: intrinsics.Result, fn: (value: U) => intrinsics.Result): intrinsics.Result { + return lift(fn, result); + } } diff --git a/src/converters/intrinsics.ts b/src/converters/intrinsics.ts new file mode 100644 index 0000000..b0daeb1 --- /dev/null +++ b/src/converters/intrinsics.ts @@ -0,0 +1,295 @@ +// Copyright 2016-2024, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as equal from 'fast-deep-equal'; + +/** + * Models a CF Intrinsic Function. + * + * CloudFormation (CF) intrinsic functions need to be implemented for @pulumi/pulumi-cdk since CDK may emit them in the + * synthesized CF template. + * + * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html + * + * @internal + */ +export interface Intrinsic { + /** + * The name of the intrinsic function such as 'Fn::If'. + */ + name: string; + + /** + * Executes the logic to evaluate CF expressions and compute the result. + * + * Most intrinsics need to use IntrinsicContext.evaluate right away to find the values of parameters before + * processing them. Conditional intrinsics such as 'Fn::If' or 'Fn::Or' are an exception to this and need to + * evaluate their parameters only when necessary. + */ + evaluate(ctx: IntrinsicContext, params: Expression[]): Result; +} + +/** + * Models a CF expression. Currently this is just 'any' but eventually adding more structure and a separate + * parse/evaluate steps can help keeping the error messages tractable. + * + * See also CfnParse for inspiration: + * + * https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk-lib/core/lib/helpers-internal/cfn-parse.ts#L347 + * + * @internal + */ +export interface Expression {} + +/** + * Production code may have intermediate values occasionally wrapped in pulumi.Output; this is currently somewhat + * difficult to test, so the essentials of pulumi.Output are abstracted into a Result. + * + * @internal + */ +// eslint-disable-next-line +export interface Result {} + +/** + * Context available when evaluating CF expressions. + * + * Note that `succeed`, `fail`, `apply` and `Result` expressions are abstracting the use of `pulumi.Input` to facilitate + * testing over a simpler structure without dealing with async evaluation. + * + * @internal + */ +export interface IntrinsicContext { + + /** + * Lookup a CF Condition by its logical ID. + * + * If the condition is found, return the CF Expression with intrinsic function calls inside. + * + * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/conditions-section-structure.html + */ + findCondition(conditionName: string): Expression|undefined; + + /** + * Finds the value of a CF expression evaluating any intrinsic functions or references within. + */ + evaluate(expression: Expression): Result; + + /** + * If result succeeds, use its value to call `fn` and proceed with what it returns. + * + * If result fails, do not call `fn` and proceed with the error message from `result`. + */ + apply(result: Result, fn: (value: U) => Result): Result; + + /** + * Fail with a given error message. + */ + fail(msg: string): Result; + + /** + * Succeed with a given value. + */ + succeed(r: T): Result; +} + +/** + * "Fn::If": [condition_name, value_if_true, value_if_false] + * + * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#intrinsic-function-reference-conditions-i + * + * @internal + */ +export const fnIf: Intrinsic = { + name: 'Fn::If', + evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + if (params.length !== 3) { + return ctx.fail(`Expected 3 parameters, got ${ params.length }`); + } + + if (typeof params[0] !== 'string') { + return ctx.fail('Expected the first parameter to be a condition name string literal'); + } + + const conditionName: string = params[0]; + const exprIfTrue = params[1]; + const exprIfFalse = params[2]; + + return ctx.apply(evaluateCondition(ctx, conditionName), ok => { + if (ok) { + return ctx.evaluate(exprIfTrue); + } else { + return ctx.evaluate(exprIfFalse); + } + }); + } +} + +/** + * + * From the docs: the minimum number of conditions that you can include is 2, and the maximum is 10. + * + * Example invocation: + * + * "MyOrCondition": { + * "Fn::Or" : [ + * {"Fn::Equals" : ["sg-mysggroup", {"Ref" : "ASecurityGroup"}]}, + * {"Condition" : "SomeOtherCondition"} + * ] + * } + * + * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#intrinsic-function-reference-conditions-or + */ +export const fnOr: Intrinsic = { + name: 'Fn::Or', + evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + if (params.length < 2) { + return ctx.fail(`Fn::Or expects at least 2 params, got ${params.length}`) + } + const reducer = (acc: Result, expr: Expression) => ctx.apply(acc, ok => { + if (ok) { + return ctx.succeed(true); + } else { + return evaluateConditionSubExpression(ctx, expr); + } + }) + return params.reduce(reducer, ctx.succeed(false)); + } +} + + +/** + * + * From the docs: the minimum number of conditions that you can include is 2, and the maximum is 10. + * + * Example invocation: + * + * "MyAndCondition": { + * "Fn::And": [ + * {"Fn::Equals": ["sg-mysggroup", {"Ref": "ASecurityGroup"}]}, + * {"Condition": "SomeOtherCondition"} + * ] + * } + * + * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#intrinsic-function-reference-conditions-and + */ +export const fnAnd: Intrinsic = { + name: 'Fn::And', + evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + if (params.length < 2) { + return ctx.fail(`Fn::And expects at least 2 params, got ${params.length}`) + } + const reducer = (acc: Result, expr: Expression) => ctx.apply(acc, ok => { + if (!ok) { + return ctx.succeed(false); + } else { + return evaluateConditionSubExpression(ctx, expr); + } + }) + return params.reduce(reducer, ctx.succeed(true)); + } +} + + +/** + * Boolean negation. Expects exactly one argument. + * + * Example invocation: + * + * "MyNotCondition" : { + * "Fn::Not" : [{ + * "Fn::Equals" : [ + * {"Ref" : "EnvironmentType"}, + * "prod" + * ] + * }] + * } + */ +export const fnNot: Intrinsic = { + name: 'Fn::Not', + evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + if (params.length != 1) { + return ctx.fail(`Fn::Not expects exactly 1 param, got ${params.length}`) + } + const x = evaluateConditionSubExpression(ctx, params[0]); + return ctx.apply(x, v => ctx.succeed(!v)); + } +} + + +/** + * From the docs: Compares if two values are equal. Returns true if the two values are equal or false if they aren't. + * + * Fn::Equals: [value_1, value_2] + * + * See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#intrinsic-function-reference-conditions-not + * + */ +export const fnEquals: Intrinsic = { + name: 'Fn::Equals', + evaluate: (ctx: IntrinsicContext, params: Expression[]): Result => { + if (params.length != 2) { + return ctx.fail(`Fn::Equals expects exactly 2 params, got ${params.length}`) + } + return ctx.apply(ctx.evaluate(params[0]), x => + ctx.apply(ctx.evaluate(params[1]), y => { + if (equal(x, y)) { + return ctx.succeed(true); + } else { + return ctx.succeed(false); + } + })); + } +} + +/** + * Recognize forms such as {"Condition" : "SomeOtherCondition"}. If recognized, returns the conditionName. + */ +function parseConditionExpr(raw: Expression): string|undefined { + if (typeof raw !== 'object' || !('Condition' in raw)) { + return undefined; + } + const cond = (raw)['Condition']; + if (typeof cond !== 'string') { + return undefined; + } + return cond; +} + +/** + * Like `ctx.evaluate` but also recognizes Condition sub-expressions as required by `Fn::Or`. + */ +function evaluateConditionSubExpression(ctx: IntrinsicContext, expr: Expression): Result { + const firstExprConditonName = parseConditionExpr(expr); + if (firstExprConditonName !== undefined) { + return evaluateCondition(ctx, firstExprConditonName) + } else { + return ctx.apply(ctx.evaluate(expr), r => mustBeBoolean(ctx, r)); + } +} + +function mustBeBoolean(ctx: IntrinsicContext, r: any): Result { + if (typeof r === "boolean") { + return ctx.succeed(r); + } else { + return ctx.fail(`Expected a boolean, got ${typeof r}`); + } +} + +function evaluateCondition(ctx: IntrinsicContext, conditionName: string): Result { + const conditionExpr = ctx.findCondition(conditionName); + if (conditionExpr === undefined) { + return ctx.fail(`No condition '${conditionName}' found`); + } + return ctx.apply(ctx.evaluate(conditionExpr), r => mustBeBoolean(ctx, r)); +} diff --git a/tests/converters/intrinsics.test.ts b/tests/converters/intrinsics.test.ts new file mode 100644 index 0000000..f8a66ab --- /dev/null +++ b/tests/converters/intrinsics.test.ts @@ -0,0 +1,228 @@ +import * as intrinsics from '../../src/converters/intrinsics'; + +describe('Fn::If', () => { + test('picks true', async () => { + const tc = new TestContext({conditions: {'MyCondition': true}}); + const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']); + expect(result).toEqual(ok('yes')); + }); + + test('picks false', async () => { + const tc = new TestContext({conditions: {'MyCondition': false}}); + const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']); + expect(result).toEqual(ok('no')); + }); + + test('errors if condition is not found', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']); + expect(result).toEqual(failed(`No condition 'MyCondition' found`)); + }); + + test('errors if condition evaluates to a non-boolean', async () => { + const tc = new TestContext({conditions: {'MyCondition': 'OOPS'}}); + const result = runIntrinsic(intrinsics.fnIf, tc, ['MyCondition', 'yes', 'no']); + expect(result).toEqual(failed(`Expected a boolean, got string`)); + }); +}); + +describe('Fn::Or', () => { + test('picks true', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnOr, tc, [true, false, true]); + expect(result).toEqual(ok(true)); + }); + + test('picks false', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnOr, tc, [false, false, false]); + expect(result).toEqual(ok(false)); + }); + + test('picks true from inner Condition', async () => { + const tc = new TestContext({conditions: {'MyCondition': true}}); + const result = runIntrinsic(intrinsics.fnOr, tc, [false, {'Condition': 'MyCondition'}]); + expect(result).toEqual(ok(true)); + }); + + test('picks false with inner Condition', async () => { + const tc = new TestContext({conditions: {'MyCondition': false}}); + const result = runIntrinsic(intrinsics.fnOr, tc, [false, {'Condition': 'MyCondition'}]); + expect(result).toEqual(ok(false)); + }); + + test('has to have at least two arguments', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnOr, tc, [false]); + expect(result).toEqual(failed(`Fn::Or expects at least 2 params, got 1`)); + }); + + test('short-cirtcuits evaluation if true is found', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnOr, tc, [true, {'Condition': 'DoesNotExist'}]); + expect(result).toEqual(ok(true)); + }); +}) + +describe('Fn::And', () => { + test('picks true', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnAnd, tc, [true, true, true]); + expect(result).toEqual(ok(true)); + }); + + test('picks false', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnAnd, tc, [true, false, true]); + expect(result).toEqual(ok(false)); + }); + + test('picks true from inner Condition', async () => { + const tc = new TestContext({conditions: {'MyCondition': true}}); + const result = runIntrinsic(intrinsics.fnAnd, tc, [true, {'Condition': 'MyCondition'}]); + expect(result).toEqual(ok(true)); + }); + + test('picks false with inner Condition', async () => { + const tc = new TestContext({conditions: {'MyCondition': false}}); + const result = runIntrinsic(intrinsics.fnAnd, tc, [true, {'Condition': 'MyCondition'}]); + expect(result).toEqual(ok(false)); + }); + + test('has to have at least two arguments', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnAnd, tc, [false]); + expect(result).toEqual(failed(`Fn::And expects at least 2 params, got 1`)); + }); + + test('short-cirtcuits evaluation if false is found', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnAnd, tc, [false, {'Condition': 'DoesNotExist'}]); + expect(result).toEqual(ok(false)); + }); +}) + + +describe('Fn::Not', () => { + test('inverts false', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnNot, tc, [true]); + expect(result).toEqual(ok(false)); + }); + + test('inverts true', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnNot, tc, [false]); + expect(result).toEqual(ok(true)); + }); + + test('inverts a false Condition', async () => { + const tc = new TestContext({conditions: {'MyCondition': false}}); + const result = runIntrinsic(intrinsics.fnNot, tc, [{'Condition': 'MyCondition'}]); + expect(result).toEqual(ok(true)); + }); + + test('inverts a true Condition', async () => { + const tc = new TestContext({conditions: {'MyCondition': true}}); + const result = runIntrinsic(intrinsics.fnNot, tc, [{'Condition': 'MyCondition'}]); + expect(result).toEqual(ok(false)); + }); + + test('requires a boolean', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnNot, tc, ['ok']); + expect(result).toEqual(failed(`Expected a boolean, got string`)); + }); +}) + +describe('Fn::Equals', () => { + test('detects equal strings', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnEquals, tc, ['a', 'a']); + expect(result).toEqual(ok(true)); + }); + + test('detects unequal strings', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnEquals, tc, ['a', 'b']); + expect(result).toEqual(ok(false)); + }); + + test('detects equal objects', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnEquals, tc, [{x: 'a'}, {'x': 'a'}]); + expect(result).toEqual(ok(true)); + }); + + test('detects unequal objects', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnEquals, tc, [{x: 'a'}, {'x': 'b'}]); + expect(result).toEqual(ok(false)); + }); + + test('insists on two arguments', async () => { + const tc = new TestContext({}); + const result = runIntrinsic(intrinsics.fnEquals, tc, [1]); + expect(result).toEqual(failed(`Fn::Equals expects exactly 2 params, got 1`)); + }); +}) + +function runIntrinsic(fn: intrinsics.Intrinsic, tc: TestContext, args: intrinsics.Expression[]): TestResult { + const result: TestResult = (fn.evaluate(tc, args)); + return result; +}; + +type TestResult = + | {'ok': true, value: T} + | {'ok': false, errorMessage: string}; + +function ok(result: T): TestResult { + return {'ok': true, value: result}; +} + +function failed(errorMessage: string): TestResult { + return {'ok': false, errorMessage: errorMessage}; +} + +class TestContext implements intrinsics.IntrinsicContext { + conditions: { [id: string]: intrinsics.Expression }; + + constructor(args: {conditions?: { [id: string]: intrinsics.Expression }}) { + if (args.conditions) { + this.conditions = args.conditions; + } else { + this.conditions = {}; + } + } + + findCondition(conditionName: string): intrinsics.Expression|undefined { + if (this.conditions.hasOwnProperty(conditionName)) { + return this.conditions[conditionName]; + } + } + + evaluate(expression: intrinsics.Expression): intrinsics.Result { + // Self-evaluate the expression. This is very incomplete. + const result: TestResult = {'ok': true, value: expression}; + return result; + } + + apply(result: intrinsics.Result, fn: (x: T) => intrinsics.Result): intrinsics.Result { + const t: TestResult = result; // assume result is a TestResult + if (t.ok) { + return fn(t.value); + } else { + return {'ok': false, errorMessage: t.errorMessage}; + } + } + + fail(msg: string): intrinsics.Result { + const result: TestResult = {'ok': false, errorMessage: msg}; + return result; + } + + succeed(r: T): intrinsics.Result { + const result: TestResult = {'ok': true, value: r}; + return result; + } +}