From 6fab1ca019e5f6685ae5ac01d634995de909fe14 Mon Sep 17 00:00:00 2001 From: Tomer Aberbach Date: Wed, 25 Dec 2024 19:58:33 -0500 Subject: [PATCH] feat: basic templated type support (#76) --- package.json | 1 + pnpm-lock.yaml | 12 ++ src/arbitrary.ts | 43 +++++ src/components.ts | 58 ++++++ src/convert.ts | 70 ++++++- src/dependency-graph.ts | 4 + src/normalize.ts | 25 +++ test/index.ts | 32 +++- test/snapshots/templates/arbitraries.js | 28 +++ test/snapshots/templates/samples.js | 238 ++++++++++++++++++++++++ 10 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 test/snapshots/templates/arbitraries.js create mode 100644 test/snapshots/templates/samples.js diff --git a/package.json b/package.json index 3fbc699..e74e89b 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@alloy-js/core": "^0.3.0", "@alloy-js/typescript": "^0.3.0", "@rtsao/scc": "^1.1.0", + "camelcase": "^8.0.0", "keyalesce": "^2.2.0", "lfi": "^3.8.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abac3c8..199db76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@rtsao/scc': specifier: ^1.1.0 version: 1.1.0 + camelcase: + specifier: ^8.0.0 + version: 8.0.0 keyalesce: specifier: ^2.2.0 version: 2.2.0 @@ -2512,6 +2515,13 @@ packages: } engines: { node: '>=6' } + camelcase@8.0.0: + resolution: + { + integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==, + } + engines: { node: '>=16' } + caniuse-lite@1.0.30001686: resolution: { @@ -7664,6 +7674,8 @@ snapshots: callsites@3.1.0: {} + camelcase@8.0.0: {} + caniuse-lite@1.0.30001686: {} chai@5.1.2: diff --git a/src/arbitrary.ts b/src/arbitrary.ts index 790eb5f..7766f6b 100644 --- a/src/arbitrary.ts +++ b/src/arbitrary.ts @@ -23,6 +23,9 @@ export type Arbitrary = | IntersectionArbitrary | ReferenceArbitrary | RecursiveReferenceArbitrary + | FunctionDeclarationArbitrary + | ParameterReferenceArbitrary + | FunctionCallArbitrary export const neverArbitrary = (): NeverArbitrary => memoize({ type: `never` }) @@ -190,6 +193,36 @@ export type RecursiveReferenceArbitrary = { deref: () => ReferenceArbitrary } +export const functionDeclarationArbitrary = ( + options: Omit, +): FunctionDeclarationArbitrary => + memoize({ ...options, type: `function-declaration` }) + +export type FunctionDeclarationArbitrary = { + type: `function-declaration` + parameters: string[] + arbitrary: Arbitrary +} + +export const parameterReferenceArbitrary = ( + name: string, +): ParameterReferenceArbitrary => memoize({ type: `parameter-reference`, name }) + +export type ParameterReferenceArbitrary = { + type: `parameter-reference` + name: string +} + +export const functionCallArbitrary = ( + options: Omit, +): FunctionCallArbitrary => memoize({ ...options, type: `function-call` }) + +export type FunctionCallArbitrary = { + type: `function-call` + name: string + args: Arbitrary[] +} + const memoize = (arbitrary: A): A => { const arbitraryKey = getArbitraryKey(arbitrary) let cachedArbitrary = arbitraryCache.get(arbitraryKey) @@ -258,6 +291,16 @@ const getArbitraryKey = (arbitrary: Arbitrary): ArbitraryKey => { ]) case `recursive-reference`: return keyalesce([arbitrary.type, arbitrary.deref]) + case `function-declaration`: + return keyalesce([ + arbitrary.type, + ...arbitrary.parameters, + arbitrary.arbitrary, + ]) + case `parameter-reference`: + return keyalesce([arbitrary.type, arbitrary.name]) + case `function-call`: + return keyalesce([arbitrary.type, arbitrary.name, ...arbitrary.args]) } } diff --git a/src/components.ts b/src/components.ts index 472601b..f1b5a84 100644 --- a/src/components.ts +++ b/src/components.ts @@ -32,6 +32,8 @@ import type { ConstantArbitrary, DictionaryArbitrary, EnumArbitrary, + FunctionCallArbitrary, + FunctionDeclarationArbitrary, IntersectionArbitrary, NumberArbitrary, RecordArbitrary, @@ -322,6 +324,20 @@ const ArbitraryDefinition = ({ }) case `recursive-reference`: return RecursiveReferenceArbitrary({ arbitrary: arbitrary.deref() }) + case `function-declaration`: + return FunctionDeclarationArbitrary({ + arbitrary, + sharedArbitraries, + currentStronglyConnectedArbitraries, + }) + case `parameter-reference`: + return arbitrary.name + case `function-call`: + return FunctionCallArbitrary({ + arbitrary, + sharedArbitraries, + currentStronglyConnectedArbitraries, + }) } } @@ -638,6 +654,48 @@ const RecursiveReferenceArbitrary = ({ arbitrary: ReferenceArbitrary }): Child => code`tie(${StringLiteral({ string: arbitrary.name })})` +const FunctionDeclarationArbitrary = ({ + arbitrary, + sharedArbitraries, + currentStronglyConnectedArbitraries, +}: { + arbitrary: FunctionDeclarationArbitrary + sharedArbitraries: SharedArbitraries + currentStronglyConnectedArbitraries: Set +}): Child => code` + ${ + arbitrary.parameters.length === 1 + ? arbitrary.parameters[0] + : code`(${Commas({ values: arbitrary.parameters, oneLine: true })})` + } => + ${Arbitrary({ + arbitrary: arbitrary.arbitrary, + sharedArbitraries, + currentStronglyConnectedArbitraries, + })} +` + +const FunctionCallArbitrary = ({ + arbitrary, + sharedArbitraries, + currentStronglyConnectedArbitraries, +}: { + arbitrary: FunctionCallArbitrary + sharedArbitraries: SharedArbitraries + currentStronglyConnectedArbitraries: Set +}): Child => + CallExpression({ + name: arbitrary.name, + args: arbitrary.args.map(arg => + Arbitrary({ + arbitrary: arg, + sharedArbitraries, + currentStronglyConnectedArbitraries, + }), + ), + oneLine: true, + }) + const ArrayExpression = ({ values }: { values: Child[] }): Child => code`[${ayJoin(values, { joiner: `, ` })}]` diff --git a/src/convert.ts b/src/convert.ts index a2fb593..5638b5f 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -1,5 +1,5 @@ import assert from 'node:assert' -import { getDoc } from '@typespec/compiler' +import { getDoc, isTemplateDeclaration } from '@typespec/compiler' import type { BooleanLiteral, Enum, @@ -13,6 +13,8 @@ import type { Program, Scalar, StringLiteral, + TemplateParameter, + TemplatedType, Tuple, Type, Union, @@ -33,6 +35,7 @@ import { values, } from 'lfi' import keyalesce from 'keyalesce' +import camelcase from 'camelcase' import { anythingArbitrary, arrayArbitrary, @@ -42,9 +45,12 @@ import { constantArbitrary, dictionaryArbitrary, enumArbitrary, + functionCallArbitrary, + functionDeclarationArbitrary, intersectionArbitrary, neverArbitrary, numberArbitrary, + parameterReferenceArbitrary, recordArbitrary, recursiveReferenceArbitrary, referenceArbitrary, @@ -59,6 +65,7 @@ import type { ArrayArbitrary, ConstantArbitrary, DictionaryArbitrary, + ParameterReferenceArbitrary, RecordArbitrary, StringArbitrary, } from './arbitrary.ts' @@ -141,6 +148,28 @@ const convertType = ( return arbitrary }), ) + + if ( + isTemplatedType(type) && + type.templateMapper && + !isTypeSpecNamespace(type.namespace) + ) { + return functionCallArbitrary({ + name: String(type.name), + // eslint-disable-next-line array-callback-return + args: type.templateMapper.args.map(arg => { + switch (arg.entityKind) { + case `Type`: + return convertType(program, arg, constraints) + case `Value`: + return convertValue(arg) + case `Indeterminate`: + throw new Error(`Unhandled entity: ${arg.entityKind}`) + } + }), + }) + } + switch (type.kind) { case `Intrinsic`: arbitrary = convertIntrinsic(type) @@ -171,12 +200,14 @@ const convertType = ( case `ModelProperty`: arbitrary = convertType(program, type.type, constraints) break + case `TemplateParameter`: + arbitrary = convertTemplateParameter(type) + break case `Operation`: throw new Error(`Unhandled type: ${type.kind}`) case `ScalarConstructor`: case `EnumMember`: case `UnionVariant`: - case `TemplateParameter`: case `Namespace`: case `Decorator`: case `Function`: @@ -185,7 +216,21 @@ const convertType = ( case `Projection`: case `StringTemplate`: case `StringTemplateSpan`: - throw new Error(`Unreachable`) + throw new Error(`Unreachable: ${type.kind}`) + } + + if ( + isTemplatedType(type) && + isTemplateDeclaration(type) && + !isTypeSpecNamespace(type.namespace) + ) { + assert(arbitrary.type === `reference`) + arbitrary.arbitrary = functionDeclarationArbitrary({ + parameters: type.node.templateParameters.map(parameter => + camelcase(parameter.symbol.name), + ), + arbitrary: arbitrary.arbitrary, + }) } arbitrary = normalizeArbitrary(arbitrary) @@ -194,6 +239,20 @@ const convertType = ( return arbitrary } +const isTemplatedType = (type: Type): type is TemplatedType => { + // eslint-disable-next-line typescript/switch-exhaustiveness-check + switch (type.kind) { + case `Scalar`: + case `Union`: + case `Model`: + case `Interface`: + case `Operation`: + return true + default: + return false + } +} + const typeToArbitrary = new Map() type TypeKey = ReturnType @@ -560,6 +619,11 @@ const toJsValue = (value: Value): unknown => { } } +const convertTemplateParameter = ( + templateParameter: TemplateParameter, +): ParameterReferenceArbitrary => + parameterReferenceArbitrary(camelcase(templateParameter.node.symbol.name)) + const isTypeSpecNamespace = (namespace?: Namespace): boolean => namespace?.name === `TypeSpec` diff --git a/src/dependency-graph.ts b/src/dependency-graph.ts index 88fd8d0..5abeb4e 100644 --- a/src/dependency-graph.ts +++ b/src/dependency-graph.ts @@ -110,6 +110,8 @@ const getDirectArbitraryDependencies = (arbitrary: Arbitrary): Arbitrary[] => { case `url`: case `bytes`: case `enum`: + case `function-declaration`: + case `parameter-reference`: return [] case `array`: return [arbitrary.value] @@ -131,5 +133,7 @@ const getDirectArbitraryDependencies = (arbitrary: Arbitrary): Arbitrary[] => { return [arbitrary.arbitrary] case `recursive-reference`: return [arbitrary.deref()] + case `function-call`: + return arbitrary.args } } diff --git a/src/normalize.ts b/src/normalize.ts index 9e10218..2616400 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -5,6 +5,8 @@ import { constantArbitrary, dictionaryArbitrary, enumArbitrary, + functionCallArbitrary, + functionDeclarationArbitrary, intersectionArbitrary, neverArbitrary, recordArbitrary, @@ -17,6 +19,8 @@ import type { ArrayArbitrary, ConstantArbitrary, DictionaryArbitrary, + FunctionCallArbitrary, + FunctionDeclarationArbitrary, IntersectionArbitrary, RecordArbitrary, ReferenceArbitrary, @@ -37,6 +41,7 @@ const normalizeArbitrary = (arbitrary: Arbitrary): Arbitrary => { case `bytes`: case `enum`: case `recursive-reference`: + case `parameter-reference`: return arbitrary case `array`: return normalizeArrayArbitrary(arbitrary) @@ -52,6 +57,10 @@ const normalizeArbitrary = (arbitrary: Arbitrary): Arbitrary => { return normalizeIntersectionArbitrary(arbitrary) case `reference`: return normalizeReferenceArbitrary(arbitrary) + case `function-declaration`: + return normalizeFunctionDeclarationArbitrary(arbitrary) + case `function-call`: + return normalizeFunctionCallArbitrary(arbitrary) } } @@ -137,4 +146,20 @@ const normalizeReferenceArbitrary = ( arbitrary: normalizeArbitrary(arbitrary.arbitrary), }) +const normalizeFunctionDeclarationArbitrary = ( + arbitrary: FunctionDeclarationArbitrary, +): Arbitrary => + functionDeclarationArbitrary({ + ...arbitrary, + arbitrary: normalizeArbitrary(arbitrary.arbitrary), + }) + +const normalizeFunctionCallArbitrary = ( + arbitrary: FunctionCallArbitrary, +): Arbitrary => + functionCallArbitrary({ + ...arbitrary, + args: arbitrary.args.map(normalizeArbitrary), + }) + export default normalizeArbitrary diff --git a/test/index.ts b/test/index.ts index 42c62a6..5cba5b4 100644 --- a/test/index.ts +++ b/test/index.ts @@ -3,7 +3,7 @@ import { beforeEach, expect, test } from 'vitest' import { createTestHost, createTestWrapper } from '@typespec/compiler/testing' import type { BasicTestRunner } from '@typespec/compiler/testing' import * as fc from 'fast-check' -import { entries, filterMap, pipe, reduce, toObject } from 'lfi' +import { entries, filterMap, pipe, reduce, repeat, take, toObject } from 'lfi' import { importFromString } from 'module-from-string' import { serializeError } from 'serialize-error' import jsesc from 'jsesc' @@ -744,6 +744,31 @@ test.each([ } `, }, + { + name: `templates`, + code: ` + union TemplateUnion { + a: A, + b: B + } + + model $Model { + instantiatedUnion: TemplateUnion + } + + model TemplateModel1 { + a: A, + b: B + } + + model TemplateModel2 { + property1: TemplateModel1, + property2: A | B + } + + model InstantiatedModel1 is TemplateModel1; + `, + }, { name: `comments`, code: ` @@ -910,6 +935,11 @@ const sampleArbitraries = ( pipe( entries(arbitraries), filterMap(([name, value]) => { + if (typeof value === `function`) { + // eslint-disable-next-line typescript/no-unsafe-call + value = value(...pipe(repeat(fc.anything()), take(value.length))) + } + if (value === null || typeof value !== `object`) { return null } diff --git a/test/snapshots/templates/arbitraries.js b/test/snapshots/templates/arbitraries.js new file mode 100644 index 0000000..2e9619e --- /dev/null +++ b/test/snapshots/templates/arbitraries.js @@ -0,0 +1,28 @@ +import * as fc from "fast-check"; + +export const TemplateUnion = (a, b) => + fc.oneof( + a, + b, + ); + +export const $Model = fc.record({ + instantiatedUnion: TemplateUnion(fc.integer(), fc.string()), +}); + +export const TemplateModel1 = (a, b) => + fc.record({ + a: a, + b: b, + }); + +export const TemplateModel2 = (a, b) => + fc.record({ + property1: TemplateModel1(a, b), + property2: fc.oneof( + a, + b, + ), + }); + +export const InstantiatedModel1 = TemplateModel1(fc.string(), fc.string()); diff --git a/test/snapshots/templates/samples.js b/test/snapshots/templates/samples.js new file mode 100644 index 0000000..fe604bb --- /dev/null +++ b/test/snapshots/templates/samples.js @@ -0,0 +1,238 @@ +export const samples = { + '$Model': [ + { + 'instantiatedUnion': -2 + }, + { + 'instantiatedUnion': 'x$Z' + }, + { + 'instantiatedUnion': 'w ' + }, + { + 'instantiatedUnion': 2147483626 + }, + { + 'instantiatedUnion': 542422934 + } + ], + 'InstantiatedModel1': [ + { + 'a': '', + 'b': '%' + }, + { + 'a': 'W|%=2Spc', + 'b': 'Z =R' + }, + { + 'a': 'X1DZwS', + 'b': 'c' + }, + { + 'a': 'gp', + 'b': '#F' + }, + { + 'a': '', + 'b': 'Q1 "~g' + } + ], + 'TemplateModel1': [ + { + 'a': [ + null + ], + 'b': [ + '#', + -1.545068207740197e-91, + false, + 'ref', + -1, + false, + -7122146618920170, + -9007199254740986, + -53, + -4.980068599881027e-156 + ] + }, + { + 'a': { + '2Spc0sZ': [ + '%.%}ayW&;' + ], + 'A$(;2O': [], + 'MU6\'': { + '^bu': '', + 'B.gE': false + } + }, + 'b': -4156858088246024 + }, + { + 'a': [ + ' ', + [ + true, + { + '"': 'n' + } + ] + ], + 'b': false + }, + { + 'a': 'g', + 'b': 'kh#F' + }, + { + 'a': [], + 'b': [ + -2.42366833263704e+292, + -2714846440565153, + true, + -2.9746991188201746e+275, + 6955436571359885, + -1.5040453034941292e-37 + ] + } + ], + 'TemplateModel2': [ + { + 'property1': { + 'a': [ + null + ], + 'b': [ + '#', + -1.545068207740197e-91, + false, + 'ref', + -1, + false, + -7122146618920170, + -9007199254740986, + -53, + -4.980068599881027e-156 + ] + }, + 'property2': { + '_F\\': undefined, + 'nfhN~o!%u': undefined, + '5h,': 8378816050479509, + 'TTXB!]': -1095597414057245, + '}anE~fZ': '0!', + 'Sn(J8Hq(`': '[', + 'K~>|q,mA)': -1312907723665260, + '': 2880768520119641 + } + }, + { + 'property1': { + 'a': { + '2Spc0sZ': [ + '%.%}ayW&;' + ], + 'A$(;2O': [], + 'MU6\'': { + '^bu': '', + 'B.gE': false + } + }, + 'b': -4156858088246024 + }, + 'property2': -2e-322 + }, + { + 'property1': { + 'a': [ + ' ', + [ + true, + { + '"': 'n' + } + ] + ], + 'b': false + }, + 'property2': { + 'MM,': { + 'g=+kU,Q': true, + '`\'i': -1524501238707520, + '#9_`4fl9': -1910162777138274, + 'x': false, + '|X;MPfa>7': 'SM,?kLibg', + '*^': undefined, + 'ml': 5798669755948247, + '"M\'LSX': false, + '@l!&_lca@': '_|ABZ', + '8Cv!m': 1.2004877007287117e-90 + }, + 'y(6/|3t': [ + 7.88330396372615e+111, + '(2.T1-Z&', + undefined, + -2896274675869791, + null, + -2.4530223397392525e-145, + undefined + ] + } + }, + { + 'property1': { + 'a': 'g', + 'b': 'kh#F' + }, + 'property2': -2.5e-322 + }, + { + 'property1': { + 'a': [], + 'b': [ + -2.42366833263704e+292, + -2714846440565153, + true, + -2.9746991188201746e+275, + 6955436571359885, + -1.5040453034941292e-37 + ] + }, + 'property2': 'w"{' + } + ], + 'TemplateUnion': [ + { + 'toLoc': { + 'J5