@typespec/http-server-js
Version:
TypeSpec HTTP server code generator for JavaScript
338 lines (292 loc) • 10.8 kB
text/typescript
// Copyright (c) Microsoft Corporation
// Licensed under the MIT license.
import {
DiagnosticTarget,
IntrinsicType,
LiteralType,
Namespace,
NoTarget,
Type,
compilerAssert,
getEffectiveModelType,
getFriendlyName,
isArrayModelType,
} from "@typespec/compiler";
import { JsContext, Module, isImportableType } from "../ctx.js";
import { reportDiagnostic } from "../lib.js";
import { parseCase } from "../util/case.js";
import { asArrayType, getArrayElementName } from "../util/pluralism.js";
import { emitModelLiteral, emitWellKnownModel, isWellKnownModel } from "./model.js";
import { createOrGetModuleForNamespace } from "./namespace.js";
import { getJsScalar } from "./scalar.js";
import { emitUnionType } from "./union.js";
export type NamespacedType = Extract<Type, { namespace?: Namespace }>;
/**
* Options for emitting a type reference.
*/
export interface EmitTypeReferenceOptions {
/**
* An optional alternative name to use for the type if it is not named.
*/
altName?: string;
/**
* Require a declaration for types that may be represented anonymously.
*/
requireDeclaration?: boolean;
}
/**
* Emits a reference to a host type.
*
* This function will automatically ensure that the referenced type is included in the emit graph, and will import the
* type into the current module if necessary.
*
* Optionally, a `preferredAlternativeName` may be supplied. This alternative name will be used if a declaration is
* required, but the type is anonymous. The alternative name can only be set once. If two callers provide different
* alternative names for the same anonymous type, the first one is used in all cases. If a declaration _is_ required,
* and no alternative name is supplied (or has been supplied in a prior call to `emitTypeReference`), this function will
* throw an error. Callers must be sure to provide an alternative name if the type _may_ have an unknown name. However,
* callers may know that they have previously emitted a reference to the type and provided an alternative name in that
* call, in which case the alternative name may be safely omitted.
*
* @param ctx - The emitter context.
* @param type - The type to emit a reference to.
* @param position - The syntactic position of the reference, for diagnostics.
* @param module - The module that the reference is being emitted into.
* @param preferredAlternativeName - An optional alternative name to use for the type if it is not named.
* @returns a string containing a reference to the TypeScript type that represents the given TypeSpec type.
*/
export function emitTypeReference(
ctx: JsContext,
type: Type,
position: DiagnosticTarget | typeof NoTarget,
module: Module,
options: EmitTypeReferenceOptions = {},
): string {
switch (type.kind) {
case "Scalar":
// Get the scalar and return it directly, as it is a primitive.
return getJsScalar(ctx, module, type, position).type;
case "Model": {
// First handle arrays.
if (isArrayModelType(ctx.program, type)) {
const argumentType = type.indexer.value;
const argTypeReference = emitTypeReference(ctx, argumentType, position, module, {
altName: options.altName && getArrayElementName(options.altName),
});
if (isImportableType(ctx, argumentType) && argumentType.namespace) {
module.imports.push({
binder: [argTypeReference],
from: createOrGetModuleForNamespace(ctx, argumentType.namespace),
});
}
return asArrayType(argTypeReference);
}
// Now other well-known models.
if (isWellKnownModel(ctx, type)) {
return emitWellKnownModel(ctx, type, module, options.altName);
}
// Try to reduce the model to an effective model if possible.
const effectiveModel = getEffectiveModelType(ctx.program, type);
if (effectiveModel.name === "") {
// We might have seen the model before and synthesized a declaration for it already.
if (ctx.syntheticNames.has(effectiveModel)) {
const name = ctx.syntheticNames.get(effectiveModel)!;
module.imports.push({
binder: [name],
from: ctx.syntheticModule,
});
return name;
}
// Require preferredAlternativeName at this point, as we have an anonymous model that we have not visited.
if (!options.altName) {
return emitModelLiteral(ctx, effectiveModel, module);
}
// Anonymous model, synthesize a new model with the preferredName
ctx.synthetics.push({
kind: "anonymous",
name: options.altName,
underlying: effectiveModel,
});
module.imports.push({
binder: [options.altName],
from: ctx.syntheticModule,
});
ctx.syntheticNames.set(effectiveModel, options.altName);
return options.altName;
} else {
// The effective model is good for a declaration, so enqueue it.
ctx.typeQueue.add(effectiveModel);
}
const friendlyName = getFriendlyName(ctx.program, effectiveModel);
// The model may be a template instance, so we generate a name for it.
const templatedName = parseCase(
friendlyName
? friendlyName
: effectiveModel.templateMapper
? effectiveModel
.templateMapper!.args.map((a) => ("name" in a ? String(a.name) : ""))
.join("_") + effectiveModel.name
: effectiveModel.name,
);
if (!effectiveModel.namespace) {
throw new Error("UNREACHABLE: no parent namespace of named model in emitTypeReference");
}
const parentModule = createOrGetModuleForNamespace(ctx, effectiveModel.namespace);
module.imports.push({
binder: [templatedName.pascalCase],
from: parentModule,
});
return templatedName.pascalCase;
}
case "Union": {
if (type.variants.size === 0) return "never";
else if (type.variants.size === 1)
return emitTypeReference(ctx, [...type.variants.values()][0], position, module, options);
if (options.requireDeclaration) {
if (type.name) {
const nameCase = parseCase(type.name);
ctx.typeQueue.add(type);
module.imports.push({
binder: [nameCase.pascalCase],
from: createOrGetModuleForNamespace(ctx, type.namespace!),
});
return type.name;
} else {
const existingSyntheticName = ctx.syntheticNames.get(type);
if (existingSyntheticName) {
module.imports.push({
binder: [existingSyntheticName],
from: ctx.syntheticModule,
});
return existingSyntheticName;
} else {
const altName = options.altName;
if (!altName) {
throw new Error("UNREACHABLE: anonymous union without preferredAlternativeName");
}
ctx.synthetics.push({
kind: "anonymous",
name: altName,
underlying: type,
});
module.imports.push({
binder: [altName],
from: ctx.syntheticModule,
});
ctx.syntheticNames.set(type, altName);
return altName;
}
}
} else {
return emitUnionType(ctx, [...type.variants.values()], module);
}
}
case "Enum": {
ctx.typeQueue.add(type);
const name = parseCase(type.name).pascalCase;
module.imports.push({
binder: [name],
from: createOrGetModuleForNamespace(ctx, type.namespace!),
});
return name;
}
case "String":
return escapeUnsafeChars(JSON.stringify(type.value));
case "Number":
case "Boolean":
return String(type.value);
case "EnumMember": {
if (typeof type.value === "string") {
return escapeUnsafeChars(JSON.stringify(type.value));
} else if (typeof type.value === "number") {
return String(type.value);
} else if (type.value === undefined) {
return escapeUnsafeChars(JSON.stringify(type.name));
} else {
void (type.value satisfies never);
return "unknown";
}
}
case "Intrinsic":
switch (type.name) {
case "never":
return "never";
case "null":
return "null";
case "void":
// It's a bit strange to have a void property, but it's possible, and TypeScript allows it. Void is simply
// only assignable from undefined or void itself.
return "void";
case "ErrorType":
compilerAssert(
false,
"ErrorType should not be encountered in emitTypeReference",
position === NoTarget ? type : position,
);
return "unknown";
case "unknown":
return "unknown";
default:
reportDiagnostic(ctx.program, {
code: "unrecognized-intrinsic",
format: { intrinsic: (type satisfies never as IntrinsicType).name },
target: position,
});
return "unknown";
}
case "Interface": {
if (type.namespace === undefined) {
throw new Error("UNREACHABLE: unparented interface");
}
const typeName = parseCase(type.name).pascalCase;
ctx.typeQueue.add(type);
const parentModule = createOrGetModuleForNamespace(ctx, type.namespace);
module.imports.push({
binder: [typeName],
from: parentModule,
});
return typeName;
}
case "Tuple": {
const elementTypes = type.values.map((e) => emitTypeReference(ctx, e, position, module));
return `[${elementTypes.join(", ")}]`;
}
case "ModelProperty":
case "UnionVariant": {
// Forward to underlying type.
return emitTypeReference(ctx, type.type, position, module, options);
}
default:
throw new Error(`UNREACHABLE: ${type.kind}`);
}
}
const UNSAFE_CHAR_MAP: { [k: string]: string } = {
"<": "\\u003C",
">": "\\u003E",
"/": "\\u002F",
"\\": "\\\\",
"\b": "\\b",
"\f": "\\f",
"\n": "\\n",
"\r": "\\r",
"\t": "\\t",
"\0": "\\0",
"\u2028": "\\u2028",
"\u2029": "\\u2029",
};
export function escapeUnsafeChars(s: string) {
return s.replace(/[<>/\\\b\f\n\r\t\0\u2028\u2029]/g, (x) => UNSAFE_CHAR_MAP[x]);
}
export type JsTypeSpecLiteralType = LiteralType | (IntrinsicType & { name: "null" });
export function isValueLiteralType(t: Type): t is JsTypeSpecLiteralType {
switch (t.kind) {
case "String":
case "Number":
case "Boolean":
return true;
case "Intrinsic":
return t.name === "null";
default:
return false;
}
}