diff --git a/.chronus/changes/witemple-msft-extern-fn-2025-10-21-12-47-57.md b/.chronus/changes/witemple-msft-extern-fn-2025-10-21-12-47-57.md new file mode 100644 index 00000000000..ffa1c37382a --- /dev/null +++ b/.chronus/changes/witemple-msft-extern-fn-2025-10-21-12-47-57.md @@ -0,0 +1,9 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Added support for Functions, a new type graph entity and language feature. Functions enable library authors to provide input-output style transforms that operate on types and values. See [the Functions Documentation](https://typespec.io/docs/language-basics/functions/) for more information about the use and implementation of functions. + +Added an `unknown` value that can be used to denote when a property or parameter _has_ a default value, but its value cannot be expressed in TypeSpec (for example, because it depends on the server instance where it is generated, or because the service author simply does not with to specify how the default value is generated). This adds a new kind of `Value` to the language that represents a value that is not known. diff --git a/.chronus/changes/witemple-msft-extern-fn-2025-10-21-12-48-14.md b/.chronus/changes/witemple-msft-extern-fn-2025-10-21-12-48-14.md new file mode 100644 index 00000000000..ad95fdc5886 --- /dev/null +++ b/.chronus/changes/witemple-msft-extern-fn-2025-10-21-12-48-14.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi3" +--- + +Updated emitter logic to ignore `unknown` values in parameter and schema property defaults. diff --git a/.chronus/changes/witemple-msft-extern-fn-2025-10-21-12-48-30.md b/.chronus/changes/witemple-msft-extern-fn-2025-10-21-12-48-30.md new file mode 100644 index 00000000000..5f99ed1e74b --- /dev/null +++ b/.chronus/changes/witemple-msft-extern-fn-2025-10-21-12-48-30.md @@ -0,0 +1,20 @@ +--- +changeKind: internal +packages: + - "@typespec/events" + - "@typespec/html-program-viewer" + - "@typespec/http-client" + - "@typespec/http" + - "@typespec/json-schema" + - "@typespec/openapi" + - "@typespec/protobuf" + - "@typespec/rest" + - "@typespec/spector" + - "@typespec/sse" + - "@typespec/streams" + - "@typespec/tspd" + - "@typespec/versioning" + - "@typespec/xml" +--- + +Updated `tspd` to generate extern function signatures. Regenerated all extern signatures. diff --git a/cspell.yaml b/cspell.yaml index b555dc79a7d..444a5de0385 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -141,6 +141,7 @@ words: - lzutf - MACVMIMAGE - MACVMIMAGEM + - marshal - mday - methodsubscriptionid - mgmt @@ -274,6 +275,7 @@ words: - unioned - unionified - unionify + - unmarshal - unparented - unprefixed - unprojected diff --git a/packages/compiler/generated-defs/TypeSpec.Prototypes.ts-test.ts b/packages/compiler/generated-defs/TypeSpec.Prototypes.ts-test.ts index ba67b433b56..27a99b09c6f 100644 --- a/packages/compiler/generated-defs/TypeSpec.Prototypes.ts-test.ts +++ b/packages/compiler/generated-defs/TypeSpec.Prototypes.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecPrototypesDecorators } from "./TypeSpec.Prototypes.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecPrototypesDecorators = $decorators["TypeSpec.Prototypes"]; +const _decs: TypeSpecPrototypesDecorators = $decorators["TypeSpec.Prototypes"]; diff --git a/packages/compiler/generated-defs/TypeSpec.ts-test.ts b/packages/compiler/generated-defs/TypeSpec.ts-test.ts index 12337f14a8a..4e0ce70e3c3 100644 --- a/packages/compiler/generated-defs/TypeSpec.ts-test.ts +++ b/packages/compiler/generated-defs/TypeSpec.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecDecorators } from "./TypeSpec.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecDecorators = $decorators["TypeSpec"]; +const _decs: TypeSpecDecorators = $decorators["TypeSpec"]; diff --git a/packages/compiler/src/core/binder.ts b/packages/compiler/src/core/binder.ts index 1e7b10c3c65..877f91ef866 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -13,6 +13,7 @@ import { EnumStatementNode, FileLibraryMetadata, FunctionDeclarationStatementNode, + FunctionImplementations, FunctionParameterNode, InterfaceStatementNode, IntersectionExpressionNode, @@ -133,7 +134,6 @@ export function createBinder(program: Program): Binder { for (const [key, member] of Object.entries(sourceFile.esmExports)) { let name: string; - let kind: "decorator" | "function"; if (key === "$flags") { const context = getLocationContext(program, sourceFile); if (context.type === "library" || context.type === "project") { @@ -152,12 +152,24 @@ export function createBinder(program: Program): Binder { ); } } + } else if (key === "$functions") { + const value: FunctionImplementations = member as any; + for (const [namespaceName, functions] of Object.entries(value)) { + for (const [functionName, fn] of Object.entries(functions)) { + bindFunctionImplementation( + namespaceName === "" ? [] : namespaceName.split("."), + "function", + functionName, + fn, + sourceFile, + ); + } + } } else if (typeof member === "function") { // lots of 'any' casts here because control flow narrowing `member` to Function // isn't particularly useful it turns out. if (isFunctionName(key)) { name = getFunctionName(key); - kind = "decorator"; if (name === "onValidate") { const context = getLocationContext(program, sourceFile); const metadata = @@ -170,12 +182,9 @@ export function createBinder(program: Program): Binder { // nothing to do here this is loaded as emitter. continue; } - } else { - name = key; - kind = "function"; + const nsParts = resolveJSMemberNamespaceParts(rootNs, member); + bindFunctionImplementation(nsParts, "decorator", name, member as any, sourceFile); } - const nsParts = resolveJSMemberNamespaceParts(rootNs, member); - bindFunctionImplementation(nsParts, kind, name, member as any, sourceFile); } } } diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index b39e55cd85f..8a6b6e3cf77 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -10,12 +10,12 @@ import { createTupleToArrayValueCodeFix, } from "./compiler-code-fixes/convert-to-value.codefix.js"; import { getDeprecationDetails, markDeprecated } from "./deprecation.js"; -import { compilerAssert, ignoreDiagnostics } from "./diagnostics.js"; +import { compilerAssert, createDiagnosticCollector, ignoreDiagnostics } from "./diagnostics.js"; import { validateInheritanceDiscriminatedUnions } from "./helpers/discriminator-utils.js"; import { explainStringTemplateNotSerializable } from "./helpers/string-template-utils.js"; import { typeReferenceToString } from "./helpers/syntax-utils.js"; import { getEntityName, getTypeName } from "./helpers/type-name-utils.js"; -import { marshallTypeForJS } from "./js-marshaller.js"; +import { marshalTypeForJs, unmarshalJsToValue } from "./js-marshaller.js"; import { createDiagnostic } from "./messages.js"; import { NameResolver } from "./name-resolver.js"; import { Numeric } from "./numeric.js"; @@ -57,6 +57,7 @@ import { DecoratorDeclarationStatementNode, DecoratorExpressionNode, Diagnostic, + DiagnosticResult, DiagnosticTarget, DocContent, Entity, @@ -67,9 +68,11 @@ import { EnumValue, ErrorType, Expression, + FunctionContext, FunctionDeclarationStatementNode, FunctionParameter, FunctionParameterNode, + FunctionType, IdentifierKind, IdentifierNode, IndeterminateEntity, @@ -155,6 +158,7 @@ import { UnionVariant, UnionVariantNode, UnknownType, + UnknownValue, UsingStatementNode, Value, ValueWithTemplate, @@ -299,6 +303,9 @@ export interface Checker { /** @internal */ readonly anyType: UnknownType; + /** @internal */ + readonly unknownEntity: IndeterminateEntity; + /** @internal */ stats: CheckerStats; } @@ -363,6 +370,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const unknownType = createAndFinishType({ kind: "Intrinsic", name: "unknown" } as const); const nullType = createAndFinishType({ kind: "Intrinsic", name: "null" } as const); + const unknownEntity: IndeterminateEntity = { + entityKind: "Indeterminate", + type: unknownType, + }; + /** * Set keeping track of node pending type resolution. * Key is the SymId of a node. It can be retrieved with getNodeSymId(node) @@ -392,6 +404,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker errorType, nullType, anyType: unknownType, + unknownEntity, voidType, typePrototype, createType, @@ -686,6 +699,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker switch (type.name) { case "null": return checkNullValue(type as any, constraint, node); + case "unknown": + return checkUnknownValue(type as UnknownType, constraint); } return type; default: @@ -794,6 +809,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // If there were diagnostic reported but we still got a value this means that the value might be invalid. reportCheckerDiagnostics(valueDiagnostics); return result; + } else { + const canBeType = constraint?.constraint.type !== undefined; + // If the node _must_ resolve to a value, we will return it unconstrained, so that we will at least produce + // a value. If it _can_ be a type, we already failed the value constraint, so we return the type as is. + return canBeType ? entity.type : getValueFromIndeterminate(entity.type, undefined, node); } } @@ -878,7 +898,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker case SyntaxKind.NeverKeyword: return neverType; case SyntaxKind.UnknownKeyword: - return unknownType; + return unknownEntity; case SyntaxKind.ObjectLiteral: return checkObjectValue(node, mapper, valueConstraint); case SyntaxKind.ArrayLiteral: @@ -1073,19 +1093,21 @@ export function createChecker(program: Program, resolver: NameResolver): Checker * @param node Node. * @param mapper Type mapper for template instantiation context. * @param instantiateTemplate If templated type should be instantiated if they haven't yet. + * @param allowFunctions If functions are allowed as types. * @returns Resolved type. */ function checkTypeReference( node: TypeReferenceNode | MemberExpressionNode | IdentifierNode, mapper: TypeMapper | undefined, instantiateTemplate = true, + allowFunctions = false, ): Type { const sym = resolveTypeReferenceSym(node, mapper); if (!sym) { return errorType; } - const type = checkTypeReferenceSymbol(sym, node, mapper, instantiateTemplate); + const type = checkTypeReferenceSymbol(sym, node, mapper, instantiateTemplate, allowFunctions); return type; } @@ -1401,6 +1423,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker * @param node Node * @param mapper Type mapper for template instantiation context. * @param instantiateTemplates If a templated type should be instantiated if not yet @default true + * @param allowFunctions If functions are allowed as types. @default false * @returns resolved type. */ function checkTypeReferenceSymbol( @@ -1408,8 +1431,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node: TypeReferenceNode | MemberExpressionNode | IdentifierNode, mapper: TypeMapper | undefined, instantiateTemplates = true, + allowFunctions = false, ): Type { - const result = checkTypeOrValueReferenceSymbol(sym, node, mapper, instantiateTemplates); + const result = checkTypeOrValueReferenceSymbol( + sym, + node, + mapper, + instantiateTemplates, + allowFunctions, + ); if (result === null || isValue(result)) { reportCheckerDiagnostic(createDiagnostic({ code: "value-in-type", target: node })); return errorType; @@ -1425,8 +1455,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node: TypeReferenceNode | MemberExpressionNode | IdentifierNode, mapper: TypeMapper | undefined, instantiateTemplates = true, + allowFunctions = false, ): Type | Value | IndeterminateEntity | null { - const entity = checkTypeOrValueReferenceSymbolWorker(sym, node, mapper, instantiateTemplates); + const entity = checkTypeOrValueReferenceSymbolWorker( + sym, + node, + mapper, + instantiateTemplates, + allowFunctions, + ); if (entity !== null && isType(entity) && entity.kind === "TemplateParameter") { templateParameterUsageMap.set(entity.node!, true); @@ -1439,6 +1476,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node: TypeReferenceNode | MemberExpressionNode | IdentifierNode, mapper: TypeMapper | undefined, instantiateTemplates = true, + allowFunctions = false, ): Type | Value | IndeterminateEntity | null { if (sym.flags & SymbolFlags.Const) { return getValueForNode(sym.declarations[0], mapper); @@ -1452,9 +1490,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return errorType; } - if (sym.flags & SymbolFlags.Function) { + if (!allowFunctions && sym.flags & SymbolFlags.Function) { reportCheckerDiagnostic( - createDiagnostic({ code: "invalid-type-ref", messageId: "function", target: sym }), + createDiagnostic({ code: "invalid-type-ref", messageId: "function", target: node }), ); return errorType; @@ -1646,7 +1684,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker args: (Type | Value | IndeterminateEntity)[], source: TypeMapper["source"], parentMapper: TypeMapper | undefined, - instantiateTempalates = true, + instantiateTemplates = true, ): Type { const symbolLinks = templateNode.kind === SyntaxKind.OperationStatement && @@ -1677,7 +1715,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (cached) { return cached; } - if (instantiateTempalates) { + if (instantiateTemplates) { return instantiateTemplate(symbolLinks.instantiations, templateNode, params, mapper); } else { return errorType; @@ -1923,9 +1961,53 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkFunctionDeclaration( node: FunctionDeclarationStatementNode, mapper: TypeMapper | undefined, - ) { - reportCheckerDiagnostic(createDiagnostic({ code: "function-unsupported", target: node })); - return errorType; + ): FunctionType { + const mergedSymbol = getMergedSymbol(node.symbol); + const links = getSymbolLinks(mergedSymbol); + + if (links.declaredType && mapper === undefined) { + // we're not instantiating this operation and we've already checked it + return links.declaredType as FunctionType; + } + + const namespace = getParentNamespaceType(node); + compilerAssert( + namespace, + `Function ${node.id.sv} should have resolved a declared namespace or the global namespace.`, + ); + + const name = node.id.sv; + + if (!(node.modifierFlags & ModifierFlags.Extern)) { + reportCheckerDiagnostic(createDiagnostic({ code: "function-extern", target: node })); + } + + const implementation = mergedSymbol.value; + if (implementation === undefined) { + reportCheckerDiagnostic(createDiagnostic({ code: "missing-implementation", target: node })); + } + + const functionType: FunctionType = createType({ + kind: "Function", + name, + namespace, + node, + parameters: node.parameters.map((x) => checkFunctionParameter(x, mapper, true)), + returnType: node.returnType + ? getParamConstraintEntityForNode(node.returnType, mapper) + : ({ + entityKind: "MixedParameterConstraint", + type: unknownType, + } satisfies MixedParameterConstraint), + implementation: + implementation ?? Object.assign(() => errorType, { isDefaultFunctionImplementation: true }), + }); + + namespace.functionDeclarations.set(name, functionType); + + linkType(links, functionType, mapper); + + return functionType; } function checkFunctionParameter( @@ -3011,12 +3093,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker case IdentifierKind.Decorator: // Only return decorators and namespaces when completing decorator return !!(sym.flags & (SymbolFlags.Decorator | SymbolFlags.Namespace)); + case IdentifierKind.Function: + // Only return functions and namespaces when completing function calls + return !!(sym.flags & (SymbolFlags.Function | SymbolFlags.Namespace)); case IdentifierKind.Using: // Only return namespaces when completing using return !!(sym.flags & SymbolFlags.Namespace); case IdentifierKind.TypeReference: - // Do not return functions or decorators when completing types - return !(sym.flags & (SymbolFlags.Function | SymbolFlags.Decorator)); + // Do not return decorators when completing types + return !(sym.flags & SymbolFlags.Decorator); case IdentifierKind.TemplateArgument: return !!(sym.flags & SymbolFlags.TemplateParameter); default: @@ -4142,6 +4227,21 @@ export function createChecker(program: Program, resolver: NameResolver): Checker ); } + function checkUnknownValue( + unknownType: UnknownType, + constraint: CheckValueConstraint | undefined, + ): UnknownValue | null { + return createValue( + { + entityKind: "Value", + + valueKind: "UnknownValue", + type: neverType, + }, + constraint ? constraint.type : neverType, + ); + } + function checkEnumValue( literalType: EnumMember, constraint: CheckValueConstraint | undefined, @@ -4165,19 +4265,29 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkCallExpressionTarget( node: CallExpressionNode, mapper: TypeMapper | undefined, - ): ScalarConstructor | Scalar | null { - const target = checkTypeReference(node.target, mapper); + ): ScalarConstructor | Scalar | FunctionType | null { + const target = checkTypeReference( + node.target, + mapper, + /* instantiateTemplate */ true, + /* allowFunctions */ true, + ); - if (target.kind === "Scalar" || target.kind === "ScalarConstructor") { + if ( + target.kind === "Scalar" || + target.kind === "ScalarConstructor" || + target.kind === "Function" + ) { return target; } else { - reportCheckerDiagnostic( - createDiagnostic({ - code: "non-callable", - format: { type: target.kind }, - target: node.target, - }), - ); + if (!isErrorType(target)) + reportCheckerDiagnostic( + createDiagnostic({ + code: "non-callable", + format: { type: target.kind }, + target: node.target, + }), + ); return null; } } @@ -4321,13 +4431,15 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkCallExpression( node: CallExpressionNode, mapper: TypeMapper | undefined, - ): Value | null { + ): Type | Value | null { const target = checkCallExpressionTarget(node, mapper); if (target === null) { return null; } if (target.kind === "ScalarConstructor") { return createScalarValue(node, mapper, target); + } else if (target.kind === "Function") { + return checkFunctionCall(node, target, mapper); } if (relation.areScalarsRelated(target, getStdType("string"))) { @@ -4348,6 +4460,381 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } + function checkFunctionCall( + node: CallExpressionNode, + target: FunctionType, + mapper: TypeMapper | undefined, + ): Type | Value | null { + const [satisfied, resolvedArgs] = checkFunctionCallArguments(node.arguments, target, mapper); + + const canCall = satisfied && !(target.implementation as any).isDefaultFunctionImplementation; + + const ctx = createFunctionContext(program, node); + + const functionReturn = canCall + ? target.implementation(ctx, ...resolvedArgs) + : getDefaultFunctionResult(target.returnType); + + const returnIsEntity = + typeof functionReturn === "object" && + functionReturn !== null && + "entityKind" in functionReturn && + (functionReturn.entityKind === "Type" || + functionReturn.entityKind === "Value" || + functionReturn.entityKind === "Indeterminate"); + + // special case for when the return value is `undefined` and the return type is `void` or `valueof void`. + if (functionReturn === undefined && isVoidReturn(target.returnType)) { + return voidType; + } + + const unmarshaled = returnIsEntity + ? (functionReturn as Type | Value) + : unmarshalJsToValue(program, functionReturn, function onInvalid(value) { + let valueSummary = String(value); + if (valueSummary.length > 30) { + valueSummary = valueSummary.slice(0, 27) + "..."; + } + reportCheckerDiagnostic( + createDiagnostic({ + code: "function-return", + messageId: "invalid-value", + format: { value: valueSummary }, + target: node, + }), + ); + }); + + let result: Type | Value | IndeterminateEntity | null = unmarshaled; + if (satisfied) result = checkFunctionReturn(target, unmarshaled, node); + + return result; + } + + function isVoidReturn(constraint: MixedParameterConstraint): boolean { + if (constraint.valueType) { + return false; + } + + if (constraint.type) { + if (!isVoidType(constraint.type)) return false; + } + + return true; + + function isVoidType(type: Type): type is VoidType { + return type.kind === "Intrinsic" && type.name === "void"; + } + } + + function getDefaultFunctionResult(constraint: MixedParameterConstraint): Type | Value { + if (constraint.valueType) { + return createValue( + { + valueKind: "UnknownValue", + entityKind: "Value", + type: constraint.valueType, + }, + constraint.valueType, + ); + } else { + compilerAssert( + constraint.type, + "Expected function to have a return type when it did not have a value type constraint", + ); + return constraint.type; + } + } + + function checkFunctionCallArguments( + args: Expression[], + target: FunctionType, + mapper: TypeMapper | undefined, + ): [boolean, any[]] { + let satisfied = true; + const minArgs = target.parameters.filter((p) => !p.optional && !p.rest).length; + const maxArgs = target.parameters[target.parameters.length - 1]?.rest + ? undefined + : target.parameters.length; + + if (args.length < minArgs) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "invalid-argument-count", + messageId: "atLeast", + format: { actual: args.length.toString(), expected: minArgs.toString() }, + target: target.node!, + }), + ); + return [false, []]; + } else if (maxArgs !== undefined && args.length > maxArgs) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "invalid-argument-count", + format: { actual: args.length.toString(), expected: maxArgs.toString() }, + target: target.node!, + }), + ); + // This error doesn't actually prevent us from checking the arguments and evaluating the function. + } + + const collector = createDiagnosticCollector(); + + const resolvedArgs: any[] = []; + + let idx = 0; + + for (const param of target.parameters) { + if (param.rest) { + const constraint = extractRestParamConstraint(param.type); + + if (!constraint) { + satisfied = false; + continue; + } + + const restArgExpressions = args.slice(idx); + + const restArgs = restArgExpressions.map((arg) => + getTypeOrValueForNode(arg, mapper, { kind: "argument", constraint }), + ); + + if (restArgs.some((x) => x === null)) { + satisfied = false; + continue; + } + + resolvedArgs.push( + ...restArgs.map((v, idx) => + v !== null && isValue(v) + ? marshalTypeForJs(v, undefined, function onUnknown() { + satisfied = false; + reportCheckerDiagnostic( + createDiagnostic({ + code: "unknown-value", + messageId: "in-js-argument", + target: restArgExpressions[idx], + }), + ); + }) + : v, + ), + ); + } else { + const arg = args[idx++]; + + if (!arg) { + if (param.optional) { + resolvedArgs.push(undefined); + continue; + } else { + // No need to report a diagnostic here because we already reported one for + // invalid argument counts above. + + satisfied = false; + continue; + } + } + + // Normal param + const checkedArg = getTypeOrValueForNode(arg, mapper, { + kind: "argument", + constraint: param.type, + }); + + if (!checkedArg) { + satisfied = false; + continue; + } + + const resolved = collector.pipe( + checkEntityAssignableToConstraint(checkedArg, param.type, arg), + ); + + satisfied &&= !!resolved; + + resolvedArgs.push( + resolved + ? isValue(resolved) + ? marshalTypeForJs(resolved, undefined, function onUnknown() { + satisfied = false; + reportCheckerDiagnostic( + createDiagnostic({ + code: "unknown-value", + messageId: "in-js-argument", + target: arg, + }), + ); + }) + : resolved + : undefined, + ); + } + } + + reportCheckerDiagnostics(collector.diagnostics); + + return [satisfied, resolvedArgs]; + } + + function checkFunctionReturn( + target: FunctionType, + result: Type | Value | IndeterminateEntity, + diagnosticTarget: Node, + ): Type | Value | null { + const [checked, diagnostics] = checkEntityAssignableToConstraint( + result, + target.returnType, + diagnosticTarget, + ); + + if (diagnostics.length > 0) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "function-return", + messageId: "unassignable", + format: { + name: getTypeName(target, { printable: true }), + entityKind: result.entityKind.toLowerCase(), + return: getEntityName(result, { printable: true }), + type: getEntityName(target.returnType, { printable: true }), + }, + target: diagnosticTarget, + }), + ); + } + + return checked; + } + + function checkEntityAssignableToConstraint( + entity: Type | Value | IndeterminateEntity, + constraint: MixedParameterConstraint, + diagnosticTarget: Node, + ): DiagnosticResult { + const constraintIsValue = !!constraint.valueType; + const constraintIsType = !!constraint.type; + + const collector = createDiagnosticCollector(); + + switch (true) { + case constraintIsValue && constraintIsType: { + const tried = tryAssignValue(); + + if (tried[0] !== null || entity.entityKind === "Value") { + // Succeeded as value or is a value + return tried; + } + + // Now we are guaranteed a type. + const typeEntity = entity.entityKind === "Indeterminate" ? entity.type : entity; + + const assignable = collector.pipe( + relation.isTypeAssignableTo(typeEntity, constraint.type, diagnosticTarget), + ); + + return collector.wrap(assignable ? typeEntity : null); + } + case constraintIsValue: { + const normed = collector.pipe(normalizeValue(entity, constraint, diagnosticTarget)); + + // Error should have been reported in normalizeValue + if (!normed) return collector.wrap(null); + + const assignable = collector.pipe( + relation.isValueOfType(normed, constraint.valueType, diagnosticTarget), + ); + + return collector.wrap(assignable ? normed : null); + } + case constraintIsType: { + if (entity.entityKind === "Indeterminate") entity = entity.type; + + if (entity.entityKind !== "Type") { + collector.add( + createDiagnostic({ + code: "value-in-type", + format: { name: getTypeName(entity.type) }, + target: diagnosticTarget, + }), + ); + return collector.wrap(null); + } + + const assignable = collector.pipe( + relation.isTypeAssignableTo(entity, constraint.type, diagnosticTarget), + ); + + return collector.wrap(assignable ? entity : null); + } + default: { + compilerAssert(false, "Expected at least one of type or value constraint to be defined."); + } + } + + function tryAssignValue(): DiagnosticResult { + const collector = createDiagnosticCollector(); + + const normed = collector.pipe(normalizeValue(entity, constraint, diagnosticTarget)); + + const assignable = normed + ? collector.pipe(relation.isValueOfType(normed, constraint.valueType!, diagnosticTarget)) + : false; + + return collector.wrap(assignable ? normed : null); + } + } + + function normalizeValue( + entity: Type | Value | IndeterminateEntity, + constraint: MixedParameterConstraint, + diagnosticTarget: Node, + ): DiagnosticResult { + if (entity.entityKind === "Value") return [entity, []]; + + if (entity.entityKind === "Indeterminate") { + // Coerce to a value + const coerced = getValueFromIndeterminate( + entity.type, + constraint.type && { kind: "argument", type: constraint.type }, + entity.type.node!, + ); + + if (coerced?.entityKind !== "Value") { + return [ + null, + [ + createDiagnostic({ + code: "expect-value", + format: { name: getTypeName(entity.type) }, + target: diagnosticTarget, + }), + ], + ]; + } + + return [coerced, []]; + } + + if (entity.entityKind === "Type") { + return [ + null, + [ + createDiagnostic({ + code: "expect-value", + format: { name: getTypeName(entity) }, + target: diagnosticTarget, + }), + ], + ]; + } + + compilerAssert( + false, + `Unreachable: unexpected entity kind '${(entity satisfies never as Entity).entityKind}'`, + ); + } + function checkTypeOfExpression(node: TypeOfExpressionNode, mapper: TypeMapper | undefined): Type { const entity = checkNode(node.target, mapper, undefined); if (entity === null) { @@ -5038,16 +5525,21 @@ export function createChecker(program: Program, resolver: NameResolver): Checker !(isType(arg) && isErrorType(arg)) && checkArgumentAssignable(arg, perParamType, argNode) ) { + const [valid, jsValue] = resolveArgumentJsValue( + arg, + extractValueOfConstraints({ + kind: "argument", + constraint: perParamType, + }), + argNode, + ); + + if (!valid) return undefined; + return { value: arg, node: argNode, - jsValue: resolveDecoratorArgJsValue( - arg, - extractValueOfConstraints({ - kind: "argument", - constraint: perParamType, - }), - ), + jsValue, }; } else { return undefined; @@ -5120,18 +5612,30 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return type.kind === "Model" ? type.indexer?.value : undefined; } - function resolveDecoratorArgJsValue( + function resolveArgumentJsValue( value: Type | Value, valueConstraint: CheckValueConstraint | undefined, - ) { + diagnosticTarget: Node, + ): [valid: boolean, jsValue: any] { if (valueConstraint !== undefined) { if (isValue(value)) { - return marshallTypeForJS(value, valueConstraint.type); + let valid = true; + const unmarshaled = marshalTypeForJs(value, valueConstraint.type, function onUnknown() { + valid = false; + reportCheckerDiagnostic( + createDiagnostic({ + code: "unknown-value", + messageId: "in-js-argument", + target: diagnosticTarget, + }), + ); + }); + return [valid, unmarshaled]; } else { - return value; + return [true, value]; } } - return value; + return [true, value]; } function checkArgumentAssignable( @@ -5506,6 +6010,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker case "EnumValue": case "NullValue": case "ScalarValue": + case "UnknownValue": return value; } } @@ -6714,26 +7219,81 @@ function applyDecoratorToType(program: Program, decApp: DecoratorApplication, ta } } -function createDecoratorContext(program: Program, decApp: DecoratorApplication): DecoratorContext { - function createPassThruContext(program: Program, decApp: DecoratorApplication): DecoratorContext { - return { - program, - decoratorTarget: decApp.node!, - getArgumentTarget: () => decApp.node!, - call: (decorator, target, ...args) => { - return decorator(createPassThruContext(program, decApp), target, ...args); - }, - }; - } +function createPassThruContexts( + program: Program, + target: DiagnosticTarget, +): { + decorator: DecoratorContext; + function: FunctionContext; +} { + const decCtx: DecoratorContext = { + program, + decoratorTarget: target, + getArgumentTarget: () => target, + call: (decorator, target, ...args) => { + return decCtx.callDecorator(decorator, target, ...args); + }, + callDecorator(decorator, target, ...args) { + return decorator(decCtx, target, ...args); + }, + callFunction(fn, ...args) { + return fn(fnCtx, ...args); + }, + }; + + const fnCtx: FunctionContext = { + program, + functionCallTarget: target, + getArgumentTarget: () => target, + callFunction(fn, ...args) { + return fn(fnCtx, ...args); + }, + callDecorator(decorator, target, ...args) { + return decorator(decCtx, target, ...args); + }, + }; return { + decorator: decCtx, + function: fnCtx, + }; +} + +function createDecoratorContext(program: Program, decApp: DecoratorApplication): DecoratorContext { + const passthrough = createPassThruContexts(program, decApp.node!); + const decCtx: DecoratorContext = { program, decoratorTarget: decApp.node!, getArgumentTarget: (index: number) => { return decApp.args[index]?.node; }, call: (decorator, target, ...args) => { - return decorator(createPassThruContext(program, decApp), target, ...args); + return decCtx.callDecorator(decorator, target, ...args); + }, + callDecorator: (decorator, target, ...args) => { + return decorator(passthrough.decorator, target, ...args); + }, + callFunction(fn, ...args) { + return fn(passthrough.function, ...args); + }, + }; + + return decCtx; +} + +function createFunctionContext(program: Program, fnCall: CallExpressionNode): FunctionContext { + const passthrough = createPassThruContexts(program, fnCall); + return { + program, + functionCallTarget: fnCall, + getArgumentTarget: (index: number) => { + return fnCall.arguments[index]; + }, + callDecorator(decorator, target, ...args) { + return decorator(passthrough.decorator, target, ...args); + }, + callFunction(fn, ...args) { + return fn(passthrough.function, ...args); }, }; } diff --git a/packages/compiler/src/core/helpers/type-name-utils.ts b/packages/compiler/src/core/helpers/type-name-utils.ts index f1390fb1a9d..943db461e06 100644 --- a/packages/compiler/src/core/helpers/type-name-utils.ts +++ b/packages/compiler/src/core/helpers/type-name-utils.ts @@ -40,6 +40,8 @@ export function getTypeName(type: Type, options?: TypeNameOptions): string { return getInterfaceName(type, options); case "Operation": return getOperationName(type, options); + case "Function": + return getIdentifierName(type.name, options); case "Enum": return getEnumName(type, options); case "EnumMember": @@ -83,6 +85,8 @@ function getValuePreview(value: Value, options?: TypeNameOptions): string { return "null"; case "ScalarValue": return `${getTypeName(value.type, options)}.${value.value.name}(${value.value.args.map((x) => getValuePreview(x, options)).join(", ")}})`; + case "UnknownValue": + return "unknown"; } } diff --git a/packages/compiler/src/core/js-marshaller.ts b/packages/compiler/src/core/js-marshaller.ts index b6548b8630d..24b1f56ba98 100644 --- a/packages/compiler/src/core/js-marshaller.ts +++ b/packages/compiler/src/core/js-marshaller.ts @@ -1,19 +1,24 @@ +import { $ } from "../typekit/index.js"; import { compilerAssert } from "./diagnostics.js"; import { numericRanges } from "./numeric-ranges.js"; import { Numeric } from "./numeric.js"; +import { Program } from "./program.js"; import type { ArrayValue, MarshalledValue, NumericValue, ObjectValue, + ObjectValuePropertyDescriptor, Scalar, Type, + UnknownValue, Value, } from "./types.js"; -export function marshallTypeForJS( +export function marshalTypeForJs( value: T, valueConstraint: Type | undefined, + onUnknown: (value: UnknownValue) => void, ): MarshalledValue { switch (value.valueKind) { case "BooleanValue": @@ -22,15 +27,18 @@ export function marshallTypeForJS( case "NumericValue": return numericValueToJs(value, valueConstraint) as any; case "ObjectValue": - return objectValueToJs(value) as any; + return objectValueToJs(value, onUnknown) as any; case "ArrayValue": - return arrayValueToJs(value) as any; + return arrayValueToJs(value, onUnknown) as any; case "EnumValue": return value as any; case "NullValue": return null as any; case "ScalarValue": return value as any; + case "UnknownValue": + onUnknown(value); + return null as any; } } @@ -68,20 +76,116 @@ function numericValueToJs(type: NumericValue, valueConstraint: Type | undefined) const asNumber = type.value.asNumber(); compilerAssert( asNumber !== null, - `Numeric value '${type.value.toString()}' is not a able to convert to a number without loosing precision.`, + `Numeric value '${type.value.toString()}' is not a able to convert to a number without losing precision.`, ); return asNumber; } return type.value; } -function objectValueToJs(type: ObjectValue) { +function objectValueToJs( + type: ObjectValue, + onUnknown: (value: UnknownValue) => void, +): Record { const result: Record = {}; for (const [key, value] of type.properties) { - result[key] = marshallTypeForJS(value.value, undefined); + result[key] = marshalTypeForJs(value.value, undefined, onUnknown); } return result; } -function arrayValueToJs(type: ArrayValue) { - return type.values.map((x) => marshallTypeForJS(x, undefined)); +function arrayValueToJs(type: ArrayValue, onUnknown: (value: UnknownValue) => void) { + return type.values.map((x) => marshalTypeForJs(x, undefined, onUnknown)); +} + +export function unmarshalJsToValue( + program: Program, + value: unknown, + onInvalid: (value: unknown) => void, +): Value { + if ( + typeof value === "object" && + value !== null && + "entityKind" in value && + value.entityKind === "Value" + ) { + return value as Value; + } + + if (value === null || value === undefined) { + return { + entityKind: "Value", + valueKind: "NullValue", + value: null, + type: program.checker.nullType, + }; + } else if (typeof value === "boolean") { + const boolean = program.checker.getStdType("boolean"); + return { + entityKind: "Value", + valueKind: "BooleanValue", + value, + type: boolean, + scalar: boolean, + }; + } else if (typeof value === "string") { + const string = program.checker.getStdType("string"); + return { + entityKind: "Value", + valueKind: "StringValue", + value, + type: string, + scalar: string, + }; + } else if (typeof value === "number") { + const numeric = Numeric(String(value)); + const numericType = program.checker.getStdType("numeric"); + return { + entityKind: "Value", + valueKind: "NumericValue", + value: numeric, + type: $(program).literal.create(value), + scalar: numericType, + }; + } else if (Array.isArray(value)) { + const values: Value[] = []; + const uniqueTypes = new Set(); + + for (const item of value) { + const itemValue = unmarshalJsToValue(program, item, onInvalid); + values.push(itemValue); + uniqueTypes.add(itemValue.type); + } + + return { + entityKind: "Value", + valueKind: "ArrayValue", + type: $(program).array.create($(program).union.create([...uniqueTypes])), + values, + }; + } else if (typeof value === "object" && !("entityKind" in value)) { + const properties: Map = new Map(); + for (const [key, val] of Object.entries(value)) { + properties.set(key, { name: key, value: unmarshalJsToValue(program, val, onInvalid) }); + } + return { + entityKind: "Value", + valueKind: "ObjectValue", + properties, + type: $(program).model.create({ + properties: Object.fromEntries( + [...properties.entries()].map( + ([k, v]) => + [k, $(program).modelProperty.create({ name: k, type: v.value.type })] as const, + ), + ), + }), + }; + } else { + onInvalid(value); + return { + entityKind: "Value", + valueKind: "UnknownValue", + type: program.checker.neverType, + }; + } } diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index 5c42d275d59..1c3e0470a59 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -395,6 +395,7 @@ const diagnostics = { modelExpression: `Is a model expression type, but is being used as a value here. Use #{} to create an object value.`, tuple: `Is a tuple type, but is being used as a value here. Use #[] to create an array value.`, templateConstraint: paramMessage`${"name"} template parameter can be a type but is being used as a value here.`, + functionReturn: paramMessage`Function returned a type, but a value was expected.`, }, }, "non-callable": { @@ -539,10 +540,12 @@ const diagnostics = { default: "A function declaration must be prefixed with the 'extern' modifier.", }, }, - "function-unsupported": { + "function-return": { severity: "error", messages: { - default: "Function are currently not supported.", + default: "Function implementation returned an invalid result.", + "invalid-value": paramMessage`Function implementation returned invalid JS value '${"value"}'.`, + unassignable: paramMessage`Implementation of function '${"name"}' returned ${"entityKind"} '${"return"}', which is not assignable to the declared return type '${"type"}'.`, }, }, "missing-implementation": { @@ -1014,6 +1017,16 @@ const diagnostics = { }, }, + "unknown-value": { + severity: "error", + messages: { + default: "The 'unknown' value cannot be used here.", + "in-json": "The 'unknown' value cannot be serialized to JSON.", + "in-js-argument": + "The 'unknown' value cannot be used as an argument to a function or decorator.", + }, + }, + // #region Visibility "visibility-sealed": { severity: "error", diff --git a/packages/compiler/src/core/parser.ts b/packages/compiler/src/core/parser.ts index 62b1c3d7b23..7cdd5909a39 100644 --- a/packages/compiler/src/core/parser.ts +++ b/packages/compiler/src/core/parser.ts @@ -952,7 +952,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const id = parseIdentifier(); let constraint: Expression | ValueOfExpressionNode | undefined; if (parseOptional(Token.ExtendsKeyword)) { - constraint = parseMixedParameterConstraint(); + constraint = parseMixedConstraint(); } let def: Expression | undefined; if (parseOptional(Token.Equals)) { @@ -971,7 +971,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa if (token() === Token.ValueOfKeyword) { return parseValueOfExpression(); } else if (parseOptional(Token.OpenParen)) { - const expr = parseMixedParameterConstraint(); + const expr = parseMixedConstraint(); parseExpected(Token.CloseParen); return expr; } @@ -979,7 +979,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return parseIntersectionExpressionOrHigher(); } - function parseMixedParameterConstraint(): Expression | ValueOfExpressionNode { + function parseMixedConstraint(): Expression | ValueOfExpressionNode { const pos = tokenPos(); parseOptional(Token.Bar); const node: Expression = parseValueOfExpressionOrIntersectionOrHigher(); @@ -1227,7 +1227,9 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const id = parseIdentifier(); const { items: templateParameters, range: templateParametersRange } = parseTemplateParameterList(); + parseExpected(Token.Equals); + const value = parseExpression(); parseExpected(Token.Semicolon); return { @@ -2059,7 +2061,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const { items: parameters } = parseFunctionParameters(); let returnType; if (parseOptional(Token.Colon)) { - returnType = parseExpression(); + returnType = parseMixedConstraint(); } parseExpected(Token.Semicolon); return { @@ -2108,7 +2110,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa const optional = parseOptional(Token.Question); let type; if (parseOptional(Token.Colon)) { - type = parseMixedParameterConstraint(); + type = parseMixedConstraint(); } return { kind: SyntaxKind.FunctionParameter, @@ -3304,6 +3306,9 @@ export function getIdentifierContext(id: IdentifierNode): IdentifierContext { case SyntaxKind.DecoratorExpression: kind = IdentifierKind.Decorator; break; + case SyntaxKind.CallExpression: + kind = IdentifierKind.Function; + break; case SyntaxKind.UsingStatement: kind = IdentifierKind.Using; break; diff --git a/packages/compiler/src/core/semantic-walker.ts b/packages/compiler/src/core/semantic-walker.ts index 6342db3d26a..803631db738 100644 --- a/packages/compiler/src/core/semantic-walker.ts +++ b/packages/compiler/src/core/semantic-walker.ts @@ -3,6 +3,7 @@ import { isTemplateDeclaration } from "./type-utils.js"; import { Decorator, Enum, + FunctionType, Interface, ListenerFlow, Model, @@ -201,6 +202,10 @@ function navigateNamespaceType(namespace: Namespace, context: NavigationContext) navigateDecoratorDeclaration(decorator, context); } + for (const func of namespace.functionDeclarations.values()) { + navigateFunctionDeclaration(func, context); + } + context.emit("exitNamespace", namespace); } @@ -394,6 +399,13 @@ function navigateScalarConstructor(type: ScalarConstructor, context: NavigationC if (context.emit("scalarConstructor", type) === ListenerFlow.NoRecursion) return; } +function navigateFunctionDeclaration(type: FunctionType, context: NavigationContext) { + if (checkVisited(context.visited, type)) { + return; + } + if (context.emit("function", type) === ListenerFlow.NoRecursion) return; +} + function navigateTypeInternal(type: Type, context: NavigationContext) { switch (type.kind) { case "Model": @@ -426,6 +438,8 @@ function navigateTypeInternal(type: Type, context: NavigationContext) { return navigateDecoratorDeclaration(type, context); case "ScalarConstructor": return navigateScalarConstructor(type, context); + case "Function": + return navigateFunctionDeclaration(type, context); case "FunctionParameter": case "Boolean": case "EnumMember": diff --git a/packages/compiler/src/core/type-relation-checker.ts b/packages/compiler/src/core/type-relation-checker.ts index dc0e02948ae..385228c0c7a 100644 --- a/packages/compiler/src/core/type-relation-checker.ts +++ b/packages/compiler/src/core/type-relation-checker.ts @@ -98,9 +98,7 @@ const ReflectionNameToKind = { Tuple: "Tuple", Union: "Union", UnionVariant: "UnionVariant", -} as const; - -const _assertReflectionNameToKind: Record = ReflectionNameToKind; +} as const satisfies Record; type ReflectionTypeName = keyof typeof ReflectionNameToKind; @@ -510,7 +508,7 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T function isSimpleTypeAssignableTo(source: Type, target: Type): boolean | undefined { if (isNeverType(source)) return true; - if (isVoidType(target)) return false; + if (isVoidType(target)) return isVoidType(source); if (isUnknownType(target)) return true; if (isReflectionType(target)) { return source.kind === ReflectionNameToKind[target.name]; @@ -706,10 +704,11 @@ export function createTypeRelationChecker(program: Program, checker: Checker): T } } - return [ - errors.length === 0 ? Related.true : Related.false, - wrapUnassignableErrors(source, target, errors), - ]; + if (errors.length === 0) { + return [Related.true, []]; + } else { + return [Related.false, wrapUnassignableErrors(source, target, errors)]; + } } /** If we should check for excess properties on the given model. */ diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 7dd56a806a2..f54450ffb23 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -15,6 +15,7 @@ Value extends StringValue ? string : Value extends EnumValue ? EnumMember : Value extends NullValue ? null : Value extends ScalarValue ? Value + : Value extends UnknownValue ? null : Value /** @@ -49,10 +50,14 @@ export interface DecoratorApplication { } export interface DecoratorFunction { - (program: DecoratorContext, target: any, ...customArgs: any[]): void; + (context: DecoratorContext, target: any, ...args: any[]): void; namespace?: string; } +export interface FunctionImplementation { + (context: FunctionContext, ...args: any[]): Type | Value; +} + export interface BaseType { readonly entityKind: "Type"; kind: string; @@ -131,7 +136,8 @@ export type Type = | TemplateParameter | Tuple | Union - | UnionVariant; + | UnionVariant + | FunctionType; export type StdTypes = { // Models @@ -166,7 +172,8 @@ export interface IndeterminateEntity { | BooleanLiteral | EnumMember | UnionVariant - | NullType; + | NullType + | UnknownType; } export interface IntrinsicType extends BaseType { @@ -323,7 +330,8 @@ export type Value = | ObjectValue | ArrayValue | EnumValue - | NullValue; + | NullValue + | UnknownValue; /** @internal */ export type ValueWithTemplate = Value | TemplateValue; @@ -390,6 +398,9 @@ export interface NullValue extends BaseValue { valueKind: "NullValue"; value: null; } +export interface UnknownValue extends BaseValue { + valueKind: "UnknownValue"; +} /** * This is an internal type that represent a value while in a template declaration. @@ -579,6 +590,13 @@ export interface Namespace extends BaseType, DecoratedType { * Order is implementation-defined and may change. */ decoratorDeclarations: Map; + + /** + * The functions declared in the namespace. + * + * Order is implementation-defined and may change. + */ + functionDeclarations: Map; } export type LiteralType = StringLiteral | NumericLiteral | BooleanLiteral; @@ -687,27 +705,81 @@ export interface Decorator extends BaseType { namespace: Namespace; target: MixedFunctionParameter; parameters: MixedFunctionParameter[]; - implementation: (...args: unknown[]) => void; + implementation: (ctx: DecoratorContext, target: Type, ...args: unknown[]) => void; +} + +/** + * A function (`fn`) declared in the TypeSpec program. + */ +export interface FunctionType extends BaseType { + kind: "Function"; + node?: FunctionDeclarationStatementNode; + /** + * The function's name as declared in the TypeSpec source. + */ + name: string; + /** + * The namespace in which this function was declared. + */ + namespace: Namespace; + /** + * The parameters of the function. + */ + parameters: MixedFunctionParameter[]; + /** + * The return type constraint of the function. + */ + returnType: MixedParameterConstraint; + /** + * The JavaScript implementation of the function. + * + * WARNING: Calling the implementation function directly is dangerous. It assumes that you have marshaled the arguments + * to JS values correctly and that you will handle the return value appropriately. Constructing the correct context + * is your responsibility (use the `call + * + * @param ctx - The FunctionContext providing information about the call site. + * @param args - The arguments passed to the function. + * @returns The return value of the function, which is arbitrary. + */ + implementation: (ctx: FunctionContext, ...args: unknown[]) => unknown; } export interface FunctionParameterBase extends BaseType { kind: "FunctionParameter"; node?: FunctionParameterNode; + /** + * The name of this function parameter, as declared in the TypeSpec source. + */ name: string; + /** + * Whether this parameter is optional. + */ optional: boolean; + /** + * Whether this parameter is a rest parameter (i.e., `...args`). + */ rest: boolean; } -/** Represent a function parameter that could accept types or values in the TypeSpec program. */ +/** + * A function parameter with a mixed parameter constraint that could accept a value. + */ export interface MixedFunctionParameter extends FunctionParameterBase { mixed: true; type: MixedParameterConstraint; } -/** Represent a function parameter that represent the parameter signature(i.e the type would be the type of the value passed) */ + +/** + * A function parameter with a simple type constraint. + */ export interface SignatureFunctionParameter extends FunctionParameterBase { mixed: false; type: Type; } + +/** + * A function parameter. + */ export type FunctionParameter = MixedFunctionParameter | SignatureFunctionParameter; export interface Sym { @@ -2312,6 +2384,12 @@ export interface DecoratorImplementations { }; } +export interface FunctionImplementations { + readonly [namespace: string]: { + readonly [name: string]: FunctionImplementation; + }; +} + export interface PackageFlags {} export interface LinterDefinition { @@ -2462,24 +2540,104 @@ export interface DecoratorContext { decoratorTarget: DiagnosticTarget; /** - * Function that can be used to retrieve the target for a parameter at the given index. - * @param paramIndex Parameter index in the typespec - * @example @foo("bar", 123) -> $foo(context, target, arg0: string, arg1: number); - * getArgumentTarget(0) -> target for arg0 - * getArgumentTarget(1) -> target for arg1 + * Helper to get the target for a given argument index. + * @param argIndex Argument index in the decorator call. + * @example + * ```tsp + * @dec("hello", 123) + * model MyModel { } + * ``` + * - `getArgumentTarget(0)` -> target for "hello" + * - `getArgumentTarget(1)` -> target for 123 */ - getArgumentTarget(paramIndex: number): DiagnosticTarget | undefined; + getArgumentTarget(argIndex: number): DiagnosticTarget | undefined; /** - * Helper to call out to another decorator - * @param decorator Other decorator function - * @param args Args to pass to other decorator function + * Helper to call a decorator implementation from within another decorator implementation. + * + * This function is identical to `callDecorator`. + * + * @param decorator The decorator function to call. + * @param target The target to which the decorator is applied. + * @param args Arguments to pass to the decorator. */ call( decorator: (context: DecoratorContext, target: T, ...args: A) => R, target: T, ...args: A ): R; + + /** + * Helper to call a decorator implementation from within another decorator implementation. + * + * @param decorator The decorator function to call. + * @param target The target to which the decorator is applied. + * @param args Arguments to pass to the decorator. + */ + callDecorator( + decorator: (context: DecoratorContext, target: T, ...args: A) => R, + target: T, + ...args: A + ): R; + + /** + * Helper to call a function implementation from within a decorator implementation. + * @param func The function implementation to call. + * @param args Arguments to pass to the function. + */ + callFunction( + func: (context: FunctionContext, ...args: A) => R, + ...args: A + ): R; +} + +/** + * Context passed to function implementations. + */ +export interface FunctionContext { + /** + * The TypeSpec Program in which the function is evaluated. + */ + program: Program; + + /** + * The function call diagnostic target. + */ + functionCallTarget: DiagnosticTarget; + + /** + * Helper to get the target for a given argument index. + * @param argIndex Argument index in the function call. + * @example + * ```tsp + * foo("bar", 123): + * ``` + * - `getArgumentTarget(0)` -> target for "bar" + * - `getArgumentTarget(1)` -> target for 123 + */ + getArgumentTarget(argIndex: number): DiagnosticTarget | undefined; + + /** + * Helper to call a decorator implementation from within a function implementation. + * @param decorator The decorator function to call. + * @param target The target to which the decorator is applied. + * @param args Arguments to pass to the decorator. + */ + callDecorator( + decorator: (context: DecoratorContext, target: T, ...args: A) => R, + target: T, + ...args: A + ): R; + + /** + * Helper to call a function implementation from within another function implementation. + * @param func The function implementation to call. + * @param args Arguments to pass to the function. + */ + callFunction( + func: (context: FunctionContext, ...args: A) => R, + ...args: A + ): R; } export interface EmitContext> { diff --git a/packages/compiler/src/experimental/typekit/index.ts b/packages/compiler/src/experimental/typekit/index.ts index 32408209095..6fd78063a05 100644 --- a/packages/compiler/src/experimental/typekit/index.ts +++ b/packages/compiler/src/experimental/typekit/index.ts @@ -1,4 +1,4 @@ -import { type Typekit, TypekitPrototype } from "../../typekit/define-kit.js"; +import { TypekitPrototype, type Typekit } from "../../typekit/define-kit.js"; import { Realm } from "../realm.js"; /** diff --git a/packages/compiler/src/lib/examples.ts b/packages/compiler/src/lib/examples.ts index 87e77dedd9d..d9f70016459 100644 --- a/packages/compiler/src/lib/examples.ts +++ b/packages/compiler/src/lib/examples.ts @@ -1,9 +1,12 @@ import { Temporal } from "temporal-polyfill"; import { ignoreDiagnostics } from "../core/diagnostics.js"; +import { reportDiagnostic } from "../core/messages.js"; import type { Program } from "../core/program.js"; import { getProperty } from "../core/semantic-walker.js"; import { isArrayModelType, isUnknownType } from "../core/type-utils.js"; import { + DiagnosticTarget, + NoTarget, type ObjectValue, type Scalar, type ScalarValue, @@ -21,9 +24,16 @@ export function serializeValueAsJson( value: Value, type: Type, encodeAs?: EncodeData, + diagnosticTarget?: DiagnosticTarget | typeof NoTarget, ): unknown { if (type.kind === "ModelProperty") { - return serializeValueAsJson(program, value, type.type, encodeAs ?? getEncode(program, type)); + return serializeValueAsJson( + program, + value, + type.type, + encodeAs ?? getEncode(program, type), + diagnosticTarget, + ); } switch (value.valueKind) { case "NullValue": @@ -43,12 +53,21 @@ export function serializeValueAsJson( type.kind === "Model" && isArrayModelType(program, type) ? type.indexer.value : program.checker.anyType, + /* encodeAs: */ undefined, + diagnosticTarget, ), ); case "ObjectValue": - return serializeObjectValueAsJson(program, value, type); + return serializeObjectValueAsJson(program, value, type, diagnosticTarget); case "ScalarValue": return serializeScalarValueAsJson(program, value, type, encodeAs); + case "UnknownValue": + reportDiagnostic(program, { + code: "unknown-value", + messageId: "in-json", + target: diagnosticTarget ?? value, + }); + return null; } } @@ -96,6 +115,7 @@ function serializeObjectValueAsJson( program: Program, value: ObjectValue, type: Type, + diagnosticTarget?: DiagnosticTarget | typeof NoTarget, ): Record { type = resolveUnions(program, value, type) ?? type; const obj: Record = {}; @@ -106,7 +126,13 @@ function serializeObjectValueAsJson( definition.kind === "ModelProperty" ? resolveEncodedName(program, definition, "application/json") : propValue.name; - obj[name] = serializeValueAsJson(program, propValue.value, definition); + obj[name] = serializeValueAsJson( + program, + propValue.value, + definition, + /* encodeAs: */ undefined, + propValue.node, + ); } } return obj; diff --git a/packages/compiler/src/server/type-signature.ts b/packages/compiler/src/server/type-signature.ts index e2cf702dcba..13fd98478e8 100644 --- a/packages/compiler/src/server/type-signature.ts +++ b/packages/compiler/src/server/type-signature.ts @@ -11,6 +11,7 @@ import { Decorator, EnumMember, FunctionParameter, + FunctionType, Interface, Model, ModelProperty, @@ -105,6 +106,8 @@ function getTypeSignature(type: Type, options: GetSymbolSignatureOptions): strin return `(union variant)\n${fence(getUnionVariantSignature(type))}`; case "Tuple": return `(tuple)\n[${fence(type.values.map((v) => getTypeSignature(v, options)).join(", "))}]`; + case "Function": + return fence(getFunctionSignature(type)); default: const _assertNever: never = type; compilerAssert(false, "Unexpected type kind"); @@ -118,6 +121,13 @@ function getDecoratorSignature(type: Decorator) { return `dec ${ns}${name}(${parameters.join(", ")})`; } +function getFunctionSignature(type: FunctionType) { + const ns = getQualifier(type.namespace); + const parameters = type.parameters.map((p) => getFunctionParameterSignature(p)); + const returnType = getEntityName(type.returnType); + return `fn ${ns}${type.name}(${parameters.join(", ")}): ${returnType}`; +} + function getOperationSignature(type: Operation, includeQualifier: boolean = true) { const parameters = [...type.parameters.properties.values()].map((p) => getModelPropertySignature(p, false /* includeQualifier */), diff --git a/packages/compiler/test/binder.test.ts b/packages/compiler/test/binder.test.ts index 488799be5da..3bfa055581d 100644 --- a/packages/compiler/test/binder.test.ts +++ b/packages/compiler/test/binder.test.ts @@ -421,20 +421,12 @@ describe("compiler: binder", () => { flags: SymbolFlags.Decorator | SymbolFlags.Declaration | SymbolFlags.Implementation, declarations: [SyntaxKind.JsSourceFile], }, - fn2: { - flags: SymbolFlags.Function | SymbolFlags.Declaration | SymbolFlags.Implementation, - declarations: [SyntaxKind.JsSourceFile], - }, }, }, "@myDec": { flags: SymbolFlags.Decorator | SymbolFlags.Declaration | SymbolFlags.Implementation, declarations: [SyntaxKind.JsSourceFile], }, - fn: { - flags: SymbolFlags.Function | SymbolFlags.Declaration | SymbolFlags.Implementation, - declarations: [SyntaxKind.JsSourceFile], - }, }, }, }); @@ -473,6 +465,39 @@ describe("compiler: binder", () => { }); }); + it("binds $functions in JS file", () => { + const exports = { + $functions: { + "Foo.Bar": { myFn2: () => {} }, + "": { myFn: () => {} }, + }, + }; + + const sourceFile = bindJs(exports); + assertBindings("jsFile", sourceFile.symbol.exports!, { + Foo: { + flags: SymbolFlags.Namespace | SymbolFlags.Declaration, + declarations: [SyntaxKind.JsNamespaceDeclaration], + exports: { + Bar: { + flags: SymbolFlags.Namespace | SymbolFlags.Declaration, + declarations: [SyntaxKind.JsNamespaceDeclaration], + exports: { + myFn2: { + flags: SymbolFlags.Function | SymbolFlags.Declaration | SymbolFlags.Implementation, + declarations: [SyntaxKind.JsSourceFile], + }, + }, + }, + }, + }, + myFn: { + flags: SymbolFlags.Function | SymbolFlags.Declaration | SymbolFlags.Implementation, + declarations: [SyntaxKind.JsSourceFile], + }, + }); + }); + function bindTypeSpec(code: string) { const sourceFile = parse(code); expectDiagnosticEmpty(sourceFile.parseDiagnostics); diff --git a/packages/compiler/test/checker/functions.test.ts b/packages/compiler/test/checker/functions.test.ts new file mode 100644 index 00000000000..c08c1a940af --- /dev/null +++ b/packages/compiler/test/checker/functions.test.ts @@ -0,0 +1,1172 @@ +import { deepStrictEqual, fail, ok, strictEqual } from "assert"; +import { beforeEach, describe, it } from "vitest"; +import { + Diagnostic, + FunctionContext, + IndeterminateEntity, + Model, + ModelProperty, + Namespace, + Type, +} from "../../src/core/types.js"; +import { + BasicTestRunner, + TestHost, + createTestHost, + createTestWrapper, + expectDiagnosticEmpty, + expectDiagnostics, +} from "../../src/testing/index.js"; +import { $ } from "../../src/typekit/index.js"; + +/** Helper to assert a function declaration was bound to the js implementation */ +function expectFunction(ns: Namespace, name: string, impl: any) { + const fn = ns.functionDeclarations.get(name); + ok(fn, `Expected function ${name} to be declared.`); + strictEqual(fn.implementation, impl); +} + +describe("compiler: checker: functions", () => { + let testHost: TestHost; + + beforeEach(async () => { + testHost = await createTestHost(); + }); + + describe("declaration", () => { + let runner: BasicTestRunner; + let testImpl: any; + let nsFnImpl: any; + beforeEach(() => { + testImpl = (_ctx: FunctionContext) => undefined; + nsFnImpl = (_ctx: FunctionContext) => undefined; + testHost.addJsFile("test.js", { + $functions: { + "": { + testFn: testImpl, + }, + "Foo.Bar": { + nsFn: nsFnImpl, + }, + }, + }); + runner = createTestWrapper(testHost, { + autoImports: ["./test.js"], + autoUsings: ["TypeSpec.Reflection"], + }); + }); + + describe("bind implementation to declaration", () => { + it("defined at root via direct export", async () => { + await runner.compile(` + extern fn testFn(); + `); + expectFunction(runner.program.getGlobalNamespaceType(), "testFn", testImpl); + }); + + it("in namespace via $functions map", async () => { + await runner.compile(`namespace Foo.Bar { extern fn nsFn(); }`); + const ns = runner.program + .getGlobalNamespaceType() + .namespaces.get("Foo") + ?.namespaces.get("Bar"); + ok(ns); + expectFunction(ns, "nsFn", nsFnImpl); + }); + }); + + it("errors if function is missing extern modifier", async () => { + const diagnostics = await runner.diagnose(`fn testFn();`); + expectDiagnostics(diagnostics, { + code: "function-extern", + message: "A function declaration must be prefixed with the 'extern' modifier.", + }); + }); + + it("errors if extern function is missing implementation", async () => { + const diagnostics = await runner.diagnose(`extern fn missing();`); + expectDiagnostics(diagnostics, { + code: "missing-implementation", + message: "Extern declaration must have an implementation in JS file.", + }); + }); + + it("errors if rest parameter type is not array", async () => { + const diagnostics = await runner.diagnose(`extern fn f(...rest: string);`); + expectDiagnostics(diagnostics, [ + { + code: "missing-implementation", + message: "Extern declaration must have an implementation in JS file.", + }, + { + code: "rest-parameter-array", + message: "A rest parameter must be of an array type.", + }, + ]); + }); + }); + + describe("usage", () => { + let runner: BasicTestRunner; + let calledArgs: any[] | undefined; + beforeEach(() => { + calledArgs = undefined; + testHost.addJsFile("test.js", { + $functions: { + "": { + testFn(ctx: FunctionContext, a: any, b: any, ...rest: any[]) { + calledArgs = [ctx, a, b, ...rest]; + return a; // Return first arg + }, + sum(_ctx: FunctionContext, ...addends: number[]) { + return addends.reduce((a, b) => a + b, 0); + }, + valFirst(_ctx: FunctionContext, v: any) { + return v; + }, + voidFn(ctx: FunctionContext, arg: any) { + calledArgs = [ctx, arg]; + // No return value + }, + }, + }, + }); + runner = createTestWrapper(testHost, { + autoImports: ["./test.js"], + autoUsings: ["TypeSpec.Reflection"], + }); + }); + + function expectCalledWith(...args: any[]) { + ok(calledArgs, "Function was not called."); + strictEqual(calledArgs.length, 1 + args.length); + for (const [i, v] of args.entries()) { + strictEqual(calledArgs[1 + i], v); + } + } + + it("errors if function not declared", async () => { + const diagnostics = await runner.diagnose(`const X = missing();`); + + expectDiagnostics(diagnostics, { + code: "invalid-ref", + message: "Unknown identifier missing", + }); + }); + + it("calls function with arguments", async () => { + await runner.compile(` + extern fn testFn(a: valueof string, b: valueof string, ...rest: valueof string[]): valueof string; + + const X = testFn("one", "two", "three"); + `); + + expectCalledWith("one", "two", "three"); // program + args, optional b provided + }); + + it("allows omitting optional param", async () => { + await runner.compile( + `extern fn testFn(a: valueof string, b?: valueof string): valueof string; const X = testFn("one");`, + ); + + expectCalledWith("one", undefined); + }); + + it("allows zero args for rest-only", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn sum(...addends: valueof int32[]): valueof int32; + const S = sum(); + + model Observer { + @test + p: int32 = S; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnostics(diagnostics, []); + + strictEqual(p.defaultValue?.entityKind, "Value"); + strictEqual(p.defaultValue.valueKind, "NumericValue"); + strictEqual(p.defaultValue.value.asNumber(), 0); + }); + + it("accepts function with explicit void return type", async () => { + const diagnostics = await runner.diagnose(` + extern fn voidFn(a: valueof string): void; + alias V = voidFn("test"); + `); + + expectDiagnostics(diagnostics, []); + expectCalledWith("test"); + }); + + it("errors if non-void function returns undefined", async () => { + const diagnostics = await runner.diagnose(` + extern fn voidFn(a: valueof string): unknown; + alias V = voidFn("test"); + `); + + expectDiagnostics(diagnostics, { + code: "function-return", + message: + "Implementation of function 'voidFn' returned value 'null', which is not assignable to the declared return type 'unknown'.", + }); + expectCalledWith("test"); + }); + + it("errors if not enough args", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn testFn(a: valueof string, b: valueof string): valueof string; + const X = testFn("one"); + + model Observer { + @test p: string = X; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnostics(diagnostics, { + code: "invalid-argument-count", + message: "Expected at least 2 arguments, but got 1.", + }); + + strictEqual(p.defaultValue?.entityKind, "Value"); + strictEqual(p.defaultValue.valueKind, "UnknownValue"); + }); + + it("errors if too many args", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn testFn(a: valueof string): valueof string; + const X = testFn("one", "two"); + + model Observer { + @test p: string = X; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnostics(diagnostics, { + code: "invalid-argument-count", + message: "Expected 1 arguments, but got 2.", + }); + + expectCalledWith("one", undefined); + + strictEqual(p.defaultValue?.entityKind, "Value"); + strictEqual(p.defaultValue.valueKind, "StringValue"); + strictEqual(p.defaultValue.value, "one"); + }); + + it("errors if too few with rest", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn testFn(a: string, ...rest: string[]); + + alias X = testFn(); + + model Observer { + @test p: X; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnostics(diagnostics, { + code: "invalid-argument-count", + message: "Expected at least 1 arguments, but got 0.", + }); + + strictEqual(p.type.kind, "Intrinsic"); + strictEqual(p.type.name, "unknown"); + }); + + it("errors if argument type mismatch (value)", async () => { + const diagnostics = await runner.diagnose(` + extern fn valFirst(a: valueof string): valueof string; + const X = valFirst(123); + `); + + expectDiagnostics(diagnostics, { + code: "unassignable", + message: "Type '123' is not assignable to type 'string'", + }); + }); + + it("errors if passing type where value expected", async () => { + const diagnostics = await runner.diagnose(` + extern fn valFirst(a: valueof string): valueof string; + const X = valFirst(string); + `); + + expectDiagnostics(diagnostics, { + code: "expect-value", + message: "string refers to a type, but is being used as a value here.", + }); + }); + + it("accepts string literal for type param", async () => { + const diagnostics = await runner.diagnose(` + extern fn testFn(a: string); + alias X = testFn("abc"); + `); + + expectDiagnosticEmpty(diagnostics); + + strictEqual(calledArgs?.[1].entityKind, "Type"); + strictEqual(calledArgs?.[1].kind, "String"); + strictEqual(calledArgs?.[1].value, "abc"); + }); + + it("accepts arguments matching rest", async () => { + const diagnostics = await runner.diagnose(` + extern fn testFn(a: string, ...rest: string[]); + alias X = testFn("a", "b", "c"); + `); + + expectDiagnosticEmpty(diagnostics); + + const expectedLiterals = ["a", "b", "c"]; + + for (let i = 1; i < calledArgs!.length; i++) { + strictEqual(calledArgs?.[i].entityKind, "Type"); + strictEqual(calledArgs?.[i].kind, "String"); + strictEqual(calledArgs?.[i].value, expectedLiterals[i - 1]); + } + }); + }); + + describe("typekit construction", () => { + it("can construct array with typekit in impl", async () => { + testHost.addJsFile("test.js", { + $functions: { + "": { + makeArray(ctx: FunctionContext, t: Type) { + return $(ctx.program).array.create(t); + }, + }, + }, + }); + const runner = createTestWrapper(testHost, { + autoImports: ["./test.js"], + autoUsings: ["TypeSpec.Reflection"], + }); + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn makeArray(T: unknown); + + alias X = makeArray(string); + + model M { + @test p: X; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnosticEmpty(diagnostics); + + ok(p.type); + ok($(runner.program).array.is(p.type)); + + const arrayIndexerType = p.type.indexer.value; + + ok(arrayIndexerType); + ok($(runner.program).scalar.isString(arrayIndexerType)); + }); + }); + + describe("specific type constraints", () => { + let runner: BasicTestRunner; + let receivedTypes: Type[] = []; + + beforeEach(() => { + receivedTypes = []; + testHost.addJsFile("test.js", { + $functions: { + "": { + expectModel(_ctx: FunctionContext, model: Type) { + receivedTypes.push(model); + return model; + }, + expectEnum(_ctx: FunctionContext, enumType: Type) { + receivedTypes.push(enumType); + return enumType; + }, + expectScalar(_ctx: FunctionContext, scalar: Type) { + receivedTypes.push(scalar); + return scalar; + }, + expectUnion(_ctx: FunctionContext, union: Type) { + receivedTypes.push(union); + return union; + }, + expectInterface(_ctx: FunctionContext, iface: Type) { + receivedTypes.push(iface); + return iface; + }, + expectNamespace(_ctx: FunctionContext, ns: Type) { + receivedTypes.push(ns); + return ns; + }, + expectOperation(_ctx: FunctionContext, op: Type) { + receivedTypes.push(op); + return op; + }, + }, + }, + }); + runner = createTestWrapper(testHost, { + autoImports: ["./test.js"], + autoUsings: ["TypeSpec.Reflection"], + }); + }); + + it("accepts Reflection.Model parameter", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectModel(m: Reflection.Model): Reflection.Model; + model TestModel { x: string; } + alias X = expectModel(TestModel); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedTypes.length, 1); + strictEqual(receivedTypes[0].kind, "Model"); + strictEqual(receivedTypes[0].name, "TestModel"); + }); + + it("accepts Reflection.Enum parameter", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectEnum(e: Reflection.Enum): Reflection.Enum; + enum TestEnum { A, B } + alias X = expectEnum(TestEnum); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedTypes.length, 1); + strictEqual(receivedTypes[0].kind, "Enum"); + strictEqual(receivedTypes[0].name, "TestEnum"); + }); + + it("accepts Reflection.Scalar parameter", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectScalar(s: Reflection.Scalar): Reflection.Scalar; + scalar TestScalar extends string; + alias X = expectScalar(TestScalar); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedTypes.length, 1); + strictEqual(receivedTypes[0].kind, "Scalar"); + strictEqual(receivedTypes[0].name, "TestScalar"); + }); + + it("accepts Reflection.Union parameter", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectUnion(u: Reflection.Union): Reflection.Union; + alias X = expectUnion(string | int32); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedTypes.length, 1); + strictEqual(receivedTypes[0].kind, "Union"); + strictEqual(receivedTypes[0].name, undefined); + }); + + it("accepts Reflection.Interface parameter", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectInterface(i: Reflection.Interface): Reflection.Interface; + interface TestInterface { + testOp(): void; + } + alias X = expectInterface(TestInterface); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedTypes.length, 1); + strictEqual(receivedTypes[0].kind, "Interface"); + strictEqual(receivedTypes[0].name, "TestInterface"); + }); + + it("accepts Reflection.Namespace parameter", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectNamespace(ns: Reflection.Namespace): Reflection.Namespace; + namespace TestNs {} + alias X = expectNamespace(TestNs); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedTypes.length, 1); + strictEqual(receivedTypes[0].kind, "Namespace"); + strictEqual(receivedTypes[0].name, "TestNs"); + }); + + it("accepts Reflection.Operation parameter", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectOperation(operation: Reflection.Operation): Reflection.Operation; + op testOp(): string; + alias X = expectOperation(testOp); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedTypes.length, 1); + strictEqual(receivedTypes[0].kind, "Operation"); + strictEqual(receivedTypes[0].name, "testOp"); + }); + + it("errors when wrong type kind is passed", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectModel(m: Reflection.Model): Reflection.Model; + enum TestEnum { A, B } + alias X = expectModel(TestEnum); + `); + + expectDiagnostics(diagnostics, { + code: "unassignable", + message: "Type 'TestEnum' is not assignable to type 'Model'", + }); + }); + }); + + describe("value marshalling", () => { + let runner: BasicTestRunner; + let receivedValues: any[] = []; + + beforeEach(() => { + receivedValues = []; + testHost.addJsFile("test.js", { + $functions: { + "": { + expectString(ctx: FunctionContext, str: string) { + receivedValues.push(str); + return str; + }, + expectNumber(ctx: FunctionContext, num: number) { + receivedValues.push(num); + return num; + }, + expectBoolean(ctx: FunctionContext, bool: boolean) { + receivedValues.push(bool); + return bool; + }, + expectArray(ctx: FunctionContext, arr: any[]) { + receivedValues.push(arr); + return arr; + }, + expectObject(ctx: FunctionContext, obj: Record) { + receivedValues.push(obj); + return obj; + }, + returnInvalidJsValue(ctx: FunctionContext) { + return Symbol("invalid"); + }, + returnComplexObject(ctx: FunctionContext) { + return { + nested: { value: 42 }, + array: [1, "test", true], + null: null, + }; + }, + returnIndeterminate(ctx: FunctionContext): IndeterminateEntity { + return { entityKind: "Indeterminate", type: $(ctx.program).literal.create(42) }; + }, + }, + }, + }); + runner = createTestWrapper(testHost, { + autoImports: ["./test.js"], + autoUsings: ["TypeSpec.Reflection"], + }); + }); + + it("marshals string values correctly", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectString(s: valueof string): valueof string; + const X = expectString("hello world"); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedValues.length, 1); + strictEqual(receivedValues[0], "hello world"); + }); + + it("marshals numeric values correctly", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectNumber(n: valueof int32): valueof int32; + const X = expectNumber(42); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedValues.length, 1); + strictEqual(receivedValues[0], 42); + }); + + it("marshals boolean values correctly", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectBoolean(b: valueof boolean): valueof boolean; + const X = expectBoolean(true); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedValues.length, 1); + strictEqual(receivedValues[0], true); + }); + + it("marshals array values correctly", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectArray(arr: valueof string[]): valueof string[]; + const X = expectArray(#["a", "b", "c"]); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedValues.length, 1); + ok(Array.isArray(receivedValues[0])); + strictEqual(receivedValues[0].length, 3); + strictEqual(receivedValues[0][0], "a"); + strictEqual(receivedValues[0][1], "b"); + strictEqual(receivedValues[0][2], "c"); + }); + + it("marshals object values correctly", async () => { + // BUG: This test reveals a type system issue where numeric literal 25 is not + // assignable to int32 in object literal context within extern functions. + // The error: Type '{ name: string, age: 25 }' is not assignable to type '{ name: string, age: int32 }' + // Expected: Numeric literal 25 should be assignable to int32 + const diagnostics = await runner.diagnose(` + extern fn expectObject(obj: valueof {name: string, age: int32}): valueof {name: string, age: int32}; + const X = expectObject(#{name: "test", age: 25}); + `); + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedValues.length, 1); + strictEqual(typeof receivedValues[0], "object"); + strictEqual(receivedValues[0].name, "test"); + strictEqual(receivedValues[0].age, 25); + }); + + it("handles invalid JS return values gracefully", async () => { + const diagnostics = await runner.diagnose(` + extern fn returnInvalidJsValue(): valueof string; + const X = returnInvalidJsValue(); + `); + + expectDiagnostics(diagnostics, { + code: "function-return", + message: "Function implementation returned invalid JS value 'Symbol(invalid)'.", + }); + }); + + it("unmarshal complex JS objects to values", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn returnComplexObject(): valueof unknown; + const X = returnComplexObject(); + + model Observer { + @test p: unknown = X; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnosticEmpty(diagnostics); + strictEqual(receivedValues.length, 0); + + strictEqual(p.defaultValue?.entityKind, "Value"); + strictEqual(p.defaultValue?.valueKind, "ObjectValue"); + + const obj = p.defaultValue!.properties; + strictEqual(obj.size, 3); + + const nested = obj.get("nested")?.value; + ok(nested); + strictEqual(nested.entityKind, "Value"); + strictEqual(nested.valueKind, "ObjectValue"); + + const nestedProps = nested.properties; + strictEqual(nestedProps.size, 1); + const nestedValue = nestedProps.get("value")?.value; + ok(nestedValue); + strictEqual(nestedValue.entityKind, "Value"); + strictEqual(nestedValue.valueKind, "NumericValue"); + strictEqual(nestedValue.value.asNumber(), 42); + + const array = obj.get("array")?.value; + ok(array); + strictEqual(array.entityKind, "Value"); + strictEqual(array.valueKind, "ArrayValue"); + + const arrayItems = array.values; + strictEqual(arrayItems.length, 3); + + strictEqual(arrayItems[0].entityKind, "Value"); + strictEqual(arrayItems[0].valueKind, "NumericValue"); + strictEqual(arrayItems[0].value.asNumber(), 1); + + strictEqual(arrayItems[1].entityKind, "Value"); + strictEqual(arrayItems[1].valueKind, "StringValue"); + strictEqual(arrayItems[1].value, "test"); + + strictEqual(arrayItems[2].entityKind, "Value"); + strictEqual(arrayItems[2].valueKind, "BooleanValue"); + strictEqual(arrayItems[2].value, true); + + const nullP = obj.get("null")?.value; + ok(nullP); + strictEqual(nullP.entityKind, "Value"); + strictEqual(nullP.valueKind, "NullValue"); + }); + + it("handles indeterminate entities coerced to values", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn returnIndeterminate(): valueof int32; + extern fn expectNumber(n: valueof int32): valueof int32; + const X = expectNumber(returnIndeterminate()); + + model Observer { + @test p: int32 = X; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnosticEmpty(diagnostics); + + strictEqual(p.defaultValue?.entityKind, "Value"); + strictEqual(p.defaultValue?.valueKind, "NumericValue"); + strictEqual(p.defaultValue?.value.asNumber(), 42); + }); + + it("handles indeterminate entities coerced to types", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn returnIndeterminate(): int32; + + alias X = returnIndeterminate(); + + model Observer { + @test p: X; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnosticEmpty(diagnostics); + + strictEqual(p.type.kind, "Number"); + strictEqual(p.type.value, 42); + }); + }); + + describe("union type constraints", () => { + let runner: BasicTestRunner; + let receivedArgs: any[] = []; + + beforeEach(() => { + receivedArgs = []; + testHost.addJsFile("test.js", { + $functions: { + "": { + acceptTypeOrValue(_ctx: FunctionContext, arg: any) { + receivedArgs.push(arg); + return arg; + }, + acceptMultipleTypes(_ctx: FunctionContext, arg: any) { + receivedArgs.push(arg); + return arg; + }, + acceptMultipleValues(_ctx: FunctionContext, arg: any) { + receivedArgs.push(arg); + return arg; + }, + returnTypeOrValue(ctx: FunctionContext, returnType: boolean) { + receivedArgs.push(returnType); + if (returnType) { + return ctx.program.checker.getStdType("string"); + } else { + return "hello"; + } + }, + }, + }, + }); + runner = createTestWrapper(testHost, { + autoImports: ["./test.js"], + autoUsings: ["TypeSpec.Reflection"], + }); + }); + + it("accepts type parameter", async () => { + const diagnostics = await runner.diagnose(` + extern fn acceptTypeOrValue(arg: unknown | valueof unknown): unknown; + + alias TypeResult = acceptTypeOrValue(string); + `); + + expectDiagnosticEmpty(diagnostics); + + strictEqual(receivedArgs.length, 1); + }); + + it("prefers value when applicable", async () => { + const diagnostics = await runner.diagnose(` + extern fn acceptTypeOrValue(arg: string | valueof string): valueof string; + + const ValueResult = acceptTypeOrValue("hello"); + `); + + expectDiagnosticEmpty(diagnostics); + + strictEqual(receivedArgs.length, 1); + // Prefer value overload + strictEqual(receivedArgs[0], "hello"); + }); + + it("accepts multiple specific types", async () => { + const diagnostics = await runner.diagnose(` + extern fn acceptMultipleTypes(arg: Reflection.Model | Reflection.Enum): Reflection.Model | Reflection.Enum; + + model TestModel {} + enum TestEnum { A } + + alias ModelResult = acceptMultipleTypes(TestModel); + alias EnumResult = acceptMultipleTypes(TestEnum); + `); + + expectDiagnosticEmpty(diagnostics); + + strictEqual(receivedArgs.length, 2); + strictEqual(receivedArgs[0].kind, "Model"); + strictEqual(receivedArgs[0].name, "TestModel"); + strictEqual(receivedArgs[1].kind, "Enum"); + strictEqual(receivedArgs[1].name, "TestEnum"); + }); + + it("accepts multiple value types", async () => { + const diagnostics = await runner.diagnose(` + extern fn acceptMultipleValues(arg: valueof (string | int32)): valueof (string | int32); + + const StringResult = acceptMultipleValues("test"); + const NumberResult = acceptMultipleValues(42); + `); + + expectDiagnosticEmpty(diagnostics); + + strictEqual(receivedArgs.length, 2); + strictEqual(receivedArgs[0], "test"); + strictEqual(receivedArgs[1], 42); + }); + + it("errors when argument doesn't match union constraint", async () => { + const diagnostics = await runner.diagnose(` + extern fn acceptMultipleTypes(arg: Reflection.Model | Reflection.Enum): Reflection.Model | Reflection.Enum; + + scalar TestScalar extends string; + alias Result = acceptMultipleTypes(TestScalar); + `); + + expectDiagnostics(diagnostics, { + code: "unassignable", + message: "Type 'TestScalar' is not assignable to type 'Model | Enum'", + }); + }); + + it("can return type from function", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn returnTypeOrValue(returnType: valueof boolean): unknown; + + alias TypeResult = returnTypeOrValue(true); + + model Observer { + @test p: TypeResult; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnosticEmpty(diagnostics); + + deepStrictEqual(receivedArgs, [true]); + + strictEqual(p.type.kind, "Scalar"); + strictEqual(p.type.name, "string"); + }); + + it("can return value from function", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn returnTypeOrValue(returnType: valueof boolean): valueof string; + + const ValueResult = returnTypeOrValue(false); + + model Observer { + @test p: string = ValueResult; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnosticEmpty(diagnostics); + + deepStrictEqual(receivedArgs, [false]); + + strictEqual(p.defaultValue?.entityKind, "Value"); + strictEqual(p.defaultValue?.valueKind, "StringValue"); + strictEqual(p.defaultValue?.value, "hello"); + }); + }); + + describe("error cases and edge cases", () => { + let runner: BasicTestRunner; + + beforeEach(() => { + testHost.addJsFile("test.js", { + $functions: { + "": { + testFn() {}, + returnWrongEntityKind(_ctx: FunctionContext) { + return "string value"; // Returns value when type expected + }, + returnWrongValueType(_ctx: FunctionContext) { + return 42; // Returns number when string expected + }, + throwError(_ctx: FunctionContext) { + throw new Error("JS error"); + }, + returnUndefined(_ctx: FunctionContext) { + return undefined; + }, + returnNull(_ctx: FunctionContext) { + return null; + }, + expectNonOptionalAfterOptional(_ctx: FunctionContext, _opt: any, req: any) { + return req; + }, + }, + }, + }); + runner = createTestWrapper(testHost, { + autoImports: ["./test.js"], + autoUsings: ["TypeSpec.Reflection"], + }); + }); + + it("errors when function returns wrong entity kind", async () => { + const diagnostics = await runner.diagnose(` + extern fn returnWrongEntityKind(): unknown; + alias X = returnWrongEntityKind(); + `); + + expectDiagnostics(diagnostics, { + code: "function-return", + message: + "Implementation of function 'returnWrongEntityKind' returned value '\"string value\"', which is not assignable to the declared return type 'unknown'.", + }); + }); + + it("errors when function returns wrong value type", async () => { + const diagnostics = await runner.diagnose(` + extern fn returnWrongValueType(): valueof string; + const X = returnWrongValueType(); + `); + + expectDiagnostics(diagnostics, { + code: "function-return", + message: + "Implementation of function 'returnWrongValueType' returned value '42', which is not assignable to the declared return type 'valueof string'.", + }); + }); + + it("thrown JS error bubbles up as ICE", async () => { + try { + const _diagnostics = await runner.diagnose(` + extern fn throwError(): unknown; + alias X = throwError(); + `); + + fail("Expected error to be thrown"); + } catch (error) { + ok(error instanceof Error); + strictEqual(error.message, "JS error"); + } + }); + + it("returns null for undefined return in value position", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn returnUndefined(): valueof unknown; + const X = returnUndefined(); + + model Observer { + @test p: unknown = X; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnosticEmpty(diagnostics); + + strictEqual(p.defaultValue?.entityKind, "Value"); + strictEqual(p.defaultValue?.valueKind, "NullValue"); + }); + + it("handles null return value", async () => { + const [{ p }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn returnNull(): valueof unknown; + const X = returnNull(); + + model Observer { + @test p: unknown = X; + } + `)) as [{ p: ModelProperty }, Diagnostic[]]; + + expectDiagnosticEmpty(diagnostics); + + strictEqual(p.defaultValue?.entityKind, "Value"); + strictEqual(p.defaultValue?.valueKind, "NullValue"); + }); + + it("validates required parameter after optional not allowed in regular param position", async () => { + const diagnostics = await runner.diagnose(` + extern fn expectNonOptionalAfterOptional(opt?: valueof string, req: valueof string): valueof string; + const X = expectNonOptionalAfterOptional("test"); + `); + + expectDiagnostics(diagnostics, { + code: "required-parameter-first", + message: "A required parameter cannot follow an optional parameter.", + }); + }); + + it("cannot be used as a regular type", async () => { + const diagnostics = await runner.diagnose(` + extern fn testFn(): unknown; + + model M { + prop: testFn; + } + `); + + expectDiagnostics(diagnostics, { + code: "invalid-type-ref", + message: "Can't use a function as a type", + }); + }); + }); + + describe("default function results", () => { + let runner: BasicTestRunner; + + beforeEach(() => { + testHost.addJsFile("missing-impl.js", {}); + runner = createTestWrapper(testHost, { + autoImports: ["./missing-impl.js"], + autoUsings: ["TypeSpec.Reflection"], + }); + }); + + it("returns default unknown value for missing value-returning function", async () => { + const diagnostics = await runner.diagnose(` + extern fn missingValueFn(): valueof string; + const X = missingValueFn(); + `); + expectDiagnostics(diagnostics, { + code: "missing-implementation", + }); + }); + + it("returns default type for missing type-returning function", async () => { + const diagnostics = await runner.diagnose(` + extern fn missingTypeFn(): unknown; + alias X = missingTypeFn(); + `); + expectDiagnostics(diagnostics, { + code: "missing-implementation", + }); + }); + + it("returns appropriate default for union return type", async () => { + const diagnostics = await runner.diagnose(` + extern fn missingUnionFn(): unknown | valueof string; + const X = missingUnionFn(); + `); + expectDiagnostics(diagnostics, { + code: "missing-implementation", + }); + }); + }); + + describe("template and generic scenarios", () => { + let runner: BasicTestRunner; + + beforeEach(() => { + testHost.addJsFile("templates.js", { + $functions: { + "": { + processGeneric(ctx: FunctionContext, type: Type) { + return $(ctx.program).array.create(type); + }, + processConstrainedGeneric(_ctx: FunctionContext, type: Type) { + return type; + }, + }, + }, + }); + runner = createTestWrapper(testHost, { + autoImports: ["./templates.js"], + autoUsings: ["TypeSpec.Reflection"], + }); + }); + + it("works with template aliases", async () => { + const [{ prop }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn processGeneric(T: unknown): unknown; + + alias ArrayOf = processGeneric(T); + + model TestModel { + @test prop: ArrayOf; + } + `)) as [{ prop: ModelProperty }, Diagnostic[]]; + + expectDiagnosticEmpty(diagnostics); + + ok(prop.type); + ok($(runner.program).array.is(prop.type)); + }); + + it("works with constrained templates", async () => { + const diagnostics = await runner.diagnose(` + extern fn processConstrainedGeneric(T: Reflection.Model): Reflection.Model; + + alias ProcessModel = processConstrainedGeneric(T); + + model TestModel {} + alias Result = ProcessModel; + `); + + expectDiagnosticEmpty(diagnostics); + }); + + it("errors when template constraint not satisfied", async () => { + const diagnostics = await runner.diagnose(` + extern fn processConstrainedGeneric(T: Reflection.Model): Reflection.Model; + + alias ProcessModel = processConstrainedGeneric(T); + + enum TestEnum { A } + alias Result = ProcessModel; + `); + + expectDiagnostics(diagnostics, { + code: "invalid-argument", + }); + }); + + it("template instantiations of function calls yield identical instances", async () => { + const [{ A, B }, diagnostics] = (await runner.compileAndDiagnose(` + extern fn processGeneric(T: unknown): unknown; + + alias ArrayOf = processGeneric(T); + + @test + model A { + propA: ArrayOf; + } + + @test + model B { + propB: ArrayOf; + } + `)) as [{ A: Model; B: Model }, Diagnostic[]]; + + expectDiagnosticEmpty(diagnostics); + + const aProp = A.properties.get("propA"); + const bProp = B.properties.get("propB"); + + ok(aProp); + ok(bProp); + + ok($(runner.program).array.is(aProp.type)); + ok($(runner.program).array.is(bProp.type)); + + strictEqual(aProp.type, bProp.type); + }); + }); +}); diff --git a/packages/compiler/test/checker/values/unknown-value.test.ts b/packages/compiler/test/checker/values/unknown-value.test.ts new file mode 100644 index 00000000000..657a719a9d7 --- /dev/null +++ b/packages/compiler/test/checker/values/unknown-value.test.ts @@ -0,0 +1,165 @@ +import { strictEqual } from "assert"; +import { beforeEach, describe, it } from "vitest"; +import { DecoratorContext, Program, Type } from "../../../src/index.js"; +import { expectDiagnostics } from "../../../src/testing/expect.js"; +import { createTestHost, createTestRunner } from "../../../src/testing/test-host.js"; +import { BasicTestRunner, TestHost } from "../../../src/testing/types.js"; + +describe("invalid uses of unknown value", () => { + let host: TestHost; + let runner: BasicTestRunner; + let observedValue: { value: unknown } | null = null; + + beforeEach(async () => { + observedValue = null; + host = await createTestHost(); + host.addJsFile("lib.js", { + $collect: (_context: DecoratorContext, _target: Type, value: unknown) => { + observedValue = { value }; + }, + $functions: { + Items: { + echo: (_: Program, value: unknown) => { + observedValue = { value }; + + return value; + }, + }, + }, + }); + runner = await createTestRunner(host); + }); + + it("cannot be passed to a decorator", async () => { + const diags = await runner.diagnose(` + import "./lib.js"; + + extern dec collect(target: Reflection.Model, value: valueof unknown); + + @collect(unknown) + model Test {} + `); + + strictEqual(observedValue, null); + + expectDiagnostics(diags, [ + { + code: "unknown-value", + message: "The 'unknown' value cannot be used as an argument to a function or decorator.", + severity: "error", + }, + ]); + }); + + it("cannot be passed to a decorator in rest position", async () => { + const diags = await runner.diagnose(` + import "./lib.js"; + + extern dec collect(target: Reflection.Model, ...values: valueof unknown[]); + + @collect(unknown) + model Test {} + `); + + strictEqual(observedValue, null); + + expectDiagnostics(diags, [ + { + code: "unknown-value", + message: "The 'unknown' value cannot be used as an argument to a function or decorator.", + severity: "error", + }, + ]); + }); + + it("cannot be passed to a function", async () => { + const diags = await runner.diagnose(` + import "./lib.js"; + + namespace Items { + extern fn echo(value: valueof unknown): unknown; + } + + alias X = Items.echo(unknown); + `); + + strictEqual(observedValue, null); + + expectDiagnostics(diags, [ + { + code: "unknown-value", + message: "The 'unknown' value cannot be used as an argument to a function or decorator.", + severity: "error", + }, + ]); + }); + + it("cannot be passed to a function in rest position", async () => { + const diags = await runner.diagnose(` + import "./lib.js"; + + namespace Items { + extern fn echo(...values: valueof unknown[]): valueof unknown; + } + + const x = Items.echo(unknown); + `); + + strictEqual(observedValue, null); + + expectDiagnostics(diags, [ + { + code: "unknown-value", + message: "The 'unknown' value cannot be used as an argument to a function or decorator.", + severity: "error", + }, + ]); + }); +}); + +describe("usage", () => { + let host: TestHost; + let runner: BasicTestRunner; + + beforeEach(async () => { + host = await createTestHost(); + runner = await createTestRunner(host); + }); + + for (const typeDescriptor of [ + "unknown", + "string", + "int32", + "boolean", + "model Foo", + "union Bar", + "enum Baz", + "string[]", + "Record", + ]) { + const type = typeDescriptor.replace(/^(model|union|enum) /, ""); + + it(`can be assigned to variable of type valueof '${typeDescriptor}'`, async () => { + const diags = await runner.diagnose(` + model Foo { + example: string; + } + + union Bar { + foo: Foo; + baz: Baz; + } + + enum Baz { + A, + B, + C, + } + + const x: ${type} = unknown; + `); + + expectDiagnostics(diags, []); + }); + } +}); diff --git a/packages/compiler/test/semantic-walker.test.ts b/packages/compiler/test/semantic-walker.test.ts index 81e4e464567..07428e136d1 100644 --- a/packages/compiler/test/semantic-walker.test.ts +++ b/packages/compiler/test/semantic-walker.test.ts @@ -7,6 +7,7 @@ import { navigateType, navigateTypesInNamespace, } from "../src/core/semantic-walker.js"; +import { FunctionType } from "../src/core/types.js"; import { Enum, Interface, @@ -50,6 +51,7 @@ describe("compiler: semantic walker", () => { namespaces: [] as Namespace[], exitNamespaces: [] as Namespace[], operations: [] as Operation[], + functions: [] as FunctionType[], exitOperations: [] as Operation[], tuples: [] as Tuple[], exitTuples: [] as Tuple[], @@ -72,6 +74,10 @@ describe("compiler: semantic walker", () => { result.operations.push(x); return customListener?.operation?.(x); }, + function: (x) => { + result.functions.push(x); + return customListener?.function?.(x); + }, exitOperation: (x) => { result.exitOperations.push(x); return customListener?.exitOperation?.(x); @@ -141,7 +147,14 @@ describe("compiler: semantic walker", () => { customListener?: SemanticNodeListener, options?: NavigationOptions, ) { - host.addTypeSpecFile("main.tsp", typespec); + host.addJsFile("main.js", { + $functions: { + Extern: { + foo() {}, + }, + }, + }); + host.addTypeSpecFile("main.tsp", `import "./main.js";\n\n${typespec}`); await host.compile("main.tsp", { nostdlib: true }); @@ -692,5 +705,16 @@ describe("compiler: semantic walker", () => { expect(results.models).toHaveLength(2); }); + + it("include functions", async () => { + const results = await runNavigator(` + namespace Extern; + + extern fn foo(): string; + `); + + expect(results.functions).toHaveLength(1); + expect(results.functions[0].name).toBe("foo"); + }); }); }); diff --git a/packages/compiler/test/server/completion.test.ts b/packages/compiler/test/server/completion.test.ts index aca984ec5ee..321daee906f 100644 --- a/packages/compiler/test/server/completion.test.ts +++ b/packages/compiler/test/server/completion.test.ts @@ -21,6 +21,7 @@ describe("complete statement keywords", () => { ["op", true], ["extern", true], ["dec", true], + ["fn", true], ["alias", true], ["namespace", true], ["import", true], diff --git a/packages/events/generated-defs/TypeSpec.Events.ts-test.ts b/packages/events/generated-defs/TypeSpec.Events.ts-test.ts index b49c6638a51..e284767b985 100644 --- a/packages/events/generated-defs/TypeSpec.Events.ts-test.ts +++ b/packages/events/generated-defs/TypeSpec.Events.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecEventsDecorators } from "./TypeSpec.Events.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecEventsDecorators = $decorators["TypeSpec.Events"]; +const _decs: TypeSpecEventsDecorators = $decorators["TypeSpec.Events"]; diff --git a/packages/html-program-viewer/src/react/type-config.ts b/packages/html-program-viewer/src/react/type-config.ts index 1066c137a37..3b5d93f4f53 100644 --- a/packages/html-program-viewer/src/react/type-config.ts +++ b/packages/html-program-viewer/src/react/type-config.ts @@ -57,6 +57,7 @@ export const TypeConfig: TypeGraphConfig = buildConfig({ unions: "nested-items", enums: "nested-items", decoratorDeclarations: "nested-items", + functionDeclarations: "nested-items", }, Interface: { operations: "nested-items", @@ -118,6 +119,11 @@ export const TypeConfig: TypeGraphConfig = buildConfig({ implementation: "skip", target: "ref", }, + Function: { + parameters: "nested-items", + returnType: "ref", + implementation: "skip", + }, ScalarConstructor: { scalar: "parent", parameters: "nested-items", diff --git a/packages/http-client/generated-defs/TypeSpec.HttpClient.ts-test.ts b/packages/http-client/generated-defs/TypeSpec.HttpClient.ts-test.ts index 619fd1f91e9..28eacf3e990 100644 --- a/packages/http-client/generated-defs/TypeSpec.HttpClient.ts-test.ts +++ b/packages/http-client/generated-defs/TypeSpec.HttpClient.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecHttpClientDecorators } from "./TypeSpec.HttpClient.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecHttpClientDecorators = $decorators["TypeSpec.HttpClient"]; +const _decs: TypeSpecHttpClientDecorators = $decorators["TypeSpec.HttpClient"]; diff --git a/packages/http/generated-defs/TypeSpec.Http.ts-test.ts b/packages/http/generated-defs/TypeSpec.Http.ts-test.ts index b7f2d8fa8a1..66ef18c6a14 100644 --- a/packages/http/generated-defs/TypeSpec.Http.ts-test.ts +++ b/packages/http/generated-defs/TypeSpec.Http.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecHttpDecorators } from "./TypeSpec.Http.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecHttpDecorators = $decorators["TypeSpec.Http"]; +const _decs: TypeSpecHttpDecorators = $decorators["TypeSpec.Http"]; diff --git a/packages/json-schema/generated-defs/TypeSpec.JsonSchema.ts-test.ts b/packages/json-schema/generated-defs/TypeSpec.JsonSchema.ts-test.ts index 067cfb7932b..000bbe0594e 100644 --- a/packages/json-schema/generated-defs/TypeSpec.JsonSchema.ts-test.ts +++ b/packages/json-schema/generated-defs/TypeSpec.JsonSchema.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecJsonSchemaDecorators } from "./TypeSpec.JsonSchema.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecJsonSchemaDecorators = $decorators["TypeSpec.JsonSchema"]; +const _decs: TypeSpecJsonSchemaDecorators = $decorators["TypeSpec.JsonSchema"]; diff --git a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts-test.ts b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts-test.ts index 8f8ecdbf06c..1d30a7518b3 100644 --- a/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts-test.ts +++ b/packages/openapi/generated-defs/TypeSpec.OpenAPI.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecOpenAPIDecorators } from "./TypeSpec.OpenAPI.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecOpenAPIDecorators = $decorators["TypeSpec.OpenAPI"]; +const _decs: TypeSpecOpenAPIDecorators = $decorators["TypeSpec.OpenAPI"]; diff --git a/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts-test.ts b/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts-test.ts index b115637c518..70efc1b2e22 100644 --- a/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts-test.ts +++ b/packages/openapi3/generated-defs/TypeSpec.OpenAPI.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecOpenAPIDecorators } from "./TypeSpec.OpenAPI.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecOpenAPIDecorators = $decorators["TypeSpec.OpenAPI"]; +const _decs: TypeSpecOpenAPIDecorators = $decorators["TypeSpec.OpenAPI"]; diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 1356578c378..12568f1a5b5 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -1584,7 +1584,7 @@ function createOAPIEmitter( options, ); - if (param.defaultValue) { + if (param.defaultValue && param.defaultValue.valueKind !== "UnknownValue") { schema.default = getDefaultValue(program, param.defaultValue, param); } // Description is already provided in the parameter itself. diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index 22117946257..3225e6f4023 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -433,7 +433,7 @@ export class OpenAPI3SchemaEmitterBase< {} as Schema, schema, ) as any; - if (prop.defaultValue) { + if (prop.defaultValue && prop.defaultValue.valueKind !== "UnknownValue") { additionalProps.default = getDefaultValue(program, prop.defaultValue, prop); } diff --git a/packages/protobuf/generated-defs/TypeSpec.Protobuf.ts-test.ts b/packages/protobuf/generated-defs/TypeSpec.Protobuf.ts-test.ts index 8439d354b21..6c62a82e7c1 100644 --- a/packages/protobuf/generated-defs/TypeSpec.Protobuf.ts-test.ts +++ b/packages/protobuf/generated-defs/TypeSpec.Protobuf.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecProtobufDecorators } from "./TypeSpec.Protobuf.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecProtobufDecorators = $decorators["TypeSpec.Protobuf"]; +const _decs: TypeSpecProtobufDecorators = $decorators["TypeSpec.Protobuf"]; diff --git a/packages/rest/generated-defs/TypeSpec.Rest.ts-test.ts b/packages/rest/generated-defs/TypeSpec.Rest.ts-test.ts index ab1192be9f6..d9238050e6c 100644 --- a/packages/rest/generated-defs/TypeSpec.Rest.ts-test.ts +++ b/packages/rest/generated-defs/TypeSpec.Rest.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecRestDecorators } from "./TypeSpec.Rest.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecRestDecorators = $decorators["TypeSpec.Rest"]; +const _decs: TypeSpecRestDecorators = $decorators["TypeSpec.Rest"]; diff --git a/packages/spector/generated-defs/TypeSpec.Spector.ts-test.ts b/packages/spector/generated-defs/TypeSpec.Spector.ts-test.ts index 515f6e257c9..cc404423ef3 100644 --- a/packages/spector/generated-defs/TypeSpec.Spector.ts-test.ts +++ b/packages/spector/generated-defs/TypeSpec.Spector.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecSpectorDecorators } from "./TypeSpec.Spector.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecSpectorDecorators = $decorators["TypeSpec.Spector"]; +const _decs: TypeSpecSpectorDecorators = $decorators["TypeSpec.Spector"]; diff --git a/packages/sse/generated-defs/TypeSpec.SSE.ts-test.ts b/packages/sse/generated-defs/TypeSpec.SSE.ts-test.ts index dc02b5a7be3..ffd24790e3e 100644 --- a/packages/sse/generated-defs/TypeSpec.SSE.ts-test.ts +++ b/packages/sse/generated-defs/TypeSpec.SSE.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecSSEDecorators } from "./TypeSpec.SSE.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecSSEDecorators = $decorators["TypeSpec.SSE"]; +const _decs: TypeSpecSSEDecorators = $decorators["TypeSpec.SSE"]; diff --git a/packages/streams/generated-defs/TypeSpec.Streams.ts-test.ts b/packages/streams/generated-defs/TypeSpec.Streams.ts-test.ts index db99152fa8d..672d8f183b8 100644 --- a/packages/streams/generated-defs/TypeSpec.Streams.ts-test.ts +++ b/packages/streams/generated-defs/TypeSpec.Streams.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecStreamsDecorators } from "./TypeSpec.Streams.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecStreamsDecorators = $decorators["TypeSpec.Streams"]; +const _decs: TypeSpecStreamsDecorators = $decorators["TypeSpec.Streams"]; diff --git a/packages/tspd/src/gen-extern-signatures/components/decorator-signature-tests.tsx b/packages/tspd/src/gen-extern-signatures/components/decorator-signature-tests.tsx deleted file mode 100644 index 8f40feac957..00000000000 --- a/packages/tspd/src/gen-extern-signatures/components/decorator-signature-tests.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Refkey } from "@alloy-js/core"; -import * as ts from "@alloy-js/typescript"; - -export interface DecoratorSignatureTests { - namespaceName: string; - dollarDecoratorRefKey: Refkey; - dollarDecoratorsTypeRefKey: Refkey; -} - -export function DecoratorSignatureTests({ - namespaceName, - dollarDecoratorRefKey, - dollarDecoratorsTypeRefKey, -}: Readonly) { - return ( - <> - - - - {dollarDecoratorRefKey} - {`["${namespaceName}"]`} - - - ); -} diff --git a/packages/tspd/src/gen-extern-signatures/components/dollar-functions-type.tsx b/packages/tspd/src/gen-extern-signatures/components/dollar-functions-type.tsx new file mode 100644 index 00000000000..4f686d02343 --- /dev/null +++ b/packages/tspd/src/gen-extern-signatures/components/dollar-functions-type.tsx @@ -0,0 +1,32 @@ +import { For, Refkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { FunctionSignature } from "../types.js"; + +export interface DollarFunctionsTypeProps { + namespaceName: string; + functions: FunctionSignature[]; + refkey: Refkey; +} + +/** Type for the $functions variable for the given namespace */ +export function DollarFunctionsType(props: Readonly) { + return ( + + + + {(signature) => { + return ; + }} + + + + ); +} + +function getFunctionsRecordForNamespaceName(namespaceName: string) { + return `${namespaceName.replaceAll(".", "")}Functions`; +} diff --git a/packages/tspd/src/gen-extern-signatures/components/entity-signature-tests.tsx b/packages/tspd/src/gen-extern-signatures/components/entity-signature-tests.tsx new file mode 100644 index 00000000000..939b289db50 --- /dev/null +++ b/packages/tspd/src/gen-extern-signatures/components/entity-signature-tests.tsx @@ -0,0 +1,53 @@ +import { Refkey, Show } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { EntitySignature } from "../types.js"; + +export interface EntitySignatureTests { + namespaceName: string; + entities: EntitySignature[]; + dollarDecoratorRefKey: Refkey; + dollarDecoratorsTypeRefKey: Refkey; + dollarFunctionsRefKey: Refkey; + dollarFunctionsTypeRefKey: Refkey; +} + +export function EntitySignatureTests({ + namespaceName, + entities, + dollarDecoratorRefKey, + dollarDecoratorsTypeRefKey, + dollarFunctionsRefKey, + dollarFunctionsTypeRefKey, +}: Readonly) { + const hasDecorators = entities.some((e) => e.kind === "Decorator"); + const hasFunctions = entities.some((e) => e.kind === "Function"); + + return ( + <> + + + + + {dollarDecoratorRefKey} + {`["${namespaceName}"]`} + + + + + + + {dollarFunctionsRefKey} + {`["${namespaceName}"]`} + + + + ); +} diff --git a/packages/tspd/src/gen-extern-signatures/components/decorators-signatures.tsx b/packages/tspd/src/gen-extern-signatures/components/entity-signatures.tsx similarity index 52% rename from packages/tspd/src/gen-extern-signatures/components/decorators-signatures.tsx rename to packages/tspd/src/gen-extern-signatures/components/entity-signatures.tsx index 13991a4f9e6..17dfceaba31 100644 --- a/packages/tspd/src/gen-extern-signatures/components/decorators-signatures.tsx +++ b/packages/tspd/src/gen-extern-signatures/components/entity-signatures.tsx @@ -5,48 +5,68 @@ import { Refkey, refkey, render, + Show, StatementList, } from "@alloy-js/core"; import * as ts from "@alloy-js/typescript"; import { Program } from "@typespec/compiler"; import { typespecCompiler } from "../external-packages/compiler.js"; -import { DecoratorSignature } from "../types.js"; -import { DecoratorSignatureTests } from "./decorator-signature-tests.jsx"; -import { - DecoratorSignatureType, - ValueOfModelTsInterfaceBody, -} from "./decorator-signature-type.jsx"; -import { DollarDecoratorsType } from "./dollar-decorators-type.jsx"; +import { DecoratorSignature, EntitySignature, FunctionSignature } from "../types.js"; +import { DecoratorSignatureType, ValueOfModelTsInterfaceBody } from "./decorator-signature-type.js"; +import { DollarDecoratorsType } from "./dollar-decorators-type.js"; +import { DollarFunctionsType } from "./dollar-functions-type.jsx"; +import { EntitySignatureTests } from "./entity-signature-tests.jsx"; +import { FunctionSignatureType } from "./function-signature-type.jsx"; import { createTspdContext, TspdContext, useTspd } from "./tspd-context.js"; -export interface DecoratorSignaturesProps { - decorators: DecoratorSignature[]; +export interface EntitySignaturesProps { + entities: EntitySignature[]; namespaceName: string; dollarDecoratorsRefKey: Refkey; + dollarFunctionsRefKey: Refkey; } -export function DecoratorSignatures({ +export function EntitySignatures({ namespaceName, - decorators, + entities, dollarDecoratorsRefKey: dollarDecoratorsRefkey, -}: DecoratorSignaturesProps) { + dollarFunctionsRefKey: dollarFunctionsRefkey, +}: EntitySignaturesProps) { + const decorators = entities.filter((e): e is DecoratorSignature => e.kind === "Decorator"); + + const functions = entities.filter((e): e is FunctionSignature => e.kind === "Function"); + return ( - - - - {(signature) => { - return ; - }} - - - - + 0}> + + + + {(signature) => } + + + + + + 0}> + + + + {(signature) => } + + + + + ); } @@ -70,19 +90,20 @@ export function LocalTypes() { export function generateSignatures( program: Program, - decorators: DecoratorSignature[], + entities: EntitySignature[], libraryName: string, namespaceName: string, ): OutputDirectory { const context = createTspdContext(program); const base = namespaceName === "" ? "__global__" : namespaceName; const $decoratorsRef = refkey(); + const $functionsRef = refkey(); const userLib = ts.createPackage({ name: libraryName, version: "0.0.0", descriptor: { ".": { - named: ["$decorators"], + named: ["$decorators", "$functions"], }, }, }); @@ -91,10 +112,11 @@ export function generateSignatures( - {!base.includes(".Private") && ( @@ -102,10 +124,13 @@ export function generateSignatures( path={`${base}.ts-test.ts`} headerComment="An error in the imports would mean that the decorator is not exported or doesn't have the right name." > - )} diff --git a/packages/tspd/src/gen-extern-signatures/components/function-signature-type.tsx b/packages/tspd/src/gen-extern-signatures/components/function-signature-type.tsx new file mode 100644 index 00000000000..dcb630ed411 --- /dev/null +++ b/packages/tspd/src/gen-extern-signatures/components/function-signature-type.tsx @@ -0,0 +1,357 @@ +import { For, join, List, refkey } from "@alloy-js/core"; +import * as ts from "@alloy-js/typescript"; +import { + getSourceLocation, + IntrinsicScalarName, + isArrayModelType, + MixedParameterConstraint, + Model, + Program, + Scalar, + type Type, +} from "@typespec/compiler"; +import { DocTag, SyntaxKind } from "@typespec/compiler/ast"; +import { typespecCompiler } from "../external-packages/compiler.js"; +import { FunctionSignature } from "../types.js"; +import { useTspd } from "./tspd-context.js"; + +export interface FunctionSignatureProps { + signature: FunctionSignature; +} + +/** Render the type of function implementation */ +export function FunctionSignatureType(props: Readonly) { + const { program } = useTspd(); + const func = props.signature.tspFunction; + const parameters: ts.ParameterDescriptor[] = [ + { + name: "context", + type: typespecCompiler.FunctionContext, + }, + ...func.parameters.map( + (param): ts.ParameterDescriptor => ({ + // https://github.com/alloy-framework/alloy/issues/144 + name: param.rest ? `...${param.name}` : param.name, + type: param.rest ? ( + <> + ( + {param.type ? ( + + ) : undefined} + )[] + + ) : ( + + ), + optional: param.optional, + }), + ), + ]; + + const returnType = ; + + return ( + + + + ); +} + +/** For a rest param of constraint T[] or valueof T[] return the T or valueof T */ +function extractRestParamConstraint( + program: Program, + constraint: MixedParameterConstraint, +): MixedParameterConstraint | undefined { + let valueType: Type | undefined; + let type: Type | undefined; + if (constraint.valueType) { + if (constraint.valueType.kind === "Model" && isArrayModelType(program, constraint.valueType)) { + valueType = constraint.valueType.indexer.value; + } else { + return undefined; + } + } + if (constraint.type) { + if (constraint.type.kind === "Model" && isArrayModelType(program, constraint.type)) { + type = constraint.type.indexer.value; + } else { + return undefined; + } + } + + return { + entityKind: "MixedParameterConstraint", + type, + valueType, + }; +} + +export interface ParameterTsTypeProps { + constraint: MixedParameterConstraint; +} +export function ParameterTsType({ constraint }: ParameterTsTypeProps) { + if (constraint.type && constraint.valueType) { + return ( + <> + + {" | "} + + + ); + } + if (constraint.valueType) { + return ; + } else if (constraint.type) { + return ; + } + + return typespecCompiler.Type; +} + +function TypeConstraintTSType({ type }: { type: Type }) { + if (type.kind === "Model" && isReflectionType(type)) { + return (typespecCompiler as any)[type.name]; + } else if (type.kind === "Union") { + const variants = [...type.variants.values()].map((x) => x.type); + + if (variants.every((x) => isReflectionType(x))) { + return join( + [...new Set(variants)].map((x) => getCompilerType((x as Model).name)), + { + joiner: " | ", + }, + ); + } else { + return typespecCompiler.Type; + } + } + return typespecCompiler.Type; +} + +function getCompilerType(name: string) { + return (typespecCompiler as any)[name]; +} + +function ValueTsType({ type }: { type: Type }) { + const { program } = useTspd(); + switch (type.kind) { + case "Boolean": + return `${type.value}`; + case "String": + return `"${type.value}"`; + case "Number": + return `${type.value}`; + case "Scalar": + return ; + case "Union": + return join( + [...type.variants.values()].map((x) => ), + { joiner: " | " }, + ); + case "Model": + if (isArrayModelType(program, type)) { + return ( + <> + readonly ( + )[] + + ); + } else if (isReflectionType(type)) { + return getValueOfReflectionType(type); + } else { + // If its exactly the record type use Record instead of the model name. + if (type.indexer && type.name === "Record" && type.namespace?.name === "TypeSpec") { + return ( + <> + Record{" + {">"} + + ); + } + if (type.name) { + return ; + } else { + return ; + } + } + } + return "unknown"; +} + +function LocalTypeReference({ type }: { type: Model }) { + const { addLocalType } = useTspd(); + addLocalType(type); + return ; +} +function ValueOfModelTsType({ model }: { model: Model }) { + return ( + + + + ); +} + +export function ValueOfModelTsInterfaceBody({ model }: { model: Model }) { + return ( + + {model.indexer?.value && ( + } + /> + )} + + {(x) => ( + } + /> + )} + + + ); +} + +function ScalarTsType({ scalar }: { scalar: Scalar }) { + const { program } = useTspd(); + const isStd = program.checker.isStdType(scalar); + if (isStd) { + return getStdScalarTSType(scalar); + } else if (scalar.baseScalar) { + return ; + } else { + return "unknown"; + } +} + +function getStdScalarTSType(scalar: Scalar & { name: IntrinsicScalarName }) { + switch (scalar.name) { + case "numeric": + case "decimal": + case "decimal128": + case "float": + case "integer": + case "int64": + case "uint64": + return typespecCompiler.Numeric; + case "int8": + case "int16": + case "int32": + case "safeint": + case "uint8": + case "uint16": + case "uint32": + case "float64": + case "float32": + return "number"; + case "string": + case "url": + return "string"; + case "boolean": + return "boolean"; + case "plainDate": + case "utcDateTime": + case "offsetDateTime": + case "plainTime": + case "duration": + case "bytes": + return "unknown"; + default: + const _assertNever: never = scalar.name; + return "unknown"; + } +} + +function isReflectionType(type: Type): type is Model & { namespace: { name: "Reflection" } } { + return ( + type.kind === "Model" && + type.namespace?.name === "Reflection" && + type.namespace?.namespace?.name === "TypeSpec" + ); +} + +function getValueOfReflectionType(type: Model) { + switch (type.name) { + case "EnumMember": + case "Enum": + return typespecCompiler.EnumValue; + case "Model": + return "Record"; + default: + return "unknown"; + } +} + +function getDocComment(type: Type): string { + const docs = type.node?.docs; + if (docs === undefined || docs.length === 0) { + return ""; + } + + const mainContentLines: string[] = []; + const tagLines = []; + for (const doc of docs) { + for (const content of doc.content) { + for (const line of content.text.split("\n")) { + mainContentLines.push(line); + } + } + for (const tag of doc.tags) { + tagLines.push(); + + let first = true; + const hasContentFirstLine = checkIfTagHasDocOnSameLine(tag); + const tagStart = + tag.kind === SyntaxKind.DocParamTag || tag.kind === SyntaxKind.DocTemplateTag + ? `@${tag.tagName.sv} ${tag.paramName.sv}` + : `@${tag.tagName.sv}`; + for (const content of tag.content) { + for (const line of content.text.split("\n")) { + const cleaned = sanitizeDocComment(line); + if (first) { + if (hasContentFirstLine) { + tagLines.push(`${tagStart} ${cleaned}`); + } else { + tagLines.push(tagStart, cleaned); + } + + first = false; + } else { + tagLines.push(cleaned); + } + } + } + } + } + + const docLines = [...mainContentLines, ...(tagLines.length > 0 ? [""] : []), ...tagLines]; + return docLines.join("\n"); +} + +function sanitizeDocComment(doc: string): string { + // Issue to escape @internal and other tsdoc tags https://github.com/microsoft/TypeScript/issues/47679 + return doc.replaceAll("@internal", `@_internal`); +} + +function checkIfTagHasDocOnSameLine(tag: DocTag): boolean { + const start = tag.content[0]?.pos; + const end = tag.content[0]?.end; + const file = getSourceLocation(tag.content[0]).file; + + let hasFirstLine = false; + for (let i = start; i < end; i++) { + const ch = file.text[i]; + if (ch === "\n") { + break; + } + // Todo reuse compiler whitespace logic or have a way to get this info from the parser. + if (ch !== " ") { + hasFirstLine = true; + } + } + return hasFirstLine; +} diff --git a/packages/tspd/src/gen-extern-signatures/external-packages/compiler.ts b/packages/tspd/src/gen-extern-signatures/external-packages/compiler.ts index b734fb02f17..4f343c1d737 100644 --- a/packages/tspd/src/gen-extern-signatures/external-packages/compiler.ts +++ b/packages/tspd/src/gen-extern-signatures/external-packages/compiler.ts @@ -6,7 +6,9 @@ export const typespecCompiler = createPackage({ descriptor: { ".": { named: [ + "Program", "DecoratorContext", + "FunctionContext", "Type", "Namespace", "Model", diff --git a/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts b/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts index ad1cea5d146..52cfa824c5e 100644 --- a/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts +++ b/packages/tspd/src/gen-extern-signatures/gen-extern-signatures.ts @@ -19,9 +19,10 @@ import { resolvePath, } from "@typespec/compiler"; import prettier from "prettier"; +import { FunctionType } from "../../../compiler/src/core/types.js"; import { createDiagnostic } from "../ref-doc/lib.js"; -import { generateSignatures } from "./components/decorators-signatures.js"; -import { DecoratorSignature } from "./types.js"; +import { generateSignatures } from "./components/entity-signatures.js"; +import { DecoratorSignature, EntitySignature, FunctionSignature } from "./types.js"; function createSourceLocation(path: string): SourceLocation { return { file: createSourceFile("", path), pos: 0, end: 0 }; @@ -108,8 +109,7 @@ export async function generateExternDecorators( packageName: string, options?: GenerateExternDecoratorOptions, ): Promise> { - const decorators = new Map(); - + const entities = new Map(); const listener: SemanticNodeListener = { decorator(dec) { if ( @@ -119,12 +119,28 @@ export async function generateExternDecorators( return; } const namespaceName = getTypeName(dec.namespace); - let decoratorForNamespace = decorators.get(namespaceName); - if (!decoratorForNamespace) { - decoratorForNamespace = []; - decorators.set(namespaceName, decoratorForNamespace); + let entitiesForNamespace = entities.get(namespaceName); + if (!entitiesForNamespace) { + entitiesForNamespace = []; + entities.set(namespaceName, entitiesForNamespace); } - decoratorForNamespace.push(resolveDecoratorSignature(dec)); + entitiesForNamespace.push(resolveDecoratorSignature(dec)); + }, + function(func) { + if ( + (packageName !== "@typespec/compiler" && + getLocationContext(program, func).type !== "project") || + func.namespace === undefined + ) { + return; + } + const namespaceName = getTypeName(func.namespace); + let entitiesForNamespace = entities.get(namespaceName); + if (!entitiesForNamespace) { + entitiesForNamespace = []; + entities.set(namespaceName, entitiesForNamespace); + } + entitiesForNamespace.push(resolveFunctionSignature(func)); }, }; if (options?.namespaces) { @@ -150,8 +166,8 @@ export async function generateExternDecorators( } const files: Record = {}; - for (const [ns, nsDecorators] of decorators.entries()) { - const output = generateSignatures(program, nsDecorators, packageName, ns); + for (const [ns, nsEntities] of entities.entries()) { + const output = generateSignatures(program, nsEntities, packageName, ns); const rawFiles: OutputFile[] = []; await traverseOutput(output, { visitDirectory: () => {}, @@ -169,9 +185,19 @@ export async function generateExternDecorators( function resolveDecoratorSignature(decorator: Decorator): DecoratorSignature { return { + kind: "Decorator", decorator, name: decorator.name, jsName: "$" + decorator.name.slice(1), typeName: decorator.name[1].toUpperCase() + decorator.name.slice(2) + "Decorator", }; } + +function resolveFunctionSignature(func: FunctionType): FunctionSignature { + return { + kind: "Function", + tspFunction: func, + name: func.name, + typeName: func.name[0].toUpperCase() + func.name.slice(1) + "FunctionImplementation", + }; +} diff --git a/packages/tspd/src/gen-extern-signatures/types.ts b/packages/tspd/src/gen-extern-signatures/types.ts index f40343d7e01..9e35b1fe901 100644 --- a/packages/tspd/src/gen-extern-signatures/types.ts +++ b/packages/tspd/src/gen-extern-signatures/types.ts @@ -1,6 +1,10 @@ -import type { Decorator } from "../../../compiler/src/core/types.js"; +import type { Decorator, FunctionType } from "../../../compiler/src/core/types.js"; + +export type EntitySignature = DecoratorSignature | FunctionSignature; export interface DecoratorSignature { + kind: Decorator["kind"]; + /** Decorator name ()`@example `@foo`) */ name: string; @@ -12,3 +16,15 @@ export interface DecoratorSignature { decorator: Decorator; } + +export interface FunctionSignature { + kind: FunctionType["kind"]; + + /** Function name */ + name: string; + + /** TypeScript type name (@example `FooFunction`) */ + typeName: string; + + tspFunction: FunctionType; +} diff --git a/packages/tspd/src/ref-doc/utils/type-signature.ts b/packages/tspd/src/ref-doc/utils/type-signature.ts index cd4e42984c0..eb30d76aba7 100644 --- a/packages/tspd/src/ref-doc/utils/type-signature.ts +++ b/packages/tspd/src/ref-doc/utils/type-signature.ts @@ -14,6 +14,7 @@ import { UnionVariant, } from "@typespec/compiler"; import { TemplateParameterDeclarationNode } from "@typespec/compiler/ast"; +import { FunctionType } from "../../../../compiler/src/core/types.js"; /** @internal */ export function getTypeSignature(type: Type): string { @@ -59,6 +60,8 @@ export function getTypeSignature(type: Type): string { return `(union variant) ${getUnionVariantSignature(type)}`; case "Tuple": return `(tuple) [${type.values.map(getTypeSignature).join(", ")}]`; + case "Function": + return getFunctionSignature(type); default: const _assertNever: never = type; compilerAssert(false, "Unexpected type kind"); @@ -84,6 +87,12 @@ function getDecoratorSignature(type: Decorator) { return signature; } +function getFunctionSignature(type: FunctionType) { + const ns = getQualifier(type.namespace); + const parameters = [...type.parameters].map((x) => getFunctionParameterSignature(x)); + return `fn ${ns}${type.name}(${parameters.join(", ")}): ${getEntityName(type.returnType)}`; +} + function getInterfaceSignature(type: Interface) { const ns = getQualifier(type.namespace); diff --git a/packages/versioning/generated-defs/TypeSpec.Versioning.ts-test.ts b/packages/versioning/generated-defs/TypeSpec.Versioning.ts-test.ts index dd682efbc45..5e7d70dad0a 100644 --- a/packages/versioning/generated-defs/TypeSpec.Versioning.ts-test.ts +++ b/packages/versioning/generated-defs/TypeSpec.Versioning.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecVersioningDecorators } from "./TypeSpec.Versioning.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecVersioningDecorators = $decorators["TypeSpec.Versioning"]; +const _decs: TypeSpecVersioningDecorators = $decorators["TypeSpec.Versioning"]; diff --git a/packages/xml/generated-defs/TypeSpec.Xml.ts-test.ts b/packages/xml/generated-defs/TypeSpec.Xml.ts-test.ts index 73d1eb64388..445937659a8 100644 --- a/packages/xml/generated-defs/TypeSpec.Xml.ts-test.ts +++ b/packages/xml/generated-defs/TypeSpec.Xml.ts-test.ts @@ -7,4 +7,4 @@ import type { TypeSpecXmlDecorators } from "./TypeSpec.Xml.js"; /** * An error here would mean that the exported decorator is not using the same signature. Make sure to have export const $decName: DecNameDecorator = (...) => ... */ -const _: TypeSpecXmlDecorators = $decorators["TypeSpec.Xml"]; +const _decs: TypeSpecXmlDecorators = $decorators["TypeSpec.Xml"]; diff --git a/website/src/content/docs/docs/extending-typespec/create-decorators.md b/website/src/content/docs/docs/extending-typespec/create-decorators.md index 380b7e8e2ff..04ac86ee03c 100644 --- a/website/src/content/docs/docs/extending-typespec/create-decorators.md +++ b/website/src/content/docs/docs/extending-typespec/create-decorators.md @@ -211,7 +211,7 @@ export const $lib = createTypeSpecLibrary({ export const StateKeys = $lib.stateKeys; ``` -### Reporting diagnostic on decorator or arguments +### Reporting diagnostics on decorator or arguments The decorator context provides the `decoratorTarget` and `getArgumentTarget` helpers. diff --git a/website/src/content/docs/docs/extending-typespec/implement-functions.md b/website/src/content/docs/docs/extending-typespec/implement-functions.md new file mode 100644 index 00000000000..c23e3563e68 --- /dev/null +++ b/website/src/content/docs/docs/extending-typespec/implement-functions.md @@ -0,0 +1,162 @@ +--- +id: implement-functions +title: Functions +--- + +TypeSpec functions, like [Decorators](./create-decorators.md), are implemented using JavaScript functions. To provide +a function in your library, you must: + +1. [Declare the function signature in TypeSpec](#declare-the-function-signature). +2. [Implement the function in JavaScript](#implement-the-function-in-javascript). + +## Declare the function signature + +Unlike decorators, declaring a function's signature in TypeSpec is mandatory. A function signature is declared using the +`fn` keyword. Functions are implemented in JavaScript and therefore the signature also requires the `extern` keyword. + +```tsp +extern fn myFn(); +``` + +A function signature can specify a list of parameters and optionally a return type constraint. + +```tsp +/** + * Concatenates to strings, equivalent to `l + r` in JavaScript, where `l` and `r` are strings. + * + * @param l String to be appended first to the result string. + * @param r String to be appended second to the result string. + * @returns the result of concatenating `l` and `r`. + */ +extern fn concat(l: valueof string, r: valueof string): valueof string; +``` + +Type constraints for parameters work exactly the same as constraints for [Decorator](../language-basics/decorators.md). + +### Optional parameters + +You can mark a function parameter as optional using `?`: + +```tsp +/** + * Renames a model, if `name` is provided and different from the input model's name. + * + * @param m the input Model to rename + * @param name if set, the name of the output model + * @returns `m` if `name` is not set or `m`'s name is equal to `name`, otherwise a new Model instance with the given + * name that is otherwise identical to `m`. + */ +extern fn rename(m: Reflection.Model, name?: valueof string): Reflection.Model; +``` + +### Rest parameters + +Functions may also specify "rest" parameters. The rest parameter collects all remaining arguments passed to the function, +and is declared using `...`. The type of a rest parameter _must_ be an array. + +```tsp +/** + * Joins a list of strings, equivalent to `rest.join(sep)` in JavaScript. + * + * @param sep the separator string used to join the list. + * @param rest the list of strings to join + * @returns the list of strings joined by the separator + */ +extern fn join(sep: valueof string, ...rest: valueof string[]): valueof string; +``` + +### Return type constraints + +Functions may optionally specify a return type constraint. The return type constraint is checked when the function is +called, and whatever the function returns must be assignable to the constraint. + +#### Void functions + +The `void` return type is treated specially. A JS implementation for a TypeSpec function that returns `void` may return +_either_ `undefined`, or an instance of the `void` intrinsic type, for compatibility with JavaScript void functions. +Regardless of what the implementation returns, the TypeSpec function call will _always_ evaluate to `void`. + +```tsp +namespace Example; + +extern fn myFn(): void; + +// Calling myFn() is guaranteed to evaluate to the `void` intrinsic type. +``` + +## Implement the function in JavaScript + +Functions must be implemented in a JavaScript library by exporting the functions the library implements using the +`$functions` variable. + +```ts +// lib.ts +import { FunctionContext } from "@typespec/compiler"; + +export const $functions = { + // Namespace + "MyOrg.MyLib": { + concat, + }, +}; + +function concat(context: FunctionContext, l: string, r: string): string { + return l + r; +} +``` + +The function implementation must be imported from TypeSpec to bind to the declaration in the signature: + +```tsp +// lib.tsp +import "./lib.js"; + +namespace MyOrg.MyLib; + +extern fn concat(l: valueof string, r: valueof string): valueof string; +``` + +The first argument passed to a JS function implementation is always the function's _context_, which has type +`FunctionContext`. The context provides information about where the function call was located in TypeSpec source, and +can be used to call other functions or invoke decorators from within the function implementation. + +### Function parameter marshalling + +When function arguments are _Types_, the type is passed to the function as-is. When a function argument is a _value_, +the function implementation receives a JavaScript value with a type that is appropriate for representing that value. + +| TypeSpec value type | Marshalled type in JS | +| ------------------- | --------------------------------- | +| `string` | `string` | +| `boolean` | `boolean` | +| `numeric` | `Numeric` or `number` (see below) | +| `null` | `null` | +| enum member | `EnumValue` | + +When marshalling numeric values, either the `Numeric` wrapper type is used, or a `number` is passed directly, depending on whether the value can be represented as a JavaScript number without precision loss. In particular, the types `numeric`, `integer`, `decimal`, `float`, `int64`, `uint64`, and `decimal128` are marshalled as a `Numeric` type. All other numeric types are marshalled as `number`. + +When marshalling custom scalar subtypes, the marshalling behavior of the known supertype is used. For example, a `scalar customScalar extends numeric` will marshal as a `Numeric`, regardless of any value constraints that might be present. + +### Reporting diagnostics on function calls or arguments + +The function context provides the `functionCallTarget` and `getArgumentTarget` helpers. + +```ts +import type { FunctionContext, Type } from "typespec/compiler"; +import { reportDiagnostic } from "./lib.js"; + +export function renamed(ctx: FunctionContext, model: Model, name: string): Model { + // To report a diagnostic on the function call + reportDiagnostic({ + code: "my-diagnostic-code", + target: ctx.functionCallTarget, + }); + // To report an error on a specific argument (for example the `model`), use the argument target. + // Note: targeting the `model` itself will put the diagnostic on the type's _declaration_, but using + // getArgumentTarget will put it on the _function argument_, which is probably what you want. + reportDiagnostic({ + code: "my-other-code", + target: ctx.getArgumentTarget(0), + }); +} +``` diff --git a/website/src/content/docs/docs/language-basics/functions.md b/website/src/content/docs/docs/language-basics/functions.md new file mode 100644 index 00000000000..0802b0ca222 --- /dev/null +++ b/website/src/content/docs/docs/language-basics/functions.md @@ -0,0 +1,156 @@ +--- +id: functions +title: Functions +--- + +Functions in TypeSpec allow library developers to compute and return types or values based on their inputs. Compared to +[decorators](./decorators.md), functions provide an input-output based approach to creating type or value instances, +offering more flexibility than decorators for creating new types dynamically. Functions enable complex type +manipulation, filtering, and transformation. + +Functions are declared using the `fn` keyword (with the required `extern` modifier, like decorators) and are backed by +JavaScript implementations. When a TypeSpec program calls a function, the corresponding JavaScript function is invoked +with the provided arguments, and the result is returned as either a Type or a Value depending on the function's +declaration. + +## Declaring functions + +Functions are declared using the `extern fn` syntax followed by a name, parameter list, optional return type constraint, +and semicolon: + +```typespec +extern fn functionName(param1: Type, param2: valueof string): ReturnType; +``` + +Here are some examples of function declarations: + +```typespec +// No arguments, returns a type (default return constraint is 'unknown') +extern fn createDefaultModel(); + +// Takes a string type, returns a type +extern fn transformModel(input: string); + +// Takes a string value, returns a type +extern fn createFromValue(name: valueof string); + +// Returns a value instead of a type +extern fn getDefaultName(): valueof string; + +// Takes and returns values +extern fn processFilter(filter: valueof Filter): valueof Filter; +``` + +## Calling functions + +Functions are called using standard function call syntax with parentheses. They can be used in type expressions, aliases, +and anywhere a type or value is expected: + +```typespec +// Call a function in an alias +alias ProcessedModel = transformModel("input"); + +// Call a function for a default value +model Example { + name: string = getDefaultName(); +} + +// Use in template constraints +alias Filtered = applyFilter(T, F); +``` + +## Return types and constraints + +Functions can return either types or values, controlled by the return type constraint: + +- **No return type specified**: Returns a `Type` (implicitly constrained to `unknown`) +- **`valueof SomeType`**: Returns a value of the specified type +- **Mixed constraints**: `Type | valueof Type` allows returning either types or values + +```typespec +// Returns a type +extern fn makeModel(): Model; + +// Returns a string value +extern fn getName(): valueof string; + +// Can return either a type or value +extern fn flexible(): unknown | (valueof unknown); +``` + +:::note +A function call does not always evaluate to its return type. The function call may evaluate to any _subtype_ +of the return type constraint (any type or value that is _assignable_ to the constraint). For example, a function that +returns `Reflection.Model` may actually evaluate to any model. A function that returns `Foo` where `Foo` is a model may +evaluate to any model that is assignable to `Foo`. +::: + +## Parameter types + +Function parameters follow the same rules as decorator parameters: + +- **Type parameters**: Accept TypeScript types (e.g., `param: string`) +- **Value parameters**: Accept values using `valueof` (e.g., `param: valueof string`) +- **Mixed parameters**: Can accept both types and values with union syntax + +```typespec +extern fn process( + model: Model, // Type parameter + name: valueof string, // Value parameter + optional?: string, // Optional type parameter + ...rest: valueof string[] // Rest parameter with values +); +``` + +## Practical examples + +### Type transformation + +Functions may be used to transform types in arbitrary ways _without_ modifying an existing type instance. In the +following example, we declare a function `applyVisibility` that could be used to transform an input Model into an +output Model based on a `VisibilityFilter` object. We use a template alias to instantiate the new instance, because +templates _cache_ their instances and always return the same type for the same template arguments. + +```typespec +// Transform a model based on a filter +extern fn applyVisibility(input: Model, visibility: valueof VisibilityFilter): Model; + +const READ_FILTER: VisibilityFilter = #{ any: #[Public] }; + +// Using a template to call a function can be beneficial because templates cache +// their instances. A function _never_ caches its results, so each time `applyVisibility` +// is called, it will run the underlying JavaScript function. By using a template to call +// the function, it ensures that the function is only called once per unique instance +// of the template. +alias Read = applyVisibility(M, READ_FILTER); +``` + +### Value computation + +Functions can also be used to extract complex logic. The following example shows how a function might be used to compute +a default value for a given type of field. The external function can have arbitrarily complex JavaScript logic, so it +can utilize any method of computing the result value that it deems appropriate. + +```typespec +// Compute a default value using some external logic +extern fn computeDefault(fieldType: string): valueof unknown; + +model Config { + timeout: int32 = computeDefault("timeout"); +} +``` + +## Implementation notes + +- Function results are _never_ cached, unlike template instances. Calling the same function with the same arguments + multiple times will result in multiple function calls. +- Functions _may_ have side-effects when called; they are not guaranteed to be "pure" functions. Be careful when writing + functions to avoid manipulating the type graph or storing undesirable state (though there is no rule that will prevent + you from doing so). +- Functions are evaluated in the compiler. If you write or utilize computationally intense functions, it will impact + compilation times and may affect language server performance. + +## Implementing functions in your library + +See [Extending TypeSpec - Functions](../extending-typespec/implement-functions.md) for more information about how to add +a function to your TypeSpec library. diff --git a/website/src/content/docs/docs/language-basics/values.md b/website/src/content/docs/docs/language-basics/values.md index 879c95bf949..48b17902464 100644 --- a/website/src/content/docs/docs/language-basics/values.md +++ b/website/src/content/docs/docs/language-basics/values.md @@ -103,6 +103,23 @@ const value: string | null = null; The `null` value, like the `null` type, doesn't have any special behavior in the TypeSpec language. It is just the value `null` like that in JSON. +#### The unknown value + +TypeSpec provides a special `unknown` value, which can be used to represent a value that is not known. + +For example, `unknown` can be specified as the default value of a model property. An unknown default value means "there is a default value, but it is _unspecified_" (because its value may depend on service configuration, may change in the future, may be autogenerated in some inexpressible way, or because you simply don't wish to specify what it is). + +```tsp +model Example { + // `field` has a default value that the server will use if it's not specified, + // but what it actually is will be determined by the server in an unspecified + // manner. + field: string = unknown; +} +``` + +When working with values, emitters should treat the `unknown` value as if it could be any value that satisfies the type constraint of its context (in the above example, it may be any string value). + ## Const declarations Const declarations allow storing values in a variable for later reference. Const declarations have an optional type annotation. When the type annotation is absent, the type is inferred from the value by constructing an exact type from the initializer.