Skip to content

Commit

Permalink
Feature: String templates (#2630)
Browse files Browse the repository at this point in the history
fix #1591


## String templates
[Playground
examples](https://cadlplayground.z22.web.core.windows.net/prs/2630/?c=aW1wb3J0ICJAdHlwZXNwZWMvanNvbi1zY2hlbWEiOwoKdXNpbmcgVHlwZVNwZWMuSnNvblPFHTsKCkDELMYOCm5hbWVzcGFjZSDGEXM7CgphbGlhcyBteWNvbnN0ID0gImZvb2JhcsRXbW9kZWwgUGVyc29uIHsKICBzaW1wbGU6ICJTxQkgJHsxMjN9IGVuZCI7CiAgbXVsdGlsaW7EIiIiCiAgTcQRIAogxAHHLMUNJHt0cnVlfQogIMQuCiDELzsKCiAgcmVmOiAiUmVmIHRoaXMg5gCcJHvnAJ7KanRlbXBsYXRlOiBUxwo8ImN1c3RvbSI%2BOwp96ADWyR1UIGV4dGVuZHMgdmFsdWVvZiBzdHJpbmc%2B5ADxRm9vICR7VH0g5QD3&e=%40typespec%2Fjson-schema&options=%7B%7D)

```
import "@typespec/json-schema";

using TypeSpec.JsonSchema;

@jsonschema
namespace Schemas;

alias myconst = "foobar";

model Person {
  simple: "Simple ${123} end";
  multiline: """
  Multi 
     ${123} 
    ${true}
  line
  """;

  ref: "Ref this alias ${myconst} end";
  template: Template<"custom">;
}

alias Template<T extends valueof string> = "Foo ${T} bar";
```

## Other fixes
Also fixes https://github.com/Azure/typespec-azure/issues/3399(Show
invalid escape sequence char instead of the whole string)
<img width="561" alt="image"
src="https://github.com/microsoft/typespec/assets/1031227/7592a046-2c2c-4597-acfd-e45ebfb02cb7">

---------

Co-authored-by: Brian Terlson <[email protected]>
Co-authored-by: Mark Cowlishaw <[email protected]>
  • Loading branch information
3 people authored Dec 1, 2023
1 parent 4e63cab commit 360add2
Show file tree
Hide file tree
Showing 40 changed files with 1,777 additions and 101 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@typespec/compiler",
"comment": "**New language feature** **BREAKING** Added string template literal in typespec. Single and multi-line strings can be interpolated with `${` and `}`. Example `\\`Doc for url ${url} is here: ${location}\\``",
"type": "none"
}
],
"packageName": "@typespec/compiler"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@typespec/json-schema",
"comment": "Added support for string template literals",
"type": "none"
}
],
"packageName": "@typespec/json-schema"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@typespec/openapi3",
"comment": "Added support for string template literals",
"type": "none"
}
],
"packageName": "@typespec/openapi3"
}
33 changes: 33 additions & 0 deletions docs/extending-typespec/create-decorators.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ For certain TypeSpec types(Literal types) the decorator do not receive the actua

for all the other types they are not transformed.

Example:

```ts
export function $tag(
context: DecoratorContext,
Expand All @@ -133,6 +135,37 @@ export function $tag(
) {}
```

#### String templates and marshalling

If a decorator parameter type is `valueof string`, a string template passed to it will also be marshalled as a string.
The TypeSpec type system will already validate the string template can be serialized as a string.

```tsp
extern dec doc(target: unknown, name: valueof string);
alias world = "world!";
@doc("Hello ${world} ") // receive: "Hello world!"
@doc("Hello ${123} ") // receive: "Hello 123"
@doc("Hello ${true} ") // receive: "Hello true"
model Bar {}
@doc("Hello ${Bar} ") // not called error
^ String template cannot be serialized as a string.
```

#### Typescript type Reference

| TypeSpec Parameter Type | TypeScript types |
| ---------------------------- | -------------------------------------------- |
| `valueof string` | `string` |
| `valueof numeric` | `number` |
| `valueof boolean` | `boolean` |
| `string` | `StringLiteral \| TemplateLiteral \| Scalar` |
| `Reflection.StringLiteral` | `StringLiteral` |
| `Reflection.TemplateLiteral` | `TemplateLiteral` |

### Adding metadata with decorators

Decorators can be used to register some metadata. For this you can use the `context.program.stateMap` or `context.program.stateSet` to insert data that will be tied to the current execution.
Expand Down
16 changes: 16 additions & 0 deletions docs/language-basics/type-literals.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ two
}
```

## String template literal

Single or multi line string literal can be interpolated using `${}`

```typespec
alias hello = "bonjour";
alias Single = "${hello} world!";
alias Multi = """
${hello}
world!
""";
```

Any valid expression can be used in the interpolation but only other literals will result in the template literal being assignable to a `valueof string`. Any other value will be dependent on the decorator/emitter receiving it to handle.

## Numeric literal

Numeric literals can be declared by using the raw number
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/lib/reflection.tsp
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ model Operation {}
model Scalar {}
model Union {}
model UnionVariant {}
model StringTemplate {}
90 changes: 85 additions & 5 deletions packages/compiler/src/core/checker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { createSymbol, createSymbolTable } from "./binder.js";
import { getDeprecationDetails, markDeprecated } from "./deprecation.js";
import { ProjectionError, compilerAssert, reportDeprecated } from "./diagnostics.js";
import { validateInheritanceDiscriminatedUnions } from "./helpers/discriminator-utils.js";
import { TypeNameOptions, getNamespaceFullName, getTypeName } from "./helpers/index.js";
import {
TypeNameOptions,
getNamespaceFullName,
getTypeName,
stringTemplateToString,
} from "./helpers/index.js";
import { isStringTemplateSerializable } from "./helpers/string-template-utils.js";
import { createDiagnostic } from "./messages.js";
import { getIdentifierContext, hasParseError, visitChildren } from "./parser.js";
import { Program, ProjectedProgram } from "./program.js";
Expand Down Expand Up @@ -102,6 +108,14 @@ import {
StdTypes,
StringLiteral,
StringLiteralNode,
StringTemplate,
StringTemplateExpressionNode,
StringTemplateHeadNode,
StringTemplateMiddleNode,
StringTemplateSpan,
StringTemplateSpanLiteral,
StringTemplateSpanValue,
StringTemplateTailNode,
Sym,
SymbolFlags,
SymbolLinks,
Expand Down Expand Up @@ -641,6 +655,8 @@ export function createChecker(program: Program): Checker {
return checkTupleExpression(node, mapper);
case SyntaxKind.StringLiteral:
return checkStringLiteral(node);
case SyntaxKind.StringTemplateExpression:
return checkStringTemplateExpresion(node, mapper);
case SyntaxKind.ArrayExpression:
return checkArrayExpression(node, mapper);
case SyntaxKind.UnionExpression:
Expand Down Expand Up @@ -2382,6 +2398,48 @@ export function createChecker(program: Program): Checker {
return getMergedSymbol(aliasType.node!.symbol) ?? aliasSymbol;
}
}

function checkStringTemplateExpresion(
node: StringTemplateExpressionNode,
mapper: TypeMapper | undefined
): StringTemplate {
const spans: StringTemplateSpan[] = [createTemplateSpanLiteral(node.head)];
for (const span of node.spans) {
spans.push(createTemplateSpanValue(span.expression, mapper));
spans.push(createTemplateSpanLiteral(span.literal));
}
const type = createType({
kind: "StringTemplate",
node,
spans,
});

return type;
}

function createTemplateSpanLiteral(
node: StringTemplateHeadNode | StringTemplateMiddleNode | StringTemplateTailNode
): StringTemplateSpanLiteral {
return createType({
kind: "StringTemplateSpan",
node: node,
isInterpolated: false,
type: getLiteralType(node),
});
}

function createTemplateSpanValue(
node: Expression,
mapper: TypeMapper | undefined
): StringTemplateSpanValue {
return createType({
kind: "StringTemplateSpan",
node: node,
isInterpolated: true,
type: getTypeForNode(node, mapper),
});
}

function checkStringLiteral(str: StringLiteralNode): StringLiteral {
return getLiteralType(str);
}
Expand Down Expand Up @@ -3243,6 +3301,10 @@ export function createChecker(program: Program): Checker {
if (type === nullType) {
return true;
}
if (type.kind === "StringTemplate") {
const [valid] = isStringTemplateSerializable(type);
return valid;
}
const valueTypes = new Set(["String", "Number", "Boolean", "EnumMember", "Tuple"]);
return valueTypes.has(type.kind);
}
Expand Down Expand Up @@ -3424,6 +3486,8 @@ export function createChecker(program: Program): Checker {
if (valueOf) {
if (value.kind === "Boolean" || value.kind === "String" || value.kind === "Number") {
return literalTypeToValue(value);
} else if (value.kind === "StringTemplate") {
return stringTemplateToString(value)[0];
}
}
return value;
Expand Down Expand Up @@ -4058,7 +4122,13 @@ export function createChecker(program: Program): Checker {
return finishTypeForProgramAndChecker(program, typePrototype, typeDef);
}

function getLiteralType(node: StringLiteralNode): StringLiteral;
function getLiteralType(
node:
| StringLiteralNode
| StringTemplateHeadNode
| StringTemplateMiddleNode
| StringTemplateTailNode
): StringLiteral;
function getLiteralType(node: NumericLiteralNode): NumericLiteral;
function getLiteralType(node: BooleanLiteralNode): BooleanLiteral;
function getLiteralType(node: LiteralNode): LiteralType;
Expand Down Expand Up @@ -4870,16 +4940,23 @@ export function createChecker(program: Program): Checker {
} as const);
}

function createLiteralType(value: string, node?: StringLiteralNode): StringLiteral;
function createLiteralType(
value: string,
node?:
| StringLiteralNode
| StringTemplateHeadNode
| StringTemplateMiddleNode
| StringTemplateTailNode
): StringLiteral;
function createLiteralType(value: number, node?: NumericLiteralNode): NumericLiteral;
function createLiteralType(value: boolean, node?: BooleanLiteralNode): BooleanLiteral;
function createLiteralType(
value: string | number | boolean,
node?: StringLiteralNode | NumericLiteralNode | BooleanLiteralNode
node?: LiteralNode
): StringLiteral | NumericLiteral | BooleanLiteral;
function createLiteralType(
value: string | number | boolean,
node?: StringLiteralNode | NumericLiteralNode | BooleanLiteralNode
node?: LiteralNode
): StringLiteral | NumericLiteral | BooleanLiteral {
if (program.literalTypes.has(value)) {
return program.literalTypes.get(value)!;
Expand Down Expand Up @@ -5267,6 +5344,7 @@ export function createChecker(program: Program): Checker {
case "Number":
return isNumericLiteralRelatedTo(source, target);
case "String":
case "StringTemplate":
return areScalarsRelated(target, getStdType("string"));
case "Boolean":
return areScalarsRelated(target, getStdType("boolean"));
Expand Down Expand Up @@ -6043,6 +6121,8 @@ function marshalArgumentsForJS<T extends Type>(args: T[]): MarshalledValue<T>[]
return args.map((arg) => {
if (arg.kind === "Boolean" || arg.kind === "String" || arg.kind === "Number") {
return literalTypeToValue(arg);
} else if (arg.kind === "StringTemplate") {
return stringTemplateToString(arg)[0];
}
return arg as any;
});
Expand Down
1 change: 1 addition & 0 deletions packages/compiler/src/core/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export { getLocationContext } from "./location-context.js";
export * from "./operation-utils.js";
export * from "./path-interpolation.js";
export * from "./projected-names-utils.js";
export { stringTemplateToString } from "./string-template-utils.js";
export * from "./type-name-utils.js";
export * from "./usage-resolver.js";
74 changes: 74 additions & 0 deletions packages/compiler/src/core/helpers/string-template-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createDiagnosticCollector } from "../diagnostics.js";
import { createDiagnostic } from "../messages.js";
import { Diagnostic, StringTemplate } from "../types.js";
import { getTypeName } from "./type-name-utils.js";

/**
* Convert a string template to a string value.
* Only literal interpolated can be converted to string.
* Otherwise diagnostics will be reported.
*
* @param stringTemplate String template to convert.
*/
export function stringTemplateToString(
stringTemplate: StringTemplate
): [string, readonly Diagnostic[]] {
const diagnostics = createDiagnosticCollector();
const result = stringTemplate.spans
.map((x) => {
if (x.isInterpolated) {
switch (x.type.kind) {
case "String":
case "Number":
case "Boolean":
return String(x.type.value);
case "StringTemplate":
return diagnostics.pipe(stringTemplateToString(x.type));
default:
diagnostics.add(
createDiagnostic({
code: "non-literal-string-template",
target: x.node,
})
);
return getTypeName(x.type);
}
} else {
return x.type.value;
}
})
.join("");
return diagnostics.wrap(result);
}

export function isStringTemplateSerializable(
stringTemplate: StringTemplate
): [boolean, readonly Diagnostic[]] {
const diagnostics = createDiagnosticCollector();
for (const span of stringTemplate.spans) {
if (span.isInterpolated) {
switch (span.type.kind) {
case "String":
case "Number":
case "Boolean":
break;
case "StringTemplate":
diagnostics.pipe(isStringTemplateSerializable(span.type));
break;
case "TemplateParameter":
if (span.type.constraint && span.type.constraint.kind === "Value") {
break; // Value types will be serializable in the template instance.
}
// eslint-disable-next-line no-fallthrough
default:
diagnostics.add(
createDiagnostic({
code: "non-literal-string-template",
target: span.node,
})
);
}
}
}
return [diagnostics.diagnostics.length === 0, diagnostics.diagnostics];
}
2 changes: 2 additions & 0 deletions packages/compiler/src/core/helpers/type-name-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ export function getTypeName(type: Type | ValueType, options?: TypeNameOptions):
return getTypeName(type.type, options);
case "Tuple":
return "[" + type.values.map((x) => getTypeName(x, options)).join(", ") + "]";
case "StringTemplate":
return "string";
case "String":
case "Number":
case "Boolean":
Expand Down
7 changes: 7 additions & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,13 @@ const diagnostics = {
"Projections are experimental - your code will need to change as this feature evolves.",
},
},
"non-literal-string-template": {
severity: "error",
messages: {
default:
"Value interpolated in this string template cannot be converted to a string. Only literal types can be automatically interpolated.",
},
},

/**
* Binder
Expand Down
Loading

0 comments on commit 360add2

Please sign in to comment.