Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Added the ability to interpolate identifiers in most positions (model, property, enum, union, scalar, operation names, etc.). To compute an identifier, use backticks as if creating a templated string type (e.g. `const v = "Model"; model \`My${v}` { ... }`).
79 changes: 64 additions & 15 deletions packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2062,7 +2062,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
namespace,
`Decorator ${node.id.sv} should have resolved a namespace or found the global namespace.`,
);
const name = node.id.sv;
const name = resolveIdentifierName(ctx, node.id);

if (!(node.modifierFlags & ModifierFlags.Extern)) {
reportCheckerDiagnostic(createDiagnostic({ code: "decorator-extern", target: node }));
Expand Down Expand Up @@ -2131,7 +2131,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
const base = {
kind: "FunctionParameter",
node,
name: node.id.sv,
name: resolveIdentifierName(ctx, node.id),
optional: node.optional,
rest: node.rest,
implementation: node.symbol.value!,
Expand Down Expand Up @@ -2330,7 +2330,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
if (!symbolLinks.type) {
// haven't seen this namespace before
const namespace = getParentNamespaceType(node);
const name = node.id.sv;
const name = resolveIdentifierName(CheckContext.DEFAULT, node.id, {
allowInterpolation: false,
kind: "namespace",
});
const type: Namespace = createType({
kind: "Namespace",
name,
Expand Down Expand Up @@ -2482,7 +2485,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
}

const namespace = getParentNamespaceType(node);
const name = node.id.sv;
const name = resolveIdentifierName(ctx, node.id);

const { resolvedSymbol: parameterModelSym } = resolver.resolveMetaMemberByName(
symbol!,
Expand Down Expand Up @@ -3806,7 +3809,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
const decorators: DecoratorApplication[] = [];
const type: Model = createType({
kind: "Model",
name: node.id.sv,
name: resolveIdentifierName(ctx, node.id),
node: node,
properties: createRekeyableMap<string, ModelProperty>(),
namespace: getParentNamespaceType(node),
Expand Down Expand Up @@ -4970,7 +4973,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
if (links && links.declaredType && ctx.mapper === undefined) {
return links.declaredType as ModelProperty;
}
const name = prop.id.sv;
const name = resolveIdentifierName(ctx, prop.id);

const type: ModelProperty = createType({
kind: "ModelProperty",
Expand Down Expand Up @@ -5030,6 +5033,47 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
};
}

function resolveIdentifierName(
ctx: CheckContext,
id: IdentifierNode,
options: { allowInterpolation?: boolean; kind?: string } = {},
): string {
if (id.interpolation === undefined) {
return id.sv;
}
if (options.allowInterpolation === false) {
reportCheckerDiagnostic(
createDiagnostic({
code: "invalid-interpolated-identifier-context",
format: { kind: options.kind ?? "this" },
target: id.interpolation,
}),
);
return id.sv;
}

let value = id.interpolation.head.value;
for (const span of id.interpolation.spans) {
const evaluated = getValueForNode(span.expression, ctx.mapper);
if (evaluated === null) {
return id.sv;
}

if (evaluated.valueKind !== "StringValue") {
reportCheckerDiagnostic(
createDiagnostic({
code: "invalid-interpolated-identifier",
target: span.expression,
}),
);
return id.sv;
}

value += evaluated.value + span.literal.value;
}
return value;
}

function checkDefaultValue(ctx: CheckContext, defaultNode: Node, type: Type): Value | null {
if (isErrorType(type)) {
// if the prop type is an error we don't need to validate again.
Expand Down Expand Up @@ -5466,7 +5510,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker

const type: Scalar = createType({
kind: "Scalar",
name: node.id.sv,
name: resolveIdentifierName(ctx, node.id),
node: node,
constructors: new Map(),
namespace: getParentNamespaceType(node),
Expand Down Expand Up @@ -5573,7 +5617,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
node: ScalarConstructorNode,
parentScalar: Scalar,
): ScalarConstructor {
const name = node.id.sv;
const name = resolveIdentifierName(ctx, node.id);
const links = getSymbolLinksForMember(node);
if (links && links.declaredType && ctx.mapper === undefined) {
// we're not instantiating this scalar constructor and we've already checked it
Expand All @@ -5599,6 +5643,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker

function checkAlias(ctx: CheckContext, node: AliasStatementNode): Type | IndeterminateEntity {
const links = getSymbolLinks(node.symbol);
resolveIdentifierName(ctx, node.id, { allowInterpolation: false, kind: "alias" });

if (ctx.mapper === undefined && node.templateParameters.length > 0) {
// This is a templated declaration and we are not instantiating it, so we need to update the flags.
Expand All @@ -5616,7 +5661,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
reportCheckerDiagnostic(
createDiagnostic({
code: "circular-alias-type",
format: { typeName: node.id.sv },
format: { typeName: resolveIdentifierName(ctx, node.id) },
target: node,
}),
);
Expand Down Expand Up @@ -5644,6 +5689,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker

function checkConst(node: ConstStatementNode): Value | null {
const links = getSymbolLinks(node.symbol);
const constName = resolveIdentifierName(CheckContext.DEFAULT, node.id, {
allowInterpolation: false,
kind: "const",
});
if (links.value !== undefined) {
return links.value;
}
Expand All @@ -5654,7 +5703,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
reportCheckerDiagnostic(
createDiagnostic({
code: "circular-const",
format: { name: node.id.sv },
format: { name: constName },
target: node,
}),
);
Expand Down Expand Up @@ -5696,7 +5745,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
if (!links.type) {
const enumType: Enum = (links.type = createType({
kind: "Enum",
name: node.id.sv,
name: resolveIdentifierName(ctx, node.id),
node,
members: createRekeyableMap<string, EnumMember>(),
decorators: [],
Expand Down Expand Up @@ -5766,7 +5815,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
namespace: getParentNamespaceType(node),
sourceInterfaces: [],
operations: createRekeyableMap(),
name: node.id.sv,
name: resolveIdentifierName(ctx, node.id),
});

linkType(ctx, links, interfaceType);
Expand Down Expand Up @@ -5879,7 +5928,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
decorators: [],
node,
namespace: getParentNamespaceType(node),
name: node.id.sv,
name: resolveIdentifierName(ctx, node.id),
variants,
get options() {
return Array.from(this.variants.values()).map((v) => v.type);
Expand Down Expand Up @@ -5935,7 +5984,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
return links.declaredType as UnionVariant;
}

const name = variantNode.id ? variantNode.id.sv : Symbol("name");
const name = variantNode.id ? resolveIdentifierName(ctx, variantNode.id) : Symbol("name");
const type = getTypeForNode(variantNode.value, ctx);
const variantType: UnionVariant = createType({
kind: "UnionVariant",
Expand Down Expand Up @@ -5979,7 +6028,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker
}

function checkEnumMember(ctx: CheckContext, node: EnumMemberNode, parentEnum?: Enum): EnumMember {
const name = node.id.sv;
const name = resolveIdentifierName(ctx, node.id);
const links = getSymbolLinksForMember(node);
if (links?.type) {
return links.type as EnumMember;
Expand Down
13 changes: 13 additions & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,19 @@ const diagnostics = {
"Value interpolated in this string template cannot be converted to a string. Only literal types can be automatically interpolated.",
},
},
"invalid-interpolated-identifier": {
severity: "error",
messages: {
default:
"Interpolated identifier must evaluate to a string value. Interpolation in identifier names cannot produce non-string values.",
},
},
"invalid-interpolated-identifier-context": {
severity: "error",
messages: {
default: paramMessage`Interpolated identifiers are not supported for ${"kind"} declarations yet.`,
},
},

/**
* Binder
Expand Down
14 changes: 14 additions & 0 deletions packages/compiler/src/core/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa
let previousTokenEnd = -1;
let realPositionOfLastError = -1;
let missingIdentifierCounter = 0;
let interpolatedIdentifierCounter = 0;
let treePrintable = true;
let newLineIsTrivia = true;
let currentMode = ParseMode.Syntax;
Expand Down Expand Up @@ -1955,6 +1956,17 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa
// Temporary solution to allow reserved keywords as identifiers in certain contexts. This should get expanded to a more general solution per keyword category.
allowReservedIdentifier?: boolean;
}): IdentifierNode {
if (token() === Token.StringTemplateHead) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I don't like this way of implementing this. It seems too fragile to allow this anywhere we call parseIdentifier... I think it would be better to have a parseIdentifierOrInterpolated and only call that from syntactic positions where it should be allowed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand diagnostic behavior is probably better this way, since we get a clean "interpolation not allowed here" error instead of "unexpected token"...

const pos = tokenPos();
const interpolation = parseStringTemplateExpression();
return {
kind: SyntaxKind.Identifier,
sv: `<interpolated identifier>${++interpolatedIdentifierCounter}`,
interpolation,
...finishNode(pos),
};
}

if (isKeyword(token())) {
error({ code: "reserved-identifier" });
return createMissingIdentifier();
Expand Down Expand Up @@ -3075,7 +3087,9 @@ export function visitChildren<T>(node: Node, cb: NodeCallback<T>): T | undefined
case SyntaxKind.StringLiteral:
case SyntaxKind.NumericLiteral:
case SyntaxKind.BooleanLiteral:
return;
case SyntaxKind.Identifier:
return visitNode(cb, node.interpolation);
case SyntaxKind.EmptyStatement:
case SyntaxKind.VoidKeyword:
case SyntaxKind.NeverKeyword:
Expand Down
25 changes: 24 additions & 1 deletion packages/compiler/src/core/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,7 @@ export function createScanner(
tail: T,
): M | T {
const multiLine = requestedTokenFlags & TokenFlags.TripleQuoted;
const backticked = requestedTokenFlags & TokenFlags.Backticked;
tokenFlags = requestedTokenFlags;
loop: for (; !eof(); position++) {
const ch = input.charCodeAt(position);
Expand All @@ -1115,6 +1116,9 @@ export function createScanner(
}
continue;
case CharCode.DoubleQuote:
if (backticked) {
continue;
}
if (multiLine) {
if (lookAhead(1) === CharCode.DoubleQuote && lookAhead(2) === CharCode.DoubleQuote) {
position += 3;
Expand All @@ -1128,6 +1132,13 @@ export function createScanner(
token = tail;
return tail;
}
case CharCode.Backtick:
if (!backticked) {
continue;
}
position++;
token = tail;
return tail;
case CharCode.$:
if (lookAhead(1) === CharCode.OpenBrace) {
position += 2;
Expand All @@ -1152,6 +1163,9 @@ export function createScanner(
token: Token.StringLiteral | StringTemplateToken,
tokenFlags: TokenFlags,
) {
if (tokenFlags & TokenFlags.Backticked) {
return 1; // ` or }
}
switch (token) {
case Token.StringLiteral:
case Token.StringTemplateHead:
Expand All @@ -1165,6 +1179,9 @@ export function createScanner(
token: Token.StringLiteral | StringTemplateToken,
tokenFlags: TokenFlags,
) {
if (tokenFlags & TokenFlags.Backticked) {
return token === Token.StringTemplateHead || token === Token.StringTemplateMiddle ? 2 : 1;
}
switch (token) {
case Token.StringLiteral:
case Token.StringTemplateTail:
Expand Down Expand Up @@ -1532,7 +1549,7 @@ export function createScanner(
return (token = Token.Identifier);
}

function scanBacktickedIdentifier(): Token.Identifier {
function scanBacktickedIdentifier(): Token.Identifier | Token.StringTemplateHead {
position++; // consume '`'

tokenFlags |= TokenFlags.Backticked;
Expand All @@ -1544,6 +1561,12 @@ export function createScanner(
position++;
tokenFlags |= TokenFlags.Escaped;
continue;
case CharCode.$:
if (lookAhead(1) === CharCode.OpenBrace) {
position += 2;
return (token = Token.StringTemplateHead);
}
continue;
case CharCode.Backtick:
position++;
return (token = Token.Identifier);
Expand Down
5 changes: 5 additions & 0 deletions packages/compiler/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,11 @@ export interface ImportStatementNode extends BaseNode {
export interface IdentifierNode extends BaseNode {
readonly kind: SyntaxKind.Identifier;
readonly sv: string;
/**
* Present when this identifier was declared using backticked interpolation syntax
* (e.g. `My${name}`).
*/
readonly interpolation?: StringTemplateExpressionNode;
}

export interface DecoratorExpressionNode extends BaseNode {
Expand Down
12 changes: 12 additions & 0 deletions packages/compiler/test/checker/alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ describe("compiler: aliases", () => {
strictEqual(Baz.properties.get("x")!.type, Bar);
});

it("disallows interpolated alias declaration names", async () => {
testHost.addTypeSpecFile(
"main.tsp",
`
alias \`Alias\${"A"}\` = string;
`,
);

const diagnostics = await testHost.diagnose("./");
expectDiagnostics(diagnostics, [{ code: "invalid-interpolated-identifier-context" }]);
});

it("model expression defined in alias use containing namespace", async () => {
testHost.addTypeSpecFile(
"main.tsp",
Expand Down
18 changes: 18 additions & 0 deletions packages/compiler/test/checker/enum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,24 @@ describe("compiler: enums", () => {
ok(!E.members.get("C")!.value);
});

it("supports interpolated enum and enum member names", async () => {
testHost.addTypeSpecFile(
"main.tsp",
`
const suffix = "A";
@test enum \`E\${suffix}\` {
\`M\${suffix}\`
}
`,
);

const { EA } = (await testHost.compile("./")) as {
EA: Enum;
};

ok(EA.members.get("MA"));
});

it("can have values", async () => {
testHost.addTypeSpecFile(
"main.tsp",
Expand Down
Loading
Loading