diff --git a/.chronus/changes/decl-expr-declaration-expressions-2026-4-18-0-0-0.md b/.chronus/changes/decl-expr-declaration-expressions-2026-4-18-0-0-0.md new file mode 100644 index 00000000000..3594f384bfa --- /dev/null +++ b/.chronus/changes/decl-expr-declaration-expressions-2026-4-18-0-0-0.md @@ -0,0 +1,29 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Allow `model`, `enum`, `union`, and `scalar` declarations to be used as expressions. A declaration used in expression position has its corresponding type marked with `expression: true` and is not registered in the enclosing namespace. It may be named or anonymous (in which case it has no name). + +This is an experimental feature that must be opted into by adding `declaration-expressions` to the `features` list in `tspconfig.yaml`; using a declaration expression without it reports a `declaration-expression-disabled` error. + +They can be used anywhere an expression is expected, including aliases, model properties, decorator arguments, template arguments, function/call arguments, and tuples. + +`model`, `scalar`, and `union` declaration expressions support the same `extends` clause as their statement form (union `extends` additionally requires the `union-extends` feature). + +```tsp +alias Foo = enum { + a, + b, +}; + +model Bar { + status: enum { active, inactive }; + unit: scalar extends string; + inner: model Inner { x: string }; +} + +@Versioning.versioned(enum Versions { v1, v2 }) +namespace MyService; +``` diff --git a/.chronus/changes/decl-expr-decorators-2026-4-18-0-0-2.md b/.chronus/changes/decl-expr-decorators-2026-4-18-0-0-2.md new file mode 100644 index 00000000000..5a9ad1f7fb9 --- /dev/null +++ b/.chronus/changes/decl-expr-decorators-2026-4-18-0-0-2.md @@ -0,0 +1,16 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Allow decorators to be applied to `model`, `enum`, `union`, and `scalar` declarations used in expression position. Inline decorators can be applied directly, and augment decorators (`@@`) can target them through a navigation reference (such as `::type`). + +```tsp +model Foo { + status: @doc("the current status") enum { active, inactive }; + inner: @doc("nested model") model Inner { x: string }; +} + +@@doc(Foo.status::type, "the current status"); +``` diff --git a/.chronus/changes/decl-expr-doc-comments-2026-4-18-0-0-3.md b/.chronus/changes/decl-expr-doc-comments-2026-4-18-0-0-3.md new file mode 100644 index 00000000000..10889dec056 --- /dev/null +++ b/.chronus/changes/decl-expr-doc-comments-2026-4-18-0-0-3.md @@ -0,0 +1,13 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Allow a doc comment to be applied inline to a `model`, `enum`, `union`, or `scalar` declaration expression, just like an inline `@doc` decorator. + +```tsp +model Foo { + status: /** the current status */ enum { active, inactive }; +} +``` diff --git a/.chronus/changes/decl-expr-formatting-2026-4-18-0-0-4.md b/.chronus/changes/decl-expr-formatting-2026-4-18-0-0-4.md new file mode 100644 index 00000000000..f6745b8413f --- /dev/null +++ b/.chronus/changes/decl-expr-formatting-2026-4-18-0-0-4.md @@ -0,0 +1,19 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Improve formatting of declaration expressions (`model`, `enum`, `union`, and `scalar` used in expression position) that carry doc comments and/or decorators. When the inline form would exceed the print width, the doc comments and decorators are now each placed on their own line and the whole block is indented one level instead of overflowing. + +```tsp +model Foo { + status: + @summary("a fairly long summary text here") + @example("some-default-example-value") + enum { + active, + inactive, + }; +} +``` diff --git a/.chronus/changes/decl-expr-json-schema-inline-2026-4-18-0-0-1.md b/.chronus/changes/decl-expr-json-schema-inline-2026-4-18-0-0-1.md new file mode 100644 index 00000000000..7b19a95904d --- /dev/null +++ b/.chronus/changes/decl-expr-json-schema-inline-2026-4-18-0-0-1.md @@ -0,0 +1,15 @@ +--- +changeKind: feature +packages: + - "@typespec/json-schema" +--- + +Support `model`, `enum`, `union`, and `scalar` declarations used in expression position. Anonymous declaration expressions are inlined, while named ones are hoisted into their own schema. + +```tsp +model Foo { + status: enum { active, inactive }; // inlined + unit: scalar extends string; // inlined + inner: model Inner { x: string }; // hoisted as `Inner.json` +} +``` diff --git a/.chronus/changes/decl-expr-openapi-inline-2026-4-18-0-0-1.md b/.chronus/changes/decl-expr-openapi-inline-2026-4-18-0-0-1.md new file mode 100644 index 00000000000..0f419aed19f --- /dev/null +++ b/.chronus/changes/decl-expr-openapi-inline-2026-4-18-0-0-1.md @@ -0,0 +1,16 @@ +--- +changeKind: feature +packages: + - "@typespec/openapi" + - "@typespec/openapi3" +--- + +Support `model`, `enum`, `union`, and `scalar` declarations used in expression position. Anonymous declaration expressions are inlined, while named ones are hoisted into a referenced component. + +```tsp +model Foo { + status: enum { active, inactive }; // inlined + unit: scalar extends string; // inlined + inner: model Inner { x: string }; // hoisted as component `Inner` +} +``` diff --git a/.chronus/changes/decl-expr-typekit-enum-2026-4-18-0-0-1.md b/.chronus/changes/decl-expr-typekit-enum-2026-4-18-0-0-1.md new file mode 100644 index 00000000000..de25cc6b0b8 --- /dev/null +++ b/.chronus/changes/decl-expr-typekit-enum-2026-4-18-0-0-1.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/compiler" +--- + +`$.enum.create` now produces an enum expression (`expression: true`) when given an empty `name`, mirroring `$.model.create`. diff --git a/.chronus/changes/decl-expr-versioning-validation-2026-4-18-0-0-1.md b/.chronus/changes/decl-expr-versioning-validation-2026-4-18-0-0-1.md new file mode 100644 index 00000000000..cc0429ffaf0 --- /dev/null +++ b/.chronus/changes/decl-expr-versioning-validation-2026-4-18-0-0-1.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/versioning" +--- + +Validate the variants of a keyword-form union expression (`union { ... }`) used in expression position like the variants of a named union, so versioning incompatibilities on decorated variants are reported. diff --git a/.chronus/changes/graphql-scalar-expression-field-2026-7-30-10-49-0.md b/.chronus/changes/graphql-scalar-expression-field-2026-7-30-10-49-0.md new file mode 100644 index 00000000000..e03d967e516 --- /dev/null +++ b/.chronus/changes/graphql-scalar-expression-field-2026-7-30-10-49-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/graphql" +--- + +Set the required `expression` field to `false` when creating a replacement scalar so scalars produced by the model mutation engine are valid declaration types. diff --git a/.chronus/changes/html-program-viewer-expression-2026-4-18-0-0-0.md b/.chronus/changes/html-program-viewer-expression-2026-4-18-0-0-0.md new file mode 100644 index 00000000000..a8ec73f3112 --- /dev/null +++ b/.chronus/changes/html-program-viewer-expression-2026-4-18-0-0-0.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/html-program-viewer" +--- + +Display the new `expression` property on `Model`, `Enum`, and `Scalar` types in the program viewer. diff --git a/grammars/typespec.json b/grammars/typespec.json index 1c9c41f6bdf..1ea96e4b33e 100644 --- a/grammars/typespec.json +++ b/grammars/typespec.json @@ -19,7 +19,7 @@ "name": "keyword.operator.assignment.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -40,7 +40,7 @@ "name": "entity.name.type.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#alias-id" @@ -61,7 +61,7 @@ "name": "entity.name.tag.tsp" } }, - "end": "(?=([_$[:alpha:]]|`))|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=([_$[:alpha:]]|`))|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -123,7 +123,7 @@ "name": "variable.name.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#type-annotation" @@ -147,7 +147,7 @@ "name": "entity.name.tag.tsp" } }, - "end": "(?=([_$[:alpha:]]|`))|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=([_$[:alpha:]]|`))|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -174,7 +174,7 @@ "name": "entity.name.function.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -195,7 +195,7 @@ "name": "keyword.directive.name.tsp" } }, - "end": "$|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "$|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#string-literal" @@ -309,6 +309,27 @@ } ] }, + "enum-expression": { + "name": "meta.enum-expression.typespec", + "begin": "\\b(enum)\\b(?:\\s+(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`))?", + "beginCaptures": { + "1": { + "name": "keyword.other.tsp" + }, + "2": { + "name": "entity.name.type.tsp" + } + }, + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", + "patterns": [ + { + "include": "#token" + }, + { + "include": "#enum-body" + } + ] + }, "enum-member": { "name": "meta.enum-member.typespec", "begin": "(?:(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`)\\s*(:?))", @@ -320,7 +341,7 @@ "name": "keyword.operator.type.annotation.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -344,7 +365,7 @@ "name": "entity.name.type.tsp" } }, - "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -387,6 +408,18 @@ { "include": "#tuple-expression" }, + { + "include": "#model-expression-keyword" + }, + { + "include": "#scalar-expression" + }, + { + "include": "#enum-expression" + }, + { + "include": "#union-expression" + }, { "include": "#model-expression" }, @@ -415,7 +448,7 @@ "name": "entity.name.function.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -440,7 +473,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -487,7 +520,7 @@ "name": "keyword.other.tsp" } }, - "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b))", + "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b))", "patterns": [ { "include": "#expression" @@ -508,7 +541,7 @@ "name": "entity.name.function.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -529,7 +562,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -587,6 +620,33 @@ } ] }, + "model-expression-keyword": { + "name": "meta.model-expression-keyword.typespec", + "begin": "\\b(model)\\b(?:\\s+(?!extends\\b|is\\b)(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`))?", + "beginCaptures": { + "1": { + "name": "keyword.other.tsp" + }, + "2": { + "name": "entity.name.type.tsp" + } + }, + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", + "patterns": [ + { + "include": "#token" + }, + { + "include": "#type-parameters" + }, + { + "include": "#model-heritage" + }, + { + "include": "#expression" + } + ] + }, "model-heritage": { "name": "meta.model-heritage.typespec", "begin": "\\b(extends|is)\\b", @@ -595,7 +655,7 @@ "name": "keyword.other.tsp" } }, - "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b))", + "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b))", "patterns": [ { "include": "#expression" @@ -616,7 +676,7 @@ "name": "string.quoted.double.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -643,7 +703,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -682,7 +742,7 @@ "namespace-name": { "name": "meta.namespace-name.typespec", "begin": "(?=([_$[:alpha:]]|`))", - "end": "((?=\\{)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b))", + "end": "((?=\\{)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b))", "patterns": [ { "include": "#identifier-expression" @@ -700,7 +760,7 @@ "name": "keyword.other.tsp" } }, - "end": "((?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b))", + "end": "((?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b))", "patterns": [ { "include": "#token" @@ -760,7 +820,7 @@ "name": "keyword.operator.type.annotation.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -778,7 +838,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -847,7 +907,7 @@ "name": "entity.name.function.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -936,7 +996,7 @@ "name": "entity.name.function.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -946,6 +1006,33 @@ } ] }, + "scalar-expression": { + "name": "meta.scalar-expression.typespec", + "begin": "\\b(scalar)\\b(?:\\s+(?!extends\\b)(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`))?", + "beginCaptures": { + "1": { + "name": "keyword.other.tsp" + }, + "2": { + "name": "entity.name.type.tsp" + } + }, + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", + "patterns": [ + { + "include": "#token" + }, + { + "include": "#type-parameters" + }, + { + "include": "#scalar-extends" + }, + { + "include": "#scalar-body" + } + ] + }, "scalar-extends": { "name": "meta.scalar-extends.typespec", "begin": "\\b(extends)\\b", @@ -954,7 +1041,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -978,7 +1065,7 @@ "name": "entity.name.type.tsp" } }, - "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -1002,7 +1089,7 @@ "name": "keyword.operator.spread.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -1192,7 +1279,7 @@ "name": "keyword.operator.type.annotation.tsp" } }, - "end": "(?=,|;|@|\\)|\\}|=|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|\\)|\\}|=|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -1210,7 +1297,7 @@ "name": "keyword.operator.assignment.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "endCaptures": { "0": { "name": "keyword.operator.assignment.tsp" @@ -1262,7 +1349,7 @@ "name": "entity.name.type.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -1283,7 +1370,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -1298,7 +1385,7 @@ "name": "keyword.operator.assignment.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -1336,7 +1423,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" @@ -1378,6 +1465,33 @@ } ] }, + "union-expression": { + "name": "meta.union-expression.typespec", + "begin": "\\b(union)\\b(?:\\s+(?!extends\\b)(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`))?", + "beginCaptures": { + "1": { + "name": "keyword.other.tsp" + }, + "2": { + "name": "entity.name.type.tsp" + } + }, + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", + "patterns": [ + { + "include": "#token" + }, + { + "include": "#type-parameters" + }, + { + "include": "#union-extends" + }, + { + "include": "#union-body" + } + ] + }, "union-extends": { "name": "meta.union-extends.typespec", "begin": "\\b(extends)\\b", @@ -1386,7 +1500,7 @@ "name": "keyword.other.tsp" } }, - "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b))", + "end": "((?=\\{)|(?=;|@|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b))", "patterns": [ { "include": "#expression" @@ -1410,7 +1524,7 @@ "name": "entity.name.type.tsp" } }, - "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?<=\\})|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -1437,7 +1551,7 @@ "name": "keyword.operator.type.annotation.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -1455,7 +1569,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#token" @@ -1476,7 +1590,7 @@ "name": "keyword.other.tsp" } }, - "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b)", + "end": "(?=>)|(?=,|;|@|#[a-z]|\\)|\\}|\\b(?:extern|internal)\\b|\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b)", "patterns": [ { "include": "#expression" diff --git a/packages/compiler/src/core/binder.ts b/packages/compiler/src/core/binder.ts index 5d4930d84d7..68a74d5479f 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -10,6 +10,7 @@ import type { Declaration, DecoratorDeclarationStatementNode, DecoratorImplementations, + EnumDeclarationExpressionNode, EnumMemberNode, EnumStatementNode, FileLibraryMetadata, @@ -20,6 +21,7 @@ import type { IntersectionExpressionNode, JsNamespaceDeclarationNode, JsSourceFileNode, + ModelDeclarationExpressionNode, ModelExpressionNode, ModelPropertyNode, ModelStatementNode, @@ -28,12 +30,14 @@ import type { Node, OperationStatementNode, ScalarConstructorNode, + ScalarDeclarationExpressionNode, ScalarStatementNode, ScopeNode, Sym, SymbolTable, TemplateParameterDeclarationNode, TypeSpecScriptNode, + UnionDeclarationExpressionNode, UnionStatementNode, UnionVariantNode, UsingStatementNode, @@ -312,6 +316,9 @@ export function createBinder(program: Program): Binder { case SyntaxKind.ModelStatement: bindModelStatement(node); break; + case SyntaxKind.ModelDeclarationExpression: + bindModelDeclarationExpression(node); + break; case SyntaxKind.ModelExpression: bindModelExpression(node); break; @@ -324,6 +331,9 @@ export function createBinder(program: Program): Binder { case SyntaxKind.ScalarStatement: bindScalarStatement(node); break; + case SyntaxKind.ScalarDeclarationExpression: + bindScalarDeclarationExpression(node); + break; case SyntaxKind.ScalarConstructor: bindScalarConstructor(node); break; @@ -333,6 +343,9 @@ export function createBinder(program: Program): Binder { case SyntaxKind.UnionStatement: bindUnionStatement(node); break; + case SyntaxKind.UnionDeclarationExpression: + bindUnionDeclarationExpression(node); + break; case SyntaxKind.AliasStatement: bindAliasStatement(node); break; @@ -342,6 +355,9 @@ export function createBinder(program: Program): Binder { case SyntaxKind.EnumStatement: bindEnumStatement(node); break; + case SyntaxKind.EnumDeclarationExpression: + bindEnumDeclarationExpression(node); + break; case SyntaxKind.EnumMember: bindEnumMember(node); break; @@ -411,6 +427,12 @@ export function createBinder(program: Program): Binder { mutate(node).locals = new SymbolTable(); } + function bindModelDeclarationExpression(node: ModelDeclarationExpressionNode) { + bindSymbol(node, SymbolFlags.Model); + // Initialize locals for type parameters + mutate(node).locals = new SymbolTable(); + } + function bindModelExpression(node: ModelExpressionNode) { bindSymbol(node, SymbolFlags.Model); } @@ -431,6 +453,12 @@ export function createBinder(program: Program): Binder { mutate(node).locals = new SymbolTable(); } + function bindScalarDeclarationExpression(node: ScalarDeclarationExpressionNode) { + bindSymbol(node, SymbolFlags.Scalar); + // Initialize locals for type parameters + mutate(node).locals = new SymbolTable(); + } + function bindScalarConstructor(node: ScalarConstructorNode) { declareMember(node, SymbolFlags.Member, node.id.sv); } @@ -449,6 +477,11 @@ export function createBinder(program: Program): Binder { mutate(node).locals = new SymbolTable(); } + function bindUnionDeclarationExpression(node: UnionDeclarationExpressionNode) { + bindSymbol(node, SymbolFlags.Union); + mutate(node).locals = new SymbolTable(); + } + function bindAliasStatement(node: AliasStatementNode) { const internal = node.modifierFlags & ModifierFlags.Internal ? SymbolFlags.Internal : SymbolFlags.None; @@ -468,6 +501,10 @@ export function createBinder(program: Program): Binder { declareSymbol(node, SymbolFlags.Enum | SymbolFlags.Declaration | internal); } + function bindEnumDeclarationExpression(node: EnumDeclarationExpressionNode) { + bindSymbol(node, SymbolFlags.Enum); + } + function bindEnumMember(node: EnumMemberNode) { declareMember(node, SymbolFlags.Member, node.id.sv); } @@ -664,15 +701,19 @@ export function createBinder(program: Program): Binder { function hasScope(node: Node): node is ScopeNode { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ModelExpression: case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: case SyntaxKind.ConstStatement: case SyntaxKind.AliasStatement: case SyntaxKind.TypeSpecScript: case SyntaxKind.InterfaceStatement: case SyntaxKind.OperationStatement: case SyntaxKind.UnionStatement: + case SyntaxKind.UnionDeclarationExpression: case SyntaxKind.EnumStatement: + case SyntaxKind.EnumDeclarationExpression: return true; case SyntaxKind.NamespaceStatement: return node.statements !== undefined; diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 11f8e422b12..dd1badc9c9b 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -77,6 +77,7 @@ import type { DocContent, Entity, Enum, + EnumDeclarationExpressionNode, EnumMember, EnumMemberNode, EnumStatementNode, @@ -109,6 +110,7 @@ import type { MixedFunctionParameter, MixedParameterConstraint, Model, + ModelDeclarationExpressionNode, ModelExpressionNode, ModelIndexer, ModelProperty, @@ -132,6 +134,7 @@ import type { Scalar, ScalarConstructor, ScalarConstructorNode, + ScalarDeclarationExpressionNode, ScalarStatementNode, ScalarValue, SignatureFunctionParameter, @@ -168,6 +171,7 @@ import type { TypeReferenceNode, TypeSpecScriptNode, Union, + UnionDeclarationExpressionNode, UnionExpressionNode, UnionStatementNode, UnionVariant, @@ -1025,20 +1029,28 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return checkModel(ctx, node); case SyntaxKind.ModelStatement: return checkModel(ctx, node); + case SyntaxKind.ModelDeclarationExpression: + return checkModel(ctx, node); case SyntaxKind.ModelProperty: return checkModelProperty(ctx, node); case SyntaxKind.ScalarStatement: return checkScalar(ctx, node); + case SyntaxKind.ScalarDeclarationExpression: + return checkScalar(ctx, node); case SyntaxKind.AliasStatement: return checkAlias(ctx, node); case SyntaxKind.EnumStatement: return checkEnum(ctx, node); + case SyntaxKind.EnumDeclarationExpression: + return checkEnum(ctx, node); case SyntaxKind.EnumMember: return checkEnumMember(ctx, node); case SyntaxKind.InterfaceStatement: return checkInterface(ctx, node); case SyntaxKind.UnionStatement: return checkUnion(ctx, node); + case SyntaxKind.UnionDeclarationExpression: + return checkUnion(ctx, node); case SyntaxKind.UnionVariant: return checkUnionVariant(ctx, node); case SyntaxKind.NamespaceStatement: @@ -1099,6 +1111,35 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } + function reportDeclarationExpressionFeature( + node: + | ModelStatementNode + | ModelDeclarationExpressionNode + | ScalarStatementNode + | ScalarDeclarationExpressionNode + | EnumStatementNode + | EnumDeclarationExpressionNode + | UnionStatementNode + | UnionDeclarationExpressionNode, + ) { + const isExpression = + node.kind === SyntaxKind.ModelDeclarationExpression || + node.kind === SyntaxKind.ScalarDeclarationExpression || + node.kind === SyntaxKind.EnumDeclarationExpression || + node.kind === SyntaxKind.UnionDeclarationExpression; + if (!isExpression) { + return; + } + if (!isCompilerFeatureEnabled(program, "declaration-expressions", node)) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "declaration-expression-disabled", + target: node, + }), + ); + } + } + /** * Return a fully qualified id of node */ @@ -1106,13 +1147,16 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node: | ModelStatementNode | ModelExpressionNode + | ModelDeclarationExpressionNode | ScalarStatementNode + | ScalarDeclarationExpressionNode | AliasStatementNode | ConstStatementNode | InterfaceStatementNode | OperationStatementNode | TemplateParameterDeclarationNode - | UnionStatementNode, + | UnionStatementNode + | UnionDeclarationExpressionNode, ): Sym { const symbol = node.kind === SyntaxKind.OperationStatement && @@ -1174,7 +1218,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker let type: TemplateParameter | undefined = links.declaredType as TemplateParameter; if (type === undefined) { if (grandParentNode) { - if (grandParentNode.locals?.has(node.id.sv)) { + if ("locals" in grandParentNode && grandParentNode.locals?.has(node.id.sv)) { reportCheckerDiagnostic( createDiagnostic({ code: "shadow", @@ -2022,7 +2066,12 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if (type === neverType) { continue; } - if (type.kind === "Union" && type.expression) { + // Flatten nested union expressions (e.g. `(a | b) | c` or an alias to a union + // expression). Only the `|`-operator form is flattened: its variants are + // anonymous (symbol-keyed) and cannot collide. Keyword-form unions used in + // expression position (`union { a, b }`) are also `expression: true` but can have + // named variants, so flattening them would silently drop colliding members. + if (type.kind === "Union" && type.node?.kind === SyntaxKind.UnionExpression) { for (const [name, variant] of type.variants) { unionType.variants.set(name, variant); } @@ -2529,15 +2578,19 @@ export function createChecker(program: Program, resolver: NameResolver): Checker node: | AliasStatementNode | ModelStatementNode + | ModelDeclarationExpressionNode | ScalarStatementNode + | ScalarDeclarationExpressionNode | NamespaceStatementNode | JsNamespaceDeclarationNode | UnionExpressionNode | OperationStatementNode | EnumStatementNode + | EnumDeclarationExpressionNode | InterfaceStatementNode | IntersectionExpressionNode | UnionStatementNode + | UnionDeclarationExpressionNode | ModelExpressionNode | DecoratorDeclarationStatementNode | FunctionDeclarationStatementNode, @@ -2547,7 +2600,11 @@ export function createChecker(program: Program, resolver: NameResolver): Checker if ( node.kind === SyntaxKind.ModelExpression || node.kind === SyntaxKind.IntersectionExpression || - node.kind === SyntaxKind.UnionExpression + node.kind === SyntaxKind.UnionExpression || + node.kind === SyntaxKind.ModelDeclarationExpression || + node.kind === SyntaxKind.EnumDeclarationExpression || + node.kind === SyntaxKind.UnionDeclarationExpression || + node.kind === SyntaxKind.ScalarDeclarationExpression ) { let parent: Node | undefined = node.parent; while (parent !== undefined) { @@ -4496,15 +4553,18 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function getMemberKindName(node: Node) { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ModelExpression: return "Model"; case SyntaxKind.ModelProperty: return "ModelProperty"; case SyntaxKind.EnumStatement: + case SyntaxKind.EnumDeclarationExpression: return "Enum"; case SyntaxKind.InterfaceStatement: return "Interface"; case SyntaxKind.UnionStatement: + case SyntaxKind.UnionDeclarationExpression: return "Union"; default: return "Type"; @@ -5031,15 +5091,48 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } - function checkModel(ctx: CheckContext, node: ModelExpressionNode | ModelStatementNode): Model { - if (node.kind === SyntaxKind.ModelStatement) { - return checkModelStatement(ctx, node); - } else { + function checkModel( + ctx: CheckContext, + node: ModelExpressionNode | ModelStatementNode | ModelDeclarationExpressionNode, + ): Model { + if (node.kind === SyntaxKind.ModelExpression) { return checkModelExpression(ctx, node); + } else { + return checkModelStatement(ctx, node); } } - function checkModelStatement(ctx: CheckContext, node: ModelStatementNode): Model { + /** + * A declaration used in expression position is anonymous and cannot be referenced or + * instantiated, so template parameters on it are meaningless. Report a diagnostic when present. + */ + function checkExpressionDeclarationConstraints( + node: + | ModelStatementNode + | ModelDeclarationExpressionNode + | UnionStatementNode + | UnionDeclarationExpressionNode + | ScalarStatementNode + | ScalarDeclarationExpressionNode, + ): void { + const isExpression = + node.kind === SyntaxKind.ModelDeclarationExpression || + node.kind === SyntaxKind.UnionDeclarationExpression || + node.kind === SyntaxKind.ScalarDeclarationExpression; + if (isExpression && node.templateParameters.length > 0) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "templated-declaration-in-expression", + target: node.templateParameters[0], + }), + ); + } + } + + function checkModelStatement( + ctx: CheckContext, + node: ModelStatementNode | ModelDeclarationExpressionNode, + ): Model { const links = getSymbolLinks(node.symbol); if (ctx.mapper === undefined && node.templateParameters.length > 0) { @@ -5054,17 +5147,22 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); + checkExpressionDeclarationConstraints(node); + if (ctx.mapper === undefined) { + reportDeclarationExpressionFeature(node); + } const decorators: DecoratorApplication[] = []; const type: Model = createType({ kind: "Model", - name: node.id.sv, + name: node.id?.sv ?? "", node: node, properties: createRekeyableMap(), namespace: getParentNamespaceType(node), decorators, sourceModels: [], derivedModels: [], + expression: node.kind === SyntaxKind.ModelDeclarationExpression, }); linkType(ctx, links, type); @@ -5123,7 +5221,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // Hold on to the model type that's being defined so that it // can be referenced - if (ctx.mapper === undefined) { + if (ctx.mapper === undefined && !type.expression) { type.namespace?.models.set(type.name, type); } @@ -5239,7 +5337,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkModelProperties( ctx: CheckContext, - node: ModelExpressionNode | ModelStatementNode, + node: ModelExpressionNode | ModelStatementNode | ModelDeclarationExpressionNode, properties: Map, parentModel: Model, ) { @@ -5323,6 +5421,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators: [], derivedModels: [], sourceModels: [], + expression: true, }); for (const prop of properties.values()) { @@ -6482,7 +6581,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } function checkClassHeritage( ctx: CheckContext, - model: ModelStatementNode, + model: ModelStatementNode | ModelDeclarationExpressionNode, heritageRef: Expression, ): Model | undefined { if (heritageRef.kind === SyntaxKind.ModelExpression) { @@ -6550,7 +6649,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkModelIs( ctx: CheckContext, - model: ModelStatementNode, + model: ModelStatementNode | ModelDeclarationExpressionNode, isExpr: Expression | undefined, ): Model | undefined { if (!isExpr) return undefined; @@ -6612,7 +6711,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker /** Get the type for the spread target */ function checkSpreadTarget( ctx: CheckContext, - model: ModelStatementNode | ModelExpressionNode, + model: ModelStatementNode | ModelExpressionNode | ModelDeclarationExpressionNode, target: TypeReferenceNode, ): Type | undefined { const modelSymId = getNodeSym(model); @@ -7218,7 +7317,8 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } else if ( links.finalSymbol?.flags && ~links.finalSymbol.flags & SymbolFlags.Declaration && - ~links.finalSymbol.flags & SymbolFlags.Member + ~links.finalSymbol.flags & SymbolFlags.Member && + !isDeclarationExpressionSym(links.finalSymbol) ) { program.reportDiagnostic( createDiagnostic({ @@ -7249,6 +7349,24 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return errorType; } + /** + * A declaration expression (e.g. the `enum { a, b }` in `model Foo { x: enum { a, b } }`) + * is bound as a non-declaration symbol but is still a real, referenceable type + * (e.g. via `Foo.x::type`) and therefore a valid augment target. Statement-position + * declarations carry {@link SymbolFlags.Declaration} and never reach this check. + */ + function isDeclarationExpressionSym(sym: Sym): boolean { + switch (getSymNode(sym)?.kind) { + case SyntaxKind.ModelDeclarationExpression: + case SyntaxKind.EnumDeclarationExpression: + case SyntaxKind.UnionDeclarationExpression: + case SyntaxKind.ScalarDeclarationExpression: + return true; + default: + return false; + } + } + /** * Check that using statements are targeting valid symbols. */ @@ -7315,7 +7433,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return decorators; } - function checkScalar(ctx: CheckContext, node: ScalarStatementNode): Scalar { + function checkScalar( + ctx: CheckContext, + node: ScalarStatementNode | ScalarDeclarationExpressionNode, + ): Scalar { const links = getSymbolLinks(node.symbol); if (ctx.mapper === undefined && node.templateParameters.length > 0) { @@ -7331,17 +7452,22 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); + checkExpressionDeclarationConstraints(node); + if (ctx.mapper === undefined) { + reportDeclarationExpressionFeature(node); + } const decorators: DecoratorApplication[] = []; const type: Scalar = createType({ kind: "Scalar", - name: node.id.sv, + name: node.id?.sv ?? "", node: node, constructors: new Map(), namespace: getParentNamespaceType(node), decorators, derivedScalars: [], + expression: node.kind === SyntaxKind.ScalarDeclarationExpression, }); linkType(ctx, links, type); @@ -7355,7 +7481,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkScalarConstructors(ctx, type, node, type.constructors); decorators.push(...checkDecorators(ctx, type, node)); - if (ctx.mapper === undefined) { + if (ctx.mapper === undefined && !type.expression) { type.namespace?.scalars.set(type.name, type); } linkMapper(type, ctx.mapper); @@ -7368,7 +7494,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkScalarExtends( ctx: CheckContext, - scalar: ScalarStatementNode, + scalar: ScalarStatementNode | ScalarDeclarationExpressionNode, extendsRef: TypeReferenceNode, ): Scalar | undefined { const symId = getNodeSym(scalar); @@ -7406,7 +7532,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkScalarConstructors( ctx: CheckContext, parentScalar: Scalar, - node: ScalarStatementNode, + node: ScalarStatementNode | ScalarDeclarationExpressionNode, constructors: Map, ) { if (parentScalar.baseScalar) { @@ -7584,59 +7710,76 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } - function checkEnum(ctx: CheckContext, node: EnumStatementNode): Type { + function checkEnum( + ctx: CheckContext, + node: EnumStatementNode | EnumDeclarationExpressionNode, + ): Type { const links = getSymbolLinks(node.symbol); - if (!links.type) { + + if (links.declaredType && ctx.mapper === undefined) { + // we're not instantiating this enum and we've already checked it + return links.declaredType as Enum; + } + + if (ctx.mapper === undefined) { checkModifiers(program, node); - const enumType: Enum = (links.type = createType({ - kind: "Enum", - name: node.id.sv, - node, - members: createRekeyableMap(), - decorators: [], - })); + reportDeclarationExpressionFeature(node); + } - const memberNames = new Set(); + const enumType: Enum = createType({ + kind: "Enum", + name: node.id?.sv ?? "", + node, + members: createRekeyableMap(), + decorators: [], + expression: node.kind === SyntaxKind.EnumDeclarationExpression, + }); + // Link the type before resolving the parent namespace: resolving the namespace may run + // its decorators which can reference this enum, and we must not create a second instance. + linkType(ctx, links, enumType); - for (const member of node.members) { - if (member.kind === SyntaxKind.EnumMember) { - const memberType = checkEnumMember(ctx, member, enumType); - if (memberNames.has(memberType.name)) { - reportCheckerDiagnostic( - createDiagnostic({ - code: "enum-member-duplicate", - format: { name: memberType.name }, - target: node, - }), - ); - continue; - } - memberNames.add(memberType.name); - enumType.members.set(memberType.name, memberType); - } else { - const members = checkEnumSpreadMember( - ctx, - node.symbol, - enumType, - member.target, - memberNames, + const memberNames = new Set(); + + for (const member of node.members) { + if (member.kind === SyntaxKind.EnumMember) { + const memberType = checkEnumMember(ctx, member, enumType); + if (memberNames.has(memberType.name)) { + reportCheckerDiagnostic( + createDiagnostic({ + code: "enum-member-duplicate", + format: { name: memberType.name }, + target: node, + }), ); - for (const memberType of members) { - linkIndirectMember(ctx, node, memberType); - enumType.members.set(memberType.name, memberType); - } + continue; + } + memberNames.add(memberType.name); + enumType.members.set(memberType.name, memberType); + } else { + const members = checkEnumSpreadMember( + ctx, + node.symbol, + enumType, + member.target, + memberNames, + ); + for (const memberType of members) { + linkIndirectMember(ctx, node, memberType); + enumType.members.set(memberType.name, memberType); } } + } - const namespace = getParentNamespaceType(node); - enumType.namespace = namespace; + enumType.namespace = getParentNamespaceType(node); + if (ctx.mapper === undefined && !enumType.expression) { enumType.namespace?.enums.set(enumType.name!, enumType); - enumType.decorators = checkDecorators(ctx, enumType, node); - linkMapper(enumType, ctx.mapper); - finishType(enumType); } + enumType.decorators = checkDecorators(ctx, enumType, node); + linkMapper(enumType, ctx.mapper); - return links.type; + return finishType(enumType, { + skipDecorators: ctx.hasFlags(CheckFlags.InTemplateDeclaration), + }); } function checkInterface(ctx: CheckContext, node: InterfaceStatementNode): Interface { @@ -7757,7 +7900,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker return ownMembers; } - function checkUnion(ctx: CheckContext, node: UnionStatementNode) { + function checkUnion( + ctx: CheckContext, + node: UnionStatementNode | UnionDeclarationExpressionNode, + ) { const links = getSymbolLinks(node.symbol); if (ctx.mapper === undefined && node.templateParameters.length > 0) { @@ -7780,6 +7926,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } } checkTemplateDeclaration(ctx, node); + checkExpressionDeclarationConstraints(node); + if (ctx.mapper === undefined) { + reportDeclarationExpressionFeature(node); + } const variants = createRekeyableMap(); const unionType: Union = createType({ @@ -7787,12 +7937,12 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators: [], node, namespace: getParentNamespaceType(node), - name: node.id.sv, + name: node.id?.sv, variants, get options() { return Array.from(this.variants.values()).map((v) => v.type); }, - expression: false, + expression: node.kind === SyntaxKind.UnionDeclarationExpression, }); linkType(ctx, links, unionType); @@ -7806,12 +7956,14 @@ export function createChecker(program: Program, resolver: NameResolver): Checker linkMapper(unionType, ctx.mapper); - if (ctx.mapper === undefined) { + if (ctx.mapper === undefined && !unionType.expression) { unionType.namespace?.unions.set(unionType.name!, unionType); } lateBindMemberContainer(unionType); - lateBindMembers(unionType); + if (unionType.symbol) { + lateBindMembers(unionType); + } return finishType(unionType, { skipDecorators: ctx.hasFlags(CheckFlags.InTemplateDeclaration), }); @@ -7820,7 +7972,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkUnionVariants( ctx: CheckContext, parentUnion: Union, - node: UnionStatementNode, + node: UnionStatementNode | UnionDeclarationExpressionNode, variants: Map, ) { for (const variantNode of node.options) { @@ -7871,7 +8023,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker */ function checkUnionBaseType( ctx: CheckContext, - union: UnionStatementNode, + union: UnionStatementNode | UnionDeclarationExpressionNode, unionType: Union, extendsRef: Expression, ): NonNullable | undefined { @@ -7908,7 +8060,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker reportCheckerDiagnostic( createDiagnostic({ code: "circular-base-type", - format: { typeName: union.id.sv }, + format: { typeName: union.id?.sv ?? "(anonymous union)" }, target: extendsRef, }), ); @@ -8156,6 +8308,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators: [], derivedModels: [], sourceModels: [], + expression: true, }); } @@ -8879,7 +9032,9 @@ function extractParamDocs(node: OperationStatementNode): Map { return paramDocs; } -function extractPropDocs(node: ModelStatementNode): Map { +function extractPropDocs( + node: ModelStatementNode | ModelDeclarationExpressionNode, +): Map { if (node.docs === undefined) { return new Map(); } diff --git a/packages/compiler/src/core/features.ts b/packages/compiler/src/core/features.ts index 30fcfce1b91..77127a1c4b5 100644 --- a/packages/compiler/src/core/features.ts +++ b/packages/compiler/src/core/features.ts @@ -11,6 +11,10 @@ export const compilerFeatures = { description: "Allows use of function declarations without experimental warnings in project code.", }, + "declaration-expressions": { + description: + "Allows use of declaration expressions (named or anonymous model, scalar, enum and union declarations in expression position) in project code.", + }, "auto-decorators": { description: "Allows use of auto decorator declarations without experimental warnings in project code.", diff --git a/packages/compiler/src/core/helpers/type-name-utils.ts b/packages/compiler/src/core/helpers/type-name-utils.ts index e9d78cb3953..28c1e68c18f 100644 --- a/packages/compiler/src/core/helpers/type-name-utils.ts +++ b/packages/compiler/src/core/helpers/type-name-utils.ts @@ -163,18 +163,31 @@ function getNamespacePrefix(type: Namespace | undefined, options?: TypeNameOptio } function getEnumName(e: Enum, options: TypeNameOptions | undefined): string { - return `${getNamespacePrefix(e.namespace, options)}${getIdentifierName(e.name, options)}`; + // An enum used in expression position is anonymous; render its members inline + // instead of a (namespace-prefixed) name. + if (e.name === "") { + return `{ ${[...e.members.values()].map((m) => m.name).join(", ")} }`; + } + const nsPrefix = e.expression ? "" : getNamespacePrefix(e.namespace, options); + return `${nsPrefix}${getIdentifierName(e.name, options)}`; } function getScalarName(scalar: Scalar, options: TypeNameOptions | undefined): string { - return `${getNamespacePrefix(scalar.namespace, options)}${getIdentifierName( - scalar.name, - options, - )}`; + // A scalar used in expression position is anonymous; render what it extends + // (there is no inline literal syntax for it) instead of a namespace-only name. + if (scalar.name === "") { + return scalar.baseScalar + ? `scalar extends ${getTypeName(scalar.baseScalar, options)}` + : "scalar"; + } + const nsPrefix = scalar.expression ? "" : getNamespacePrefix(scalar.namespace, options); + return `${nsPrefix}${getIdentifierName(scalar.name, options)}`; } function getModelName(model: Model, options: TypeNameOptions | undefined) { - const nsPrefix = getNamespacePrefix(model.namespace, options); + // Declarations used in expression position are anonymous and not addressable, so + // they should not be namespace-qualified (mirrors union expression naming). + const nsPrefix = model.expression ? "" : getNamespacePrefix(model.namespace, options); if (model.name === "") { const operationParametersName = getOperationParametersName(model, options); if (operationParametersName !== undefined) { diff --git a/packages/compiler/src/core/inspector/node.ts b/packages/compiler/src/core/inspector/node.ts index af9f783d828..9183158d0a2 100644 --- a/packages/compiler/src/core/inspector/node.ts +++ b/packages/compiler/src/core/inspector/node.ts @@ -33,12 +33,17 @@ function printNodeInfoInternal(node: Node): string { case SyntaxKind.JsNamespaceDeclaration: case SyntaxKind.NamespaceStatement: case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.OperationStatement: case SyntaxKind.EnumStatement: + case SyntaxKind.EnumDeclarationExpression: case SyntaxKind.AliasStatement: case SyntaxKind.ConstStatement: case SyntaxKind.UnionStatement: - return node.id.sv; + case SyntaxKind.UnionDeclarationExpression: + case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: + return node.id?.sv ?? ""; default: return ""; } diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index a60c765a8ae..be42c466ce8 100644 --- a/packages/compiler/src/core/messages.ts +++ b/packages/compiler/src/core/messages.ts @@ -207,6 +207,13 @@ const diagnostics = { default: paramMessage`Cannot decorate ${"nodeName"}.`, }, }, + "templated-declaration-in-expression": { + severity: "error", + messages: { + default: + "A declaration used as an expression cannot have template parameters as it cannot be referenced or instantiated.", + }, + }, "default-required": { severity: "error", messages: { @@ -269,6 +276,13 @@ const diagnostics = { "Function declarations are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", }, }, + "declaration-expression-disabled": { + severity: "error", + messages: { + default: + "Declaration expressions require the 'declaration-expressions' feature to be enabled. Add 'declaration-expressions' to the 'features' list in your tspconfig.yaml.", + }, + }, "union-extends-disabled": { severity: "error", messages: { diff --git a/packages/compiler/src/core/modifiers.ts b/packages/compiler/src/core/modifiers.ts index 596675f10b4..0fac51a7e32 100644 --- a/packages/compiler/src/core/modifiers.ts +++ b/packages/compiler/src/core/modifiers.ts @@ -4,7 +4,14 @@ import { compilerAssert } from "./diagnostics.js"; import { createDiagnostic } from "./messages.js"; import type { Program } from "./program.js"; -import type { Declaration, Modifier } from "./types.js"; +import type { + Declaration, + EnumDeclarationExpressionNode, + ModelDeclarationExpressionNode, + Modifier, + ScalarDeclarationExpressionNode, + UnionDeclarationExpressionNode, +} from "./types.js"; import { ModifierFlags, SyntaxKind } from "./types.js"; /** @@ -35,7 +42,19 @@ const NO_MODIFIERS: ModifierCompatibility = { required: ModifierFlags.None, }; -const SYNTAX_MODIFIERS: Readonly> = { +/** + * Declaration nodes whose modifiers can be checked. Includes the statement + * declarations as well as the declaration-expression nodes (which never carry + * modifiers, but go through the same checking path). + */ +type ModifierCheckableNode = + | Declaration + | ModelDeclarationExpressionNode + | ScalarDeclarationExpressionNode + | UnionDeclarationExpressionNode + | EnumDeclarationExpressionNode; + +const SYNTAX_MODIFIERS: Readonly> = { [SyntaxKind.NamespaceStatement]: NO_MODIFIERS, [SyntaxKind.OperationStatement]: DEFAULT_COMPATIBILITY, [SyntaxKind.ModelStatement]: DEFAULT_COMPATIBILITY, @@ -45,6 +64,10 @@ const SYNTAX_MODIFIERS: Readonly(); if (optionalIs) { - const tok = expectTokenIsOneOf(Token.Semicolon, Token.OpenBrace); - if (tok === Token.Semicolon) { - nextToken(); + if (inExpressionPosition) { + // In expression position there is no `;` terminator (it belongs to the enclosing + // construct): only parse a `{ ... }` body when present, otherwise the model has no + // own properties. + if (token() === Token.OpenBrace) { + propDetail = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); + } } else { - propDetail = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); + const tok = expectTokenIsOneOf(Token.Semicolon, Token.OpenBrace); + if (tok === Token.Semicolon) { + nextToken(); + } else { + propDetail = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); + } } } else { propDetail = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); } return { - kind: SyntaxKind.ModelStatement, - id, extends: optionalExtends, is: optionalIs, - templateParameters, - templateParametersRange, - decorators, properties: propDetail.items, bodyRange: propDetail.range, - modifiers, - modifierFlags: modifiersToFlags(modifiers), - ...finishNode(pos), }; } @@ -1138,6 +1237,33 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + function parseScalarDeclarationExpression( + pos: number, + decorators: DecoratorExpressionNode[] = [], + ): ScalarDeclarationExpressionNode { + parseExpected(Token.ScalarKeyword); + const id = parseOptionalDeclarationExpressionIdentifier(); + const { items: templateParameters, range: templateParametersRange } = + parseTemplateParameterList(); + + const optionalExtends = parseOptionalScalarExtends(); + const { items: members, range: bodyRange } = parseScalarMembers(true); + + return { + kind: SyntaxKind.ScalarDeclarationExpression, + id, + templateParameters, + templateParametersRange, + extends: optionalExtends, + members, + bodyRange, + decorators, + modifiers: [], + modifierFlags: ModifierFlags.None, + ...finishNode(pos), + }; + } + function parseOptionalScalarExtends() { if (parseOptional(Token.ExtendsKeyword)) { return parseReferenceExpression(); @@ -1145,7 +1271,12 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return undefined; } - function parseScalarMembers(): ListDetail { + function parseScalarMembers(inExpressionPosition = false): ListDetail { + // In expression position there is no `;` terminator: only parse a `{ ... }` body + // when present, otherwise the scalar has no members. + if (inExpressionPosition && token() !== Token.OpenBrace) { + return createEmptyList(); + } if (token() === Token.Semicolon) { nextToken(); return createEmptyList(); @@ -1190,6 +1321,24 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + function parseEnumDeclarationExpression( + pos: number, + decorators: DecoratorExpressionNode[] = [], + ): EnumDeclarationExpressionNode { + parseExpected(Token.EnumKeyword); + const id = parseOptionalDeclarationExpressionIdentifier(); + const { items: members } = parseList(ListKind.EnumMembers, parseEnumMemberOrSpread); + return { + kind: SyntaxKind.EnumDeclarationExpression, + id, + decorators, + modifiers: [], + modifierFlags: ModifierFlags.None, + members, + ...finishNode(pos), + }; + } + function parseEnumMemberOrSpread(pos: number, decorators: DecoratorExpressionNode[]) { return token() === Token.Ellipsis ? parseEnumSpreadMember(pos, decorators) @@ -1716,6 +1865,48 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return base; } + /** + * Parse a declaration used in expression position (`model`/`enum`/`union`/`scalar`). + * {@link pos} should point at the start of the node (the first decorator's `@` when + * {@link decorators} are provided, otherwise the keyword). A leading doc comment + * ({@link docs}) is attached to the node so it is applied just like an `@doc` decorator. + */ + function parseDeclarationExpression( + pos: number, + decorators: DecoratorExpressionNode[] = [], + docs: DocNode[] = [], + ): + | ModelDeclarationExpressionNode + | EnumDeclarationExpressionNode + | UnionDeclarationExpressionNode + | ScalarDeclarationExpressionNode { + let node: + | ModelDeclarationExpressionNode + | EnumDeclarationExpressionNode + | UnionDeclarationExpressionNode + | ScalarDeclarationExpressionNode; + switch (token()) { + case Token.ModelKeyword: + node = parseModelDeclarationExpression(pos, decorators); + break; + case Token.EnumKeyword: + node = parseEnumDeclarationExpression(pos, decorators); + break; + case Token.UnionKeyword: + node = parseUnionDeclarationExpression(pos, decorators); + break; + case Token.ScalarKeyword: + node = parseScalarDeclarationExpression(pos, decorators); + break; + default: + compilerAssert(false, "Expected a declaration expression keyword."); + } + if (docs.length > 0) { + mutate(node).docs = docs; + } + return node; + } + function parsePrimaryExpression(): Expression { while (true) { switch (token()) { @@ -1736,14 +1927,36 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return parseNumericLiteral(); case Token.OpenBrace: return parseModelExpression(); + case Token.ModelKeyword: + case Token.EnumKeyword: + case Token.UnionKeyword: + case Token.ScalarKeyword: { + // A leading doc comment applies to the declaration expression, just like `@doc`. + // `pos` starts at the doc comment so the resulting node span includes it. + const [pos, docs] = parseDocList(); + return parseDeclarationExpression(pos, [], docs); + } case Token.OpenBracket: return parseTupleExpression(); case Token.OpenParen: return parseParenthesizedExpression(); - case Token.At: + case Token.At: { + // Capture a doc comment that precedes the decorators before they are parsed + // (parsing decorators advances the scanner and clears the pending doc range). + const [docPos, docs] = parseDocList(); + // `pos` starts at the doc comment (if any) or the `@` so the resulting node + // span includes the docs and decorators. + const pos = docs.length > 0 ? docPos : tokenPos(); const decorators = parseDecoratorList(); + // Decorators are only valid in expression position when they decorate a + // declaration expression (the keyword forms below). `pos` starts at the `@` + // so the resulting node span includes the decorators. + if (isDeclarationExpressionStatementKeyword(token())) { + return parseDeclarationExpression(pos, decorators, docs); + } reportInvalidDecorators(decorators, "expression"); continue; + } case Token.Hash: const directives = parseDirectiveList(); reportInvalidDirective(directives, "expression"); @@ -2023,6 +2236,14 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + /** + * Parse the optional identifier of a declaration used in expression position. The name + * is only parsed when actually present; an anonymous declaration expression has no `id`. + */ + function parseOptionalDeclarationExpressionIdentifier(): IdentifierNode | undefined { + return token() === Token.Identifier ? parseIdentifier() : undefined; + } + function parseIdentifier(options?: { message?: keyof CompilerDiagnostics["token-expected"]; allowStringLiteral?: boolean; // Allow string literals to be used as identifiers for backward-compatibility, but convert to an identifier node. @@ -2247,16 +2468,27 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa if (docRanges.length === 0 || options.docs === false) { return [tokenPos(), []]; } + return [docRanges[0].pos, parseDocRanges(docRanges)]; + } + + /** + * Parse the given doc comment ranges into doc nodes. Unlike {@link parseDocList} this does + * not read the pending {@link docRanges}, so it can be used to attach doc comments that were + * captured earlier (e.g. before a list element's decorators were parsed). + */ + function parseDocRanges(ranges: readonly DocRange[]): DocNode[] { + if (ranges.length === 0 || options.docs === false) { + return []; + } const docs: DocNode[] = []; - for (const range of docRanges) { + for (const range of ranges) { const doc = parseRange(ParseMode.Doc, innerDocRange(range), () => parseDoc(range)); docs.push(doc); if (range.comment) { mutate(range.comment).parsedAsDocs = true; } } - - return [docRanges[0].pos, docs]; + return docs; } function parseDoc(range: TextRange): DocNode { @@ -2646,15 +2878,34 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa while (true) { const startingPos = tokenPos(); + // Capture any pending doc comment before `parseAnnotations` parses (and clears the + // pending range for) decorators, so a doc comment preceding a decorated declaration + // expression element can still be attached to it below. + const pendingDocRanges = kind.allowDeclarationExpression ? docRanges : undefined; const { pos, docs, directives, decorators } = parseAnnotations({ skipParsingDocNodes: Boolean(kind.invalidAnnotationTarget), }); + + // A declaration expression element (e.g. `enum { a }`) can carry decorators (and a + // doc comment) even in an expression list. Attach them to the declaration expression + // instead of rejecting them as an invalid annotation target. + const declarationExpressionWithDecorators = + kind.allowDeclarationExpression && + decorators.length > 0 && + isDeclarationExpressionStatementKeyword(token()); + if (kind.invalidAnnotationTarget) { - reportInvalidDecorators(decorators, kind.invalidAnnotationTarget); reportInvalidDirective(directives, kind.invalidAnnotationTarget); + if (!declarationExpressionWithDecorators) { + reportInvalidDecorators(decorators, kind.invalidAnnotationTarget); + } } - if (directives.length === 0 && decorators.length === 0 && atEndOfListWithError(kind)) { + if ( + directives.length === 0 && + decorators.length === 0 && + atEndOfListWithError(kind, { atElementStart: true }) + ) { // Error recovery: end surrounded list at statement keyword or end // of file. Note, however, that we must parse a missing element if // there were directives or decorators as we cannot drop those from @@ -2666,7 +2917,13 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa } let item: T; - if (kind.invalidAnnotationTarget) { + if (declarationExpressionWithDecorators) { + const declDocs = pendingDocRanges ? parseDocRanges(pendingDocRanges) : []; + // When a doc comment precedes the decorators, start the node span at the doc + // comment so the doc nodes stay within the parent node's range. + const declPos = declDocs.length > 0 ? pendingDocRanges![0].pos : pos; + item = parseDeclarationExpression(declPos, decorators, declDocs) as unknown as T; + } else if (kind.invalidAnnotationTarget) { item = (parseItem as ParseListItem)(); } else { item = parseItem(pos, decorators); @@ -2695,7 +2952,7 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa // If a list *is* surrounded by punctuation, then the list ends when we // reach the close token. break; - } else if (atEndOfListWithError(kind)) { + } else if (atEndOfListWithError(kind, { atElementStart: false })) { // Error recovery: If a list *is* surrounded by punctuation, then // the list ends at statement keyword or end-of-file under the // assumption that the closing delimiter is missing. This check is @@ -2762,11 +3019,31 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return false; } - function atEndOfListWithError(kind: ListKind) { + /** + * Whether the parser is at a position where the current list (when surrounded by + * punctuation) should be ended under the assumption that its closing token is missing — + * i.e. the current token is a statement keyword or end-of-file. + * + * @param atElementStart Whether we are about to parse a new element (`true`) or have just + * parsed one and found neither a delimiter nor the close token (`false`). A + * declaration-expression keyword (`model`/`enum`/`union`/`scalar`) is a valid element + * start in lists that {@link ListKind.allowDeclarationExpression allow declaration + * expressions}, so it must not end the list at the start of an element. After an element, + * a declaration-expression keyword cannot start a new element (a delimiter is required + * first), so it is treated like any other statement keyword and ends the list — restoring + * error recovery for an unclosed delimiter (e.g. `@foo(model X {}` followed by a + * statement). + */ + function atEndOfListWithError(kind: ListKind, { atElementStart }: { atElementStart: boolean }) { return ( kind.close !== Token.None && (isStatementKeyword(token()) || token() === Token.EndOfFile) && - token() !== kind.allowedStatementKeyword + token() !== kind.allowedStatementKeyword && + !( + atElementStart && + kind.allowDeclarationExpression && + isDeclarationExpressionStatementKeyword(token()) + ) ); } @@ -3066,6 +3343,15 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitNode(cb, node.is) || visitEach(cb, node.properties) ); + case SyntaxKind.ModelDeclarationExpression: + return ( + visitEach(cb, node.decorators) || + visitNode(cb, node.id) || + visitEach(cb, node.templateParameters) || + visitNode(cb, node.extends) || + visitNode(cb, node.is) || + visitEach(cb, node.properties) + ); case SyntaxKind.ScalarStatement: return ( visitEach(cb, node.modifiers) || @@ -3075,6 +3361,14 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitEach(cb, node.members) || visitNode(cb, node.extends) ); + case SyntaxKind.ScalarDeclarationExpression: + return ( + visitEach(cb, node.decorators) || + visitNode(cb, node.id) || + visitEach(cb, node.templateParameters) || + visitEach(cb, node.members) || + visitNode(cb, node.extends) + ); case SyntaxKind.ScalarConstructor: return visitNode(cb, node.id) || visitEach(cb, node.parameters); case SyntaxKind.UnionStatement: @@ -3086,6 +3380,14 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitNode(cb, node.extends) || visitEach(cb, node.options) ); + case SyntaxKind.UnionDeclarationExpression: + return ( + visitEach(cb, node.decorators) || + visitNode(cb, node.id) || + visitEach(cb, node.templateParameters) || + visitNode(cb, node.extends) || + visitEach(cb, node.options) + ); case SyntaxKind.UnionVariant: return visitEach(cb, node.decorators) || visitNode(cb, node.id) || visitNode(cb, node.value); case SyntaxKind.EnumStatement: @@ -3095,6 +3397,10 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitNode(cb, node.id) || visitEach(cb, node.members) ); + case SyntaxKind.EnumDeclarationExpression: + return ( + visitEach(cb, node.decorators) || visitNode(cb, node.id) || visitEach(cb, node.members) + ); case SyntaxKind.EnumMember: return visitEach(cb, node.decorators) || visitNode(cb, node.id) || visitNode(cb, node.value); case SyntaxKind.EnumSpreadMember: @@ -3440,6 +3746,9 @@ export function getIdentifierContext(id: IdentifierNode): IdentifierContext { case SyntaxKind.ModelStatement: kind = IdentifierKind.ModelStatementProperty; break; + case SyntaxKind.ModelDeclarationExpression: + kind = IdentifierKind.ModelStatementProperty; + break; default: compilerAssert("false", "ModelProperty with unexpected parent kind."); kind = diff --git a/packages/compiler/src/core/scanner.ts b/packages/compiler/src/core/scanner.ts index 9301b4e0732..4f81a2fa382 100644 --- a/packages/compiler/src/core/scanner.ts +++ b/packages/compiler/src/core/scanner.ts @@ -575,6 +575,19 @@ export function isStatementKeyword(token: Token) { return token >= Token.__StartStatementKeyword && token < Token.__EndStatementKeyword; } +/** + * Keywords that introduce a declaration which can also be used in expression position + * (e.g. `model { ... }`, `enum { ... }`, `union { ... }`, `scalar extends ...`). + */ +export function isDeclarationExpressionStatementKeyword(token: Token) { + return ( + token === Token.ModelKeyword || + token === Token.EnumKeyword || + token === Token.UnionKeyword || + token === Token.ScalarKeyword + ); +} + export function createScanner( source: string | SourceFile, diagnosticHandler: DiagnosticHandler, diff --git a/packages/compiler/src/core/type-utils.ts b/packages/compiler/src/core/type-utils.ts index db70de8ecec..6a357495c16 100644 --- a/packages/compiler/src/core/type-utils.ts +++ b/packages/compiler/src/core/type-utils.ts @@ -92,8 +92,11 @@ export function isRecordModelType(programOrType: Program | Model, maybeType?: Mo export function getParentTemplateNode(node: Node): (Node & TemplateDeclarationNode) | undefined { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: case SyntaxKind.UnionStatement: + case SyntaxKind.UnionDeclarationExpression: case SyntaxKind.InterfaceStatement: return node.templateParameters.length > 0 ? node : undefined; case SyntaxKind.OperationStatement: diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index cd7ad7fef85..52638c01cf4 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -260,10 +260,21 @@ export interface RecordModelType extends Model { export interface Model extends BaseType, DecoratedType, TemplatedTypeBase { kind: "Model"; name: string; - node?: ModelStatementNode | ModelExpressionNode | IntersectionExpressionNode | ObjectLiteralNode; + node?: + | ModelStatementNode + | ModelDeclarationExpressionNode + | ModelExpressionNode + | IntersectionExpressionNode + | ObjectLiteralNode; namespace?: Namespace; indexer?: ModelIndexer; + /** + * Whether this model was declared in expression position (e.g. an anonymous + * `model { ... }` used as a type) rather than as a named statement. + */ + expression: boolean; + /** * The properties of the model. * @@ -425,12 +436,18 @@ export interface TemplateValue extends BaseValue { export interface Scalar extends BaseType, DecoratedType, TemplatedTypeBase { kind: "Scalar"; name: string; - node?: ScalarStatementNode; + node?: ScalarStatementNode | ScalarDeclarationExpressionNode; /** * Namespace the scalar was defined in. */ namespace?: Namespace; + /** + * Whether this scalar was declared in expression position (anonymous `scalar ...`) + * rather than as a named statement. + */ + expression: boolean; + /** * Scalar this scalar extends. */ @@ -492,9 +509,15 @@ export interface Interface extends BaseType, DecoratedType, TemplatedTypeBase { export interface Enum extends BaseType, DecoratedType { kind: "Enum"; name: string; - node?: EnumStatementNode; + node?: EnumStatementNode | EnumDeclarationExpressionNode; namespace?: Namespace; + /** + * Whether this enum was declared in expression position (anonymous `enum { ... }`) + * rather than as a named statement. + */ + expression: boolean; + /** * The members of the enum. * @@ -663,7 +686,7 @@ export interface Tuple extends BaseType { export interface Union extends BaseType, DecoratedType, TemplatedTypeBase { kind: "Union"; name?: string; - node?: UnionExpressionNode | UnionStatementNode; + node?: UnionExpressionNode | UnionStatementNode | UnionDeclarationExpressionNode; namespace?: Namespace; /** @@ -1228,6 +1251,10 @@ export enum SyntaxKind { InternalKeyword, AutoKeyword, FunctionTypeExpression, + ModelDeclarationExpression, + ScalarDeclarationExpression, + UnionDeclarationExpression, + EnumDeclarationExpression, } export const enum NodeFlags { @@ -1358,11 +1385,14 @@ export type Node = */ export type TemplateableNode = | ModelStatementNode + | ModelDeclarationExpressionNode | ScalarStatementNode + | ScalarDeclarationExpressionNode | AliasStatementNode | InterfaceStatementNode | OperationStatementNode - | UnionStatementNode; + | UnionStatementNode + | UnionDeclarationExpressionNode; /** * Node types that can have referencable members @@ -1370,11 +1400,15 @@ export type TemplateableNode = export type MemberContainerNode = | ModelStatementNode | ModelExpressionNode + | ModelDeclarationExpressionNode | InterfaceStatementNode | EnumStatementNode + | EnumDeclarationExpressionNode | UnionStatementNode + | UnionDeclarationExpressionNode | IntersectionExpressionNode - | ScalarStatementNode; + | ScalarStatementNode + | ScalarDeclarationExpressionNode; export type MemberNode = | ModelPropertyNode @@ -1459,6 +1493,29 @@ export interface DeclarationNode { readonly modifierFlags: ModifierFlags; } +/** + * Declaration node whose identifier is optional. Used by declaration-expression nodes + * (e.g. `alias Foo = enum { a, b }`), which may be anonymous (no `id`) or carry a name + * that is kept on the resulting type but never registered in a namespace. + */ +export interface OptionallyNamedDeclarationNode { + /** + * Identifier that this node declares. May be undefined when the declaration is used + * as an anonymous expression. + */ + readonly id?: IdentifierNode; + + /** + * Modifier nodes applied to this declaration. + */ + readonly modifiers: Modifier[]; + + /** + * Combined modifier flags for this declaration. + */ + readonly modifierFlags: ModifierFlags; +} + export type Declaration = Extract; export type ScopeNode = @@ -1506,6 +1563,10 @@ export type Expression = | ArrayExpressionNode | MemberExpressionNode | ModelExpressionNode + | ModelDeclarationExpressionNode + | EnumDeclarationExpressionNode + | UnionDeclarationExpressionNode + | ScalarDeclarationExpressionNode | ObjectLiteralNode | ArrayLiteralNode | TupleExpressionNode @@ -1591,6 +1652,22 @@ export interface ModelStatementNode extends BaseNode, DeclarationNode, TemplateD readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } +/** + * A `model` declaration used in expression position (e.g. `alias M = model { x: string }` + * or a property type). May carry a name (kept on the resulting type but never registered) + * and is always `expression: true`. Template parameters are syntactically accepted for + * error recovery but rejected by the checker. + */ +export interface ModelDeclarationExpressionNode + extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { + readonly kind: SyntaxKind.ModelDeclarationExpression; + readonly properties: readonly (ModelPropertyNode | ModelSpreadPropertyNode)[]; + readonly bodyRange: TextRange; + readonly extends?: Expression; + readonly is?: Expression; + readonly decorators: readonly DecoratorExpressionNode[]; +} + export interface ScalarStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { readonly kind: SyntaxKind.ScalarStatement; readonly extends?: TypeReferenceNode; @@ -1600,11 +1677,26 @@ export interface ScalarStatementNode extends BaseNode, DeclarationNode, Template readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } +/** + * A `scalar` declaration used in expression position (e.g. `alias S = scalar extends int32`). + * May carry a name (kept on the resulting type but never registered) and is always + * `expression: true`. Template parameters are syntactically accepted for error recovery but + * rejected by the checker. + */ +export interface ScalarDeclarationExpressionNode + extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { + readonly kind: SyntaxKind.ScalarDeclarationExpression; + readonly extends?: TypeReferenceNode; + readonly decorators: readonly DecoratorExpressionNode[]; + readonly members: readonly ScalarConstructorNode[]; + readonly bodyRange: TextRange; +} + export interface ScalarConstructorNode extends BaseNode { readonly kind: SyntaxKind.ScalarConstructor; readonly id: IdentifierNode; readonly parameters: FunctionParameterNode[]; - readonly parent?: ScalarStatementNode; + readonly parent?: ScalarStatementNode | ScalarDeclarationExpressionNode; } export interface InterfaceStatementNode extends BaseNode, DeclarationNode, TemplateDeclarationNode { @@ -1632,12 +1724,34 @@ export interface UnionStatementNode extends BaseNode, DeclarationNode, TemplateD readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } +/** + * A keyword-form `union` declaration used in expression position + * (e.g. `alias U = union { string, int32 }`). May carry a name (kept on the resulting type + * but never registered) and is always `expression: true`. Template parameters are + * syntactically accepted for error recovery but rejected by the checker. + */ +export interface UnionDeclarationExpressionNode + extends BaseNode, OptionallyNamedDeclarationNode, TemplateDeclarationNode { + readonly kind: SyntaxKind.UnionDeclarationExpression; + readonly options: readonly UnionVariantNode[]; + /** + * Type that every variant of this union must be assignable to. + * + * This is a constraint only, it does not imply any subtyping relationship between + * the union and the base type beyond the one that already exists structurally. + * + * @experimental + */ + readonly extends?: Expression; + readonly decorators: readonly DecoratorExpressionNode[]; +} + export interface UnionVariantNode extends BaseNode { readonly kind: SyntaxKind.UnionVariant; readonly id?: IdentifierNode; readonly value: Expression; readonly decorators: readonly DecoratorExpressionNode[]; - readonly parent?: UnionStatementNode; + readonly parent?: UnionStatementNode | UnionDeclarationExpressionNode; } export interface EnumStatementNode extends BaseNode, DeclarationNode { @@ -1647,12 +1761,23 @@ export interface EnumStatementNode extends BaseNode, DeclarationNode { readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; } +/** + * An `enum` declaration used in expression position (e.g. `alias E = enum { a, b }`). + * May carry a name (kept on the resulting type but never registered) and is always + * `expression: true`. + */ +export interface EnumDeclarationExpressionNode extends BaseNode, OptionallyNamedDeclarationNode { + readonly kind: SyntaxKind.EnumDeclarationExpression; + readonly members: readonly (EnumMemberNode | EnumSpreadMemberNode)[]; + readonly decorators: readonly DecoratorExpressionNode[]; +} + export interface EnumMemberNode extends BaseNode { readonly kind: SyntaxKind.EnumMember; readonly id: IdentifierNode; readonly value?: StringLiteralNode | NumericLiteralNode; readonly decorators: readonly DecoratorExpressionNode[]; - readonly parent?: EnumStatementNode; + readonly parent?: EnumStatementNode | EnumDeclarationExpressionNode; } export interface EnumSpreadMemberNode extends BaseNode { @@ -1709,13 +1834,13 @@ export interface ModelPropertyNode extends BaseNode { readonly decorators: readonly DecoratorExpressionNode[]; readonly optional: boolean; readonly default?: Expression; - readonly parent?: ModelStatementNode | ModelExpressionNode; + readonly parent?: ModelStatementNode | ModelExpressionNode | ModelDeclarationExpressionNode; } export interface ModelSpreadPropertyNode extends BaseNode { readonly kind: SyntaxKind.ModelSpreadProperty; readonly target: TypeReferenceNode; - readonly parent?: ModelStatementNode | ModelExpressionNode; + readonly parent?: ModelStatementNode | ModelExpressionNode | ModelDeclarationExpressionNode; } export interface ObjectLiteralNode extends BaseNode { diff --git a/packages/compiler/src/formatter/print/comment-handler.ts b/packages/compiler/src/formatter/print/comment-handler.ts index 355e2a26b7a..0288dca1702 100644 --- a/packages/compiler/src/formatter/print/comment-handler.ts +++ b/packages/compiler/src/formatter/print/comment-handler.ts @@ -59,6 +59,7 @@ function addEmptyDeclarationComment({ comment }: CommentContext) { enclosingNode.operations.length === 0 && precedingNode.kind === SyntaxKind.Identifier; break; case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: isEmptyDeclarationBody = enclosingNode.properties.length === 0 && (precedingNode === enclosingNode.is || @@ -66,6 +67,7 @@ function addEmptyDeclarationComment({ comment }: CommentContext) { precedingNode === enclosingNode.extends); break; case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: isEmptyDeclarationBody = enclosingNode.members.length === 0 && (precedingNode === enclosingNode.id || precedingNode === enclosingNode.extends); @@ -75,6 +77,10 @@ function addEmptyDeclarationComment({ comment }: CommentContext) { enclosingNode.options.length === 0 && (precedingNode === enclosingNode.id || precedingNode === enclosingNode.extends); break; + case SyntaxKind.UnionDeclarationExpression: + isEmptyDeclarationBody = + enclosingNode.options.length === 0 && precedingNode === enclosingNode.id; + break; } if (isEmptyDeclarationBody) { @@ -106,14 +112,18 @@ function addCommentBetweenAnnotationsAndNode({ comment }: CommentContext) { enclosingNode && (enclosingNode.kind === SyntaxKind.NamespaceStatement || enclosingNode.kind === SyntaxKind.ModelStatement || + enclosingNode.kind === SyntaxKind.ModelDeclarationExpression || enclosingNode.kind === SyntaxKind.EnumStatement || + enclosingNode.kind === SyntaxKind.EnumDeclarationExpression || enclosingNode.kind === SyntaxKind.OperationStatement || enclosingNode.kind === SyntaxKind.ScalarStatement || + enclosingNode.kind === SyntaxKind.ScalarDeclarationExpression || enclosingNode.kind === SyntaxKind.InterfaceStatement || enclosingNode.kind === SyntaxKind.ModelProperty || enclosingNode.kind === SyntaxKind.EnumMember || enclosingNode.kind === SyntaxKind.UnionVariant || - enclosingNode.kind === SyntaxKind.UnionStatement) + enclosingNode.kind === SyntaxKind.UnionStatement || + enclosingNode.kind === SyntaxKind.UnionDeclarationExpression) ) { util.addTrailingComment(precedingNode, comment); return true; diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 207124fe98c..dee4b222902 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -43,6 +43,7 @@ import type { OperationSignatureDeclarationNode, OperationSignatureReferenceNode, OperationStatementNode, + OptionallyNamedDeclarationNode, ScalarConstructorNode, ScalarStatementNode, Statement, @@ -72,6 +73,7 @@ import { util } from "./util.js"; const { align, breakParent, + conditionalGroup, group, hardline, ifBreak, @@ -109,6 +111,11 @@ export function printTypeSpec( print: PrettierChildPrint, ): Doc { const node = path.node; + if (isDeclarationExpressionNode(node)) { + // Declaration expressions own the layout of their doc comments and decorators so + // they can break (and indent) together with the declaration when too wide. + return printDeclarationExpression(path, options, print); + } const docs = printDocComments(path, options, print); const directives = shouldPrintDirective(node) ? printDirectives(path, options, print) : ""; const printedNode = printNode(path, options, print); @@ -123,6 +130,61 @@ export function printTypeSpec( return parts; } +/** + * Print a declaration expression (`model`/`enum`/`union`/`scalar` used in expression + * position) together with its doc comments and decorators. + * + * When everything fits on the line the doc comments and decorators are kept inline + * (e.g. `@a @b enum { ... }`). When it does not fit, each doc comment and decorator is + * placed on its own line and the whole block is indented one level relative to the + * surrounding expression. + * + * The declaration body always breaks (it contains hardlines), so a plain `group` would + * always break. A `conditionalGroup` is used instead so the break decision is based on + * the width of the first line only. + */ +function printDeclarationExpression( + path: AstPath, + options: TypeSpecPrettierOptions, + print: PrettierChildPrint, +): Doc { + const node = path.node; + const bare = printNode(path, options, print); + + const docs = node.docs?.length ? path.map((x) => print(x as any), "docs") : []; + const decorators = (node as DecorableNode).decorators?.length + ? path.map((x) => print(x as any), "decorators") + : []; + const prefix = [...docs, ...decorators]; + + // No doc comments or decorators: nothing to lay out, print the bare declaration. + if (prefix.length === 0) { + return bare; + } + + const inline = join(" ", [...prefix, bare]); + + // In argument position (e.g. a decorator argument) the enclosing parentheses already + // provide the line break and indentation, so the block must not add its own. + const broken = isInArgumentPosition(path) + ? join(hardline, [...prefix, bare]) + : indent([hardline, join(hardline, [...prefix, bare])]); + + return conditionalGroup([inline, broken]); +} + +function isInArgumentPosition(path: AstPath): boolean { + const parent = path.getParentNode() as Node | null; + switch (parent?.kind) { + case SyntaxKind.DecoratorExpression: + case SyntaxKind.AugmentDecoratorStatement: + case SyntaxKind.CallExpression: + return true; + default: + return false; + } +} + function shouldPrintDirective(node: Node) { // Model property handle printing directive itself. return node.kind !== SyntaxKind.ModelProperty; @@ -167,16 +229,24 @@ export function printNode( ); case SyntaxKind.ModelStatement: return printModelStatement(path as AstPath, options, print); + case SyntaxKind.ModelDeclarationExpression: + return printModelStatement(path as AstPath, options, print); case SyntaxKind.ScalarStatement: return printScalarStatement(path as AstPath, options, print); + case SyntaxKind.ScalarDeclarationExpression: + return printScalarStatement(path as AstPath, options, print); case SyntaxKind.ScalarConstructor: return printScalarConstructor(path as AstPath, options, print); case SyntaxKind.AliasStatement: return printAliasStatement(path as AstPath, options, print); case SyntaxKind.EnumStatement: return printEnumStatement(path as AstPath, options, print); + case SyntaxKind.EnumDeclarationExpression: + return printEnumStatement(path as AstPath, options, print); case SyntaxKind.UnionStatement: return printUnionStatement(path as AstPath, options, print); + case SyntaxKind.UnionDeclarationExpression: + return printUnionStatement(path as AstPath, options, print); case SyntaxKind.InterfaceStatement: return printInterfaceStatement(path as AstPath, options, print); // Others. @@ -654,6 +724,18 @@ export function printDocComments(path: AstPath, options: object, print: Pr return group([...docs, breakParent]); } +function isDeclarationExpressionNode(node: Node): boolean { + switch (node.kind) { + case SyntaxKind.ModelDeclarationExpression: + case SyntaxKind.EnumDeclarationExpression: + case SyntaxKind.UnionDeclarationExpression: + case SyntaxKind.ScalarDeclarationExpression: + return true; + default: + return false; + } +} + export function printDirectives(path: AstPath, options: object, print: PrettierChildPrint) { const node = path.node; if (node.directives === undefined || node.directives.length === 0) { @@ -754,12 +836,15 @@ export function printEnumStatement( options: TypeSpecPrettierOptions, print: PrettierChildPrint, ) { - const { decorators } = printDecorators(path, options, print, { tryInline: false }); - const id = path.call(print, "id"); + const inExpressionPosition = isDeclarationExpressionNode(path.node); + const decorators = inExpressionPosition + ? "" + : printDecorators(path, options, print, { tryInline: false }).decorators; + const id = path.node.id ? [" ", path.call(print, "id")] : ""; return [ decorators, printModifiers(path, options, print), - "enum ", + "enum", id, " ", printEnumBlock(path, options, print), @@ -807,14 +892,17 @@ export function printUnionStatement( options: TypeSpecPrettierOptions, print: PrettierChildPrint, ) { - const id = path.call(print, "id"); - const { decorators } = printDecorators(path, options, print, { tryInline: false }); + const inExpressionPosition = isDeclarationExpressionNode(path.node); + const id = path.node.id ? [" ", path.call(print, "id")] : ""; + const decorators = inExpressionPosition + ? "" + : printDecorators(path, options, print, { tryInline: false }).decorators; const generic = printTemplateParameterDeclarations(path, options, print, "templateParameters"); const heritage = printHeritageClause(path, print, "extends", "extends"); return [ decorators, printModifiers(path, options, print), - "union ", + "union", id, generic, heritage, @@ -1149,17 +1237,25 @@ export function printModelStatement( print: PrettierChildPrint, ) { const node = path.node; - const id = path.call(print, "id"); + const id = node.id ? [" ", path.call(print, "id")] : ""; const heritage = printHeritageClause(path, print, "extends", "extends"); const isBase = printHeritageClause(path, print, "is", "is"); const generic = printTemplateParameterDeclarations(path, options, print, "templateParameters"); const nodeHasComments = hasComments(node, CommentCheckFlags.Dangling); const shouldPrintBody = nodeHasComments || !(node.properties.length === 0 && node.is); - const body = shouldPrintBody ? [" ", printModelPropertiesBlock(path, options, print)] : ";"; + const inExpressionPosition = isDeclarationExpressionNode(node); + const body = shouldPrintBody + ? [" ", printModelPropertiesBlock(path, options, print)] + : inExpressionPosition + ? "" + : ";"; + const decorators = inExpressionPosition + ? "" + : printDecorators(path, options, print, { tryInline: false }).decorators; return [ - printDecorators(path, options, print, { tryInline: false }).decorators, + decorators, printModifiers(path, options, print), - "model ", + "model", id, generic, heritage, @@ -1284,6 +1380,7 @@ function isModelAValue(path: AstPath): boolean { do { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.AliasStatement: case SyntaxKind.OperationStatement: return false; @@ -1338,18 +1435,26 @@ function printScalarStatement( print: PrettierChildPrint, ) { const node = path.node; - const id = path.call(print, "id"); + const id = node.id ? [" ", path.call(print, "id")] : ""; const template = printTemplateParameterDeclarations(path, options, print, "templateParameters"); const heritage = printHeritageClause(path, print, "extends", "extends"); const nodeHasComments = hasComments(node, CommentCheckFlags.Dangling); const shouldPrintBody = nodeHasComments || !(node.members.length === 0); - const members = shouldPrintBody ? [" ", printScalarBody(path, options, print)] : ";"; + const inExpressionPosition = isDeclarationExpressionNode(node); + const members = shouldPrintBody + ? [" ", printScalarBody(path, options, print)] + : inExpressionPosition + ? "" + : ";"; + const decorators = inExpressionPosition + ? "" + : printDecorators(path, options, print, { tryInline: false }).decorators; return [ - printDecorators(path, options, print, { tryInline: false }).decorators, + decorators, printModifiers(path, options, print), - "scalar ", + "scalar", id, template, heritage, @@ -1687,7 +1792,7 @@ function printFunctionParameterDeclaration( } export function printModifiers( - path: AstPath, + path: AstPath<(DeclarationNode | OptionallyNamedDeclarationNode) & Node>, options: TypeSpecPrettierOptions, print: PrettierChildPrint, ): Doc { diff --git a/packages/compiler/src/server/classify.ts b/packages/compiler/src/server/classify.ts index ac3c7053c33..3f95abaf872 100644 --- a/packages/compiler/src/server/classify.ts +++ b/packages/compiler/src/server/classify.ts @@ -223,10 +223,12 @@ export function getSemanticTokens(ast: TypeSpecScriptNode): SemanticToken[] { classify(node.id, SemanticTokenKind.Struct); break; case SyntaxKind.ModelStatement: - classify(node.id, SemanticTokenKind.Struct); + case SyntaxKind.ModelDeclarationExpression: + if (node.id) classify(node.id, SemanticTokenKind.Struct); break; case SyntaxKind.ScalarStatement: - classify(node.id, SemanticTokenKind.Type); + case SyntaxKind.ScalarDeclarationExpression: + if (node.id) classify(node.id, SemanticTokenKind.Type); break; case SyntaxKind.ScalarConstructor: classify(node.id, SemanticTokenKind.Function); @@ -237,10 +239,12 @@ export function getSemanticTokens(ast: TypeSpecScriptNode): SemanticToken[] { } break; case SyntaxKind.EnumStatement: - classify(node.id, SemanticTokenKind.Enum); + case SyntaxKind.EnumDeclarationExpression: + if (node.id) classify(node.id, SemanticTokenKind.Enum); break; case SyntaxKind.UnionStatement: - classify(node.id, SemanticTokenKind.Enum); + case SyntaxKind.UnionDeclarationExpression: + if (node.id) classify(node.id, SemanticTokenKind.Enum); break; case SyntaxKind.EnumMember: classify(node.id, SemanticTokenKind.EnumMember); diff --git a/packages/compiler/src/server/completion.ts b/packages/compiler/src/server/completion.ts index 2d596830069..3b349ea7a0f 100644 --- a/packages/compiler/src/server/completion.ts +++ b/packages/compiler/src/server/completion.ts @@ -88,7 +88,9 @@ function addCompletionByLookingBackward( preDetail.node, (n) => n.kind === SyntaxKind.ModelStatement || + n.kind === SyntaxKind.ModelDeclarationExpression || n.kind === SyntaxKind.ScalarStatement || + n.kind === SyntaxKind.ScalarDeclarationExpression || n.kind === SyntaxKind.OperationStatement || n.kind === SyntaxKind.InterfaceStatement || n.kind === SyntaxKind.UnionStatement || @@ -104,20 +106,24 @@ function addCompletionByLookingBackwardNode( posDetail: PositionDetail, context: CompletionContext, ): boolean { - const getIdentifierEndPos = (n: IdentifierNode) => { + const getIdentifierEndPos = (n: IdentifierNode | undefined) => { // n.pos === n.end, it means it's a missing identifier, just return -1; - return n.pos === n.end ? -1 : n.end; + return n === undefined || n.pos === n.end ? -1 : n.end; }; const map: { [key in SyntaxKind]?: keyof KeywordArea } = { [SyntaxKind.ModelStatement]: "modelHeader", + [SyntaxKind.ModelDeclarationExpression]: "modelHeader", [SyntaxKind.ScalarStatement]: "scalarHeader", + [SyntaxKind.ScalarDeclarationExpression]: "scalarHeader", [SyntaxKind.OperationStatement]: "operationHeader", [SyntaxKind.InterfaceStatement]: "interfaceHeader", [SyntaxKind.UnionStatement]: "unionHeader", }; switch (preNode?.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: case SyntaxKind.OperationStatement: case SyntaxKind.InterfaceStatement: case SyntaxKind.UnionStatement: @@ -166,6 +172,7 @@ async function AddCompletionNonTrivia( addKeywordCompletion("namespace", context.completions); break; case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: if (positionInRange(posDetail.position, node.bodyRange)) { addKeywordCompletion("scalarBody", context.completions); } @@ -180,6 +187,7 @@ async function AddCompletionNonTrivia( } break; case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ObjectLiteral: case SyntaxKind.ModelExpression: await addModelCompletion(context, posDetail); @@ -372,6 +380,7 @@ async function addModelCompletion(context: CompletionContext, posDetail: Positio if ( !node || (node.kind !== SyntaxKind.ModelStatement && + node.kind !== SyntaxKind.ModelDeclarationExpression && node.kind !== SyntaxKind.ModelExpression && node.kind !== SyntaxKind.ObjectLiteral) ) { @@ -545,7 +554,9 @@ function addDirectiveCompletion({ completions }: CompletionContext, node: Identi function getCompletionItemKind(program: Program, target: Type): CompletionItemKind { switch (target.node?.kind) { case SyntaxKind.EnumStatement: + case SyntaxKind.EnumDeclarationExpression: case SyntaxKind.UnionStatement: + case SyntaxKind.UnionDeclarationExpression: return CompletionItemKind.Enum; case SyntaxKind.EnumMember: case SyntaxKind.UnionVariant: @@ -553,8 +564,10 @@ function getCompletionItemKind(program: Program, target: Type): CompletionItemKi case SyntaxKind.AliasStatement: return CompletionItemKind.Variable; case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: return CompletionItemKind.Class; case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: return CompletionItemKind.Unit; case SyntaxKind.ModelProperty: return CompletionItemKind.Field; diff --git a/packages/compiler/src/server/tmlanguage.ts b/packages/compiler/src/server/tmlanguage.ts index f766da9433b..d0e0d359b29 100644 --- a/packages/compiler/src/server/tmlanguage.ts +++ b/packages/compiler/src/server/tmlanguage.ts @@ -68,7 +68,10 @@ const identifier = `${simpleIdentifier}|${escapedIdentifier}`; const qualifiedIdentifier = `\\b${identifierStart}(?:${identifierContinue}|\\.${identifierStart})*\\b`; const stringPattern = '\\"(?:[^\\"\\\\]|\\\\.)*\\"'; const modifierKeyword = `\\b(?:extern|internal)\\b`; -const statementKeyword = `\\b(?:namespace|model|op|using|import|enum|alias|union|interface|dec|fn)\\b`; +// Keywords that begin a statement. Used as a heuristic terminator for expressions. +// `model`, `enum` and `union` are intentionally excluded because they can now appear +// in expression position (declarations-as-expressions) and must not terminate an expression. +const statementKeyword = `\\b(?:namespace|op|using|import|alias|interface|dec|fn)\\b`; const universalEnd = `(?=,|;|@|#[a-z]|\\)|\\}|${modifierKeyword}|${statementKeyword})`; const universalEndExceptComma = `(?=;|@|\\)|\\}|${modifierKeyword}|${statementKeyword})`; @@ -724,6 +727,71 @@ const unionStatement: BeginEndRule = { ], }; +// Declarations used in expression position (e.g. `alias Foo = enum { a, b }`). +// The name is optional since these can be anonymous when used as an expression. +const modelExpressionKeyword: BeginEndRule = { + key: "model-expression-keyword", + scope: meta, + begin: `\\b(model)\\b(?:\\s+(?!extends\\b|is\\b)(${identifier}))?`, + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + "2": { scope: "entity.name.type.tsp" }, + }, + end: `(?<=\\})|${universalEnd}`, + patterns: [ + token, + typeParameters, + modelHeritage, // before expression or `extends` or `is` will look like type name + expression, // enough to match type parameters and body. + ], +}; + +const scalarExpression: BeginEndRule = { + key: "scalar-expression", + scope: meta, + begin: `\\b(scalar)\\b(?:\\s+(?!extends\\b)(${identifier}))?`, + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + "2": { scope: "entity.name.type.tsp" }, + }, + end: `(?<=\\})|${universalEnd}`, + patterns: [ + token, + typeParameters, + scalarExtends, // before expression or `extends` will look like type name + scalarBody, + ], +}; + +const enumExpression: BeginEndRule = { + key: "enum-expression", + scope: meta, + begin: `\\b(enum)\\b(?:\\s+(${identifier}))?`, + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + "2": { scope: "entity.name.type.tsp" }, + }, + end: `(?<=\\})|${universalEnd}`, + patterns: [token, enumBody], +}; + +const unionExpression: BeginEndRule = { + key: "union-expression", + scope: meta, + begin: `\\b(union)\\b(?:\\s+(?!extends\\b)(${identifier}))?`, + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + "2": { scope: "entity.name.type.tsp" }, + }, + end: `(?<=\\})|${universalEnd}`, + patterns: [ + token, + typeParameters, + unionExtends, // before unionBody or `extends` will look like a type name + unionBody, + ], +}; + const aliasAssignment: BeginEndRule = { key: "alias-id", scope: meta, @@ -949,6 +1017,10 @@ expression.patterns = [ objectLiteral, tupleLiteral, tupleExpression, + modelExpressionKeyword, + scalarExpression, + enumExpression, + unionExpression, modelExpression, callExpression, identifierExpression, diff --git a/packages/compiler/src/server/type-signature.ts b/packages/compiler/src/server/type-signature.ts index 338327a6f5e..7ba735d7493 100644 --- a/packages/compiler/src/server/type-signature.ts +++ b/packages/compiler/src/server/type-signature.ts @@ -203,7 +203,12 @@ function getModelSignature(type: Model, includeBody: boolean): string { } return `${type.kind.toLowerCase()} ${getPrintableTypeName(type)}{\n${propDesc.map((d) => `${d};`).join("\n")}\n}`; } else { - if (type.node && type.node.kind === SyntaxKind.ModelStatement && type.node.templateParameters) { + if ( + type.node && + (type.node.kind === SyntaxKind.ModelStatement || + type.node.kind === SyntaxKind.ModelDeclarationExpression) && + type.node.templateParameters + ) { type.node.templateParameters.forEach((t) => { if (t.default) { getRawTextWithCache(t); diff --git a/packages/compiler/src/testing/tester.ts b/packages/compiler/src/testing/tester.ts index 1203c86b951..4b4cfb8d2a4 100644 --- a/packages/compiler/src/testing/tester.ts +++ b/packages/compiler/src/testing/tester.ts @@ -1,6 +1,8 @@ import { realpath } from "fs/promises"; import { pathToFileURL } from "url"; +import type { TypeSpecConfig } from "../config/types.js"; import { compilerAssert } from "../core/diagnostics.js"; +import type { CompilerFeatureName } from "../core/features.js"; import { getEntityName } from "../core/helpers/type-name-utils.js"; import { NodeHost } from "../core/node-host.js"; import type { CompilerOptions } from "../core/options.js"; @@ -41,6 +43,17 @@ import type { export interface TesterOptions { libraries: string[]; + /** + * Default compiler options applied to every compilation. Per-call options are merged on top. + */ + compilerOptions?: CompilerOptions; + + /** + * Compiler features to enable for every compilation (e.g. experimental features gated behind a + * `features` entry in `tspconfig.yaml`). Equivalent to setting `features` in the project config. + */ + features?: CompilerFeatureName[]; + /** * System host for loading libraries * @internal @@ -51,9 +64,41 @@ export function createTester(base: string, options: TesterOptions): Tester { return createTesterInternal({ fs: once(() => createTesterFs(base, options)), libraries: options.libraries, + compilerOptions: resolveTesterCompilerOptions(options), }); } +function resolveTesterCompilerOptions(options: TesterOptions): CompilerOptions | undefined { + if (options.features === undefined) { + return options.compilerOptions; + } + // The tester compiles directly without resolving a `tspconfig.yaml`, so the enabled features have + // to be injected into the synthetic `configFile` the compiler reads them from. Only `features` is + // relevant here, hence the cast to the otherwise more complete `TypeSpecConfig`. + const configFile = { + ...options.compilerOptions?.configFile, + features: [...(options.compilerOptions?.configFile?.features ?? []), ...options.features], + } as TypeSpecConfig; + return { ...options.compilerOptions, configFile }; +} + +/** + * Merge per-call compiler options on top of the tester defaults. + * + * `configFile` is merged as well so that per-call options (e.g. `emit`) don't drop the features + * or config the tester instance was created with. + */ +function mergeCompilerOptions( + base: CompilerOptions | undefined, + override: CompilerOptions | undefined, +): CompilerOptions { + const configFile = + base?.configFile && override?.configFile + ? ({ ...base.configFile, ...override.configFile } as TypeSpecConfig) + : (override?.configFile ?? base?.configFile); + return { ...base, ...override, ...(configFile && { configFile }) }; +} + function once(fn: () => Promise): () => Promise { let load: Promise | undefined; return () => { @@ -314,8 +359,7 @@ async function createEmitterTesterInstance( const resolvedOptions: TestCompileOptions = { ...options, compilerOptions: { - ...params.compilerOptions, - ...options?.compilerOptions, + ...mergeCompilerOptions(params.compilerOptions, options?.compilerOptions), outputDir: "tsp-output", emit: [params.emitter], }, @@ -433,7 +477,7 @@ async function createTesterInstance(params: TesterInternalParams): Promise({ name: desc.name, decorators: decoratorApplication(this, desc.decorators), members: createRekeyableMap(), + expression: resolveExpressionFlag("enum", desc.name, desc.expression), }); if (Array.isArray(desc.members)) { diff --git a/packages/compiler/src/typekit/kits/model.ts b/packages/compiler/src/typekit/kits/model.ts index d75a1ee7695..289d6e859c4 100644 --- a/packages/compiler/src/typekit/kits/model.ts +++ b/packages/compiler/src/typekit/kits/model.ts @@ -15,18 +15,30 @@ import type { Diagnosable } from "../create-diagnosable.js"; import { createDiagnosable } from "../create-diagnosable.js"; import { defineKit } from "../define-kit.js"; import type { DecoratorArgs } from "../utils.js"; -import { copyMap, decoratorApplication } from "../utils.js"; +import { copyMap, decoratorApplication, resolveExpressionFlag } from "../utils.js"; /** * A descriptor for creating a model. */ export interface ModelDescriptor { /** - * The name of the Model. If name is provided, it is a Model declaration. + * The name of the Model. If a non-empty name is provided, it is a Model declaration. * Otherwise, it is a Model expression. */ name?: string; + /** + * Whether the model is used in expression position (`expression: true`). When + * omitted, this defaults to `true` for an anonymous model (no name or an empty + * name) and `false` for a named one. Set this explicitly to create a *named* + * model declaration expression (a name that is kept on the type but not + * registered in a namespace). + * + * A declaration must have a name, so setting `expression: false` on an anonymous + * model throws. + */ + expression?: boolean; + /** * Decorators to apply to the Model. */ @@ -154,6 +166,7 @@ defineKit({ derivedModels: desc.derivedModels ?? [], sourceModels: desc.sourceModels ?? [], indexer: desc.indexer, + expression: resolveExpressionFlag("model", desc.name, desc.expression), }); this.program.checker.finishType(model); @@ -165,7 +178,7 @@ defineKit({ }, isExpresion(type) { - return type.name === ""; + return type.expression; }, getEffectiveModel(model, filter?: (property: ModelProperty) => boolean) { return getEffectiveModelType(this.program, model, filter); diff --git a/packages/compiler/src/typekit/kits/union.ts b/packages/compiler/src/typekit/kits/union.ts index 70aa5b80e2b..797f699c0fa 100644 --- a/packages/compiler/src/typekit/kits/union.ts +++ b/packages/compiler/src/typekit/kits/union.ts @@ -8,18 +8,30 @@ import type { Diagnosable } from "../create-diagnosable.js"; import { createDiagnosable } from "../create-diagnosable.js"; import { defineKit } from "../define-kit.js"; import type { DecoratorArgs } from "../utils.js"; -import { decoratorApplication } from "../utils.js"; +import { decoratorApplication, resolveExpressionFlag } from "../utils.js"; /** * A descriptor for a union type. */ export interface UnionDescriptor { /** - * The name of the union. If name is provided, it is a union declaration. + * The name of the union. If a non-empty name is provided, it is a union declaration. * Otherwise, it is a union expression. */ name?: string; + /** + * Whether the union is used in expression position (`expression: true`). When + * omitted, this defaults to `true` for an anonymous union (no name or an empty + * name) and `false` for a named one. Set this explicitly to create a *named* + * union declaration expression (a name that is kept on the type but not + * registered in a namespace). + * + * A declaration must have a name, so setting `expression: false` on an anonymous + * union throws. + */ + expression?: boolean; + /** * Decorators to apply to the union. */ @@ -154,7 +166,7 @@ export const UnionKit = defineKit({ get options() { return Array.from(this.variants.values()).map((v) => v.type); }, - expression: descriptor.name === undefined, + expression: resolveExpressionFlag("union", descriptor.name, descriptor.expression), }); if (Array.isArray(descriptor.variants)) { @@ -238,7 +250,7 @@ export const UnionKit = defineKit({ }, isExpression(type) { - return type.name === undefined || type.name === ""; + return type.expression; }, getDiscriminatedUnion: createDiagnosable(function (type) { return getDiscriminatedUnion(this.program, type); diff --git a/packages/compiler/src/typekit/utils.ts b/packages/compiler/src/typekit/utils.ts index 23ffbc97eb8..1df38b4cad2 100644 --- a/packages/compiler/src/typekit/utils.ts +++ b/packages/compiler/src/typekit/utils.ts @@ -11,6 +11,30 @@ export function copyMap(map: RekeyableMap): RekeyableMap { return createRekeyableMap(map.entries()); } +/** + * Resolve the `expression` flag for a created type. + * + * A declaration must have a name, so an anonymous type is always an expression. Passing + * `expression: false` without a name is a caller error and is rejected. + * + * @internal + */ +export function resolveExpressionFlag( + kind: string, + name: string | undefined, + expression: boolean | undefined, +): boolean { + if (!name) { + if (expression === false) { + throw new Error( + `Cannot create an anonymous ${kind} declaration. Provide a name or leave \`expression\` unset.`, + ); + } + return true; + } + return expression ?? false; +} + /** * Decorator arguments can be either a single decorator (treated as a decorator with no arguments) or an array where the * first element is the decorator and the rest of the elements are the JavaScript values of the arguments. diff --git a/packages/compiler/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts new file mode 100644 index 00000000000..7c5b2ebd06b --- /dev/null +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -0,0 +1,810 @@ +import { describe, expect, it } from "vitest"; +import type { Enum, Model, Scalar, Type, Union } from "../../src/core/types.js"; +import { getDoc, getTypeName, resolvePath } from "../../src/index.js"; +import { expectDiagnosticEmpty, expectDiagnostics, mockFile, t } from "../../src/testing/index.js"; +import { createTester } from "../../src/testing/tester.js"; +import { Tester as BaseTester } from "../tester.js"; + +/** + * Declaration expressions are an experimental feature gated behind the + * `declaration-expressions` compiler feature. Enable it for these tests so they + * exercise the feature without the experimental warning. + */ +const Tester = createTester(resolvePath(import.meta.dirname, "../.."), { + libraries: [], + features: ["declaration-expressions"], +}); + +/** Declaration expressions + union `extends`, which is gated behind its own feature. */ +const UnionExtendsTester = createTester(resolvePath(import.meta.dirname, "../.."), { + libraries: [], + features: ["declaration-expressions", "union-extends"], +}); + +describe("enum", () => { + it("can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: enum { active, inactive }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(type.kind).toBe("Enum"); + expect(type.name).toBe(""); + expect(type.expression).toBe(true); + expect(type.members.size).toBe(2); + expect(type.members.has("active")).toBe(true); + expect(type.members.has("inactive")).toBe(true); + }); + + it("supports explicit member values", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: enum { active: "a", inactive: "i" }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(type.expression).toBe(true); + expect(type.members.get("active")!.value).toBe("a"); + }); + + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + status: enum { a, b }; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + expect(ns.enums.size).toBe(0); + }); +}); + +describe("union", () => { + it("keyword form can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: union { string, int32 }; + } + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.kind).toBe("Union"); + expect(type.expression).toBe(true); + expect(type.variants.size).toBe(2); + }); + + it("supports named variants", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: union { foo: string, bar: int32 }; + } + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.expression).toBe(true); + expect(type.variants.has("foo")).toBe(true); + expect(type.variants.has("bar")).toBe(true); + }); + + it("keeps its members when used as a `|` operand instead of being flattened", async () => { + // Regression: a keyword-form union is `expression: true`; it must not be flattened + // into the parent `|` union (which would silently drop colliding named variants). + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: union { a: "a1", b: "b1" } | union { a: "a2", c: "c1" }; + } + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.kind).toBe("Union"); + expect(type.variants.size).toBe(2); + for (const variant of type.variants.values()) { + expect((variant.type as Union).kind).toBe("Union"); + expect((variant.type as Union).variants.size).toBe(2); + } + }); + + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + value: union { string, int32 }; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + expect(ns.unions.size).toBe(0); + }); + + describe("extends", () => { + it("constrains the variants of an anonymous union expression", async () => { + const { Foo } = await UnionExtendsTester.compile(t.code` + model PetBase { name: string } + model Cat extends PetBase { meow: boolean } + + model ${t.model("Foo")} { + pet: union extends PetBase { cat: Cat }; + } + `); + const type = Foo.properties.get("pet")!.type as Union; + expect(type.kind).toBe("Union"); + expect(type.expression).toBe(true); + expect(type.baseType?.kind).toBe("Model"); + expect((type.baseType as Model).name).toBe("PetBase"); + }); + + it("constrains the variants of a named union expression", async () => { + const { Foo } = await UnionExtendsTester.compile(t.code` + model ${t.model("Foo")} { + status: union Status extends string { ok: "ok", ko: "ko" }; + } + `); + const type = Foo.properties.get("status")!.type as Union; + expect(type.name).toBe("Status"); + expect(type.expression).toBe(true); + expect((type.baseType as Scalar).name).toBe("string"); + }); + + it("reports a variant that is not assignable to the base type", async () => { + const diagnostics = await UnionExtendsTester.diagnose(` + model Foo { + value: union extends string { ok: "ok", ko: 123 }; + } + `); + expectDiagnostics(diagnostics, { + code: "unassignable", + }); + }); + + it("reports an error when the union-extends feature is not enabled", async () => { + const diagnostics = await Tester.diagnose(` + model Foo { + value: union extends string { ok: "ok" }; + } + `); + expectDiagnostics(diagnostics, { + code: "union-extends-disabled", + severity: "error", + }); + }); + }); +}); + +describe("scalar", () => { + it("can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + unit: scalar extends string; + } + `); + const type = Foo.properties.get("unit")!.type as Scalar; + expect(type.kind).toBe("Scalar"); + expect(type.name).toBe(""); + expect(type.expression).toBe(true); + expect(type.baseScalar?.name).toBe("string"); + }); + + it("supports constructors", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + unit: scalar extends string { + init fromValue(value: string); + }; + } + `); + const type = Foo.properties.get("unit")!.type as Scalar; + expect(type.expression).toBe(true); + expect(type.constructors.has("fromValue")).toBe(true); + }); + + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + unit: scalar extends string; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + expect(ns.scalars.size).toBe(0); + }); +}); + +describe("model", () => { + it("keyword form can be used as a property type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: model { x: string }; + } + `); + const type = Foo.properties.get("value")!.type as Model; + expect(type.kind).toBe("Model"); + expect(type.expression).toBe(true); + expect(type.properties.size).toBe(1); + }); + + it("supports spreading another model", async () => { + const { Foo } = await Tester.compile(t.code` + model Base { b: string } + model ${t.model("Foo")} { + value: model { ...Base, x: string }; + } + `); + const type = Foo.properties.get("value")!.type as Model; + expect(type.expression).toBe(true); + expect(type.properties.has("b")).toBe(true); + expect(type.properties.has("x")).toBe(true); + }); + + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + value: model { x: string }; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + // Only Foo should be registered, not the anonymous model expression. + expect(ns.models.size).toBe(1); + expect(ns.models.has("Foo")).toBe(true); + }); + + it("supports an `is` heritage clause with no body", async () => { + // Regression: an `is` clause with no `{ }` body in expression position must not consume + // the enclosing `;` (which belongs to the alias / property). + const { Foo } = await Tester.compile(t.code` + model Base { a: string; b: int32 } + model ${t.model("Foo")} { + value: model is Base; + } + `); + const type = Foo.properties.get("value")!.type as Model; + expect(type.kind).toBe("Model"); + expect(type.expression).toBe(true); + expect(type.properties.has("a")).toBe(true); + expect(type.properties.has("b")).toBe(true); + }); + + it("supports an `is` heritage clause followed by more properties", async () => { + const { Foo } = await Tester.compile(t.code` + model Base { a: string } + model ${t.model("Foo")} { + value: model is Base; + other: string; + } + `); + expect((Foo.properties.get("value")!.type as Model).properties.has("a")).toBe(true); + expect(Foo.properties.get("other")!.type.kind).toBe("Scalar"); + }); + + it("supports an `is` heritage clause with an additional body", async () => { + const { Foo } = await Tester.compile(t.code` + model Base { a: string } + model ${t.model("Foo")} { + value: model is Base { extra: int32 }; + } + `); + const type = Foo.properties.get("value")!.type as Model; + expect(type.properties.has("a")).toBe(true); + expect(type.properties.has("extra")).toBe(true); + }); +}); + +describe("named declaration expressions", () => { + it("keeps the name on the resulting type", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + m: model Inner { x: string }; + e: enum Color { red }; + s: scalar Celsius extends int32; + u: union Choice { string, int32 }; + } + `); + expect((Foo.properties.get("m")!.type as Model).name).toBe("Inner"); + expect((Foo.properties.get("e")!.type as Enum).name).toBe("Color"); + expect((Foo.properties.get("s")!.type as Scalar).name).toBe("Celsius"); + expect((Foo.properties.get("u")!.type as Union).name).toBe("Choice"); + }); + + it("is still marked as an expression", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + m: model Inner { x: string }; + } + `); + const type = Foo.properties.get("m")!.type as Model; + expect(type.name).toBe("Inner"); + expect(type.expression).toBe(true); + }); + + it("is not registered in the namespace", async () => { + const { program } = await Tester.compile(` + namespace Ns; + model Foo { + m: model Inner { x: string }; + } + `); + const ns = program.getGlobalNamespaceType().namespaces.get("Ns")!; + expect(ns.models.size).toBe(1); + expect(ns.models.has("Foo")).toBe(true); + expect(ns.models.has("Inner")).toBe(false); + }); + + it("cannot be referenced by its name", async () => { + const diagnostics = await Tester.diagnose(` + alias M = model Inner { x: string }; + model Use { y: Inner } + `); + expectDiagnostics(diagnostics, { + code: "invalid-ref", + message: "Unknown identifier Inner", + }); + }); +}); + +describe("statement declarations are not expressions", () => { + it("marks model/enum/union/scalar statements with expression: false", async () => { + const { M, E, S, U } = await Tester.compile(t.code` + model ${t.model("M")} {} + enum ${t.enum("E")} { a } + scalar ${t.scalar("S")} extends string; + union ${t.union("U")} { string } + `); + expect(M.expression).toBe(false); + expect(E.expression).toBe(false); + expect(S.expression).toBe(false); + expect(U.expression).toBe(false); + }); +}); + +describe("usage contexts", () => { + it("resolves through an alias and keeps expression: true", async () => { + const { Foo } = await Tester.compile(t.code` + alias E = enum { a, b }; + alias U = union { string, int32 }; + alias S = scalar extends string; + alias M = model { x: string }; + model ${t.model("Foo")} { + e: E; + u: U; + s: S; + m: M; + } + `); + expect((Foo.properties.get("e")!.type as Enum).expression).toBe(true); + expect((Foo.properties.get("u")!.type as Union).expression).toBe(true); + expect((Foo.properties.get("s")!.type as Scalar).expression).toBe(true); + expect((Foo.properties.get("m")!.type as Model).expression).toBe(true); + }); + + it("can reference an enclosing template parameter", async () => { + const { Bar } = await Tester.compile(t.code` + model Wrapper { + nested: model { item: T }; + } + model ${t.model("Bar")} { + w: Wrapper; + } + `); + const wrapper = Bar.properties.get("w")!.type as Model; + const nested = wrapper.properties.get("nested")!.type as Model; + expect(nested.expression).toBe(true); + expect((nested.properties.get("item")!.type as Scalar).name).toBe("int32"); + }); + + it("can be used as an operation return type", async () => { + const { test } = await Tester.compile(t.code` + op ${t.op("test")}(): enum { a, b }; + `); + const returnType = test.returnType as Enum; + expect(returnType.kind).toBe("Enum"); + expect(returnType.expression).toBe(true); + }); + + it("can be used as an operation parameter type", async () => { + const { test } = await Tester.compile(t.code` + op ${t.op("test")}(value: model { x: string }): void; + `); + const paramType = test.parameters.properties.get("value")!.type as Model; + expect(paramType.kind).toBe("Model"); + expect(paramType.expression).toBe(true); + }); + + it("can be used as a union variant", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: string | enum { a, b }; + } + `); + const union = Foo.properties.get("value")!.type as Union; + expect(union.kind).toBe("Union"); + const variants = [...union.variants.values()]; + const enumVariant = variants.find((v) => (v.type as Enum).kind === "Enum")!; + expect((enumVariant.type as Enum).expression).toBe(true); + }); + + it("can be nested inside another declaration expression", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: model { inner: enum { a, b } }; + } + `); + const model = Foo.properties.get("value")!.type as Model; + expect(model.expression).toBe(true); + const inner = model.properties.get("inner")!.type as Enum; + expect(inner.kind).toBe("Enum"); + expect(inner.expression).toBe(true); + }); + + it("allows member access of an anonymous expression through an alias", async () => { + const { Foo } = await Tester.compile(t.code` + alias E = enum { a, b }; + alias A = E.a; + model ${t.model("Foo")} { + value: A; + } + `); + expect(Foo.properties.get("value")!.type.kind).toBe("EnumMember"); + }); + + it("compiles without diagnostics when used in alias position", async () => { + const diagnostics = await Tester.diagnose(` + alias E = enum { a, b }; + alias U = union { string, int32 }; + alias S = scalar extends string; + alias M = model { x: string }; + `); + expectDiagnosticEmpty(diagnostics); + }); +}); + +describe("type name", () => { + it("renders anonymous expressions inline and is not namespace-qualified", async () => { + const { Foo } = await Tester.compile(t.code` + namespace Ns; + model ${t.model("Foo")} { + modelProp: model { x: string }; + enumProp: enum { a, b }; + scalarProp: scalar extends string; + unionProp: union { string, int32 }; + } + `); + expect(getTypeName(Foo.properties.get("modelProp")!.type)).toBe("{ x: string }"); + expect(getTypeName(Foo.properties.get("enumProp")!.type)).toBe("{ a, b }"); + expect(getTypeName(Foo.properties.get("scalarProp")!.type)).toBe("scalar extends string"); + expect(getTypeName(Foo.properties.get("unionProp")!.type)).toBe("string | int32"); + }); + + it("renders a named expression by its name without a namespace prefix", async () => { + const { Foo } = await Tester.compile(t.code` + namespace Ns; + model ${t.model("Foo")} { + named: enum Color { red }; + } + `); + expect(getTypeName(Foo.properties.get("named")!.type)).toBe("Color"); + }); +}); + +describe("decorators", () => { + it("applies a decorator to an anonymous enum expression", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: @doc("the status") enum { active, inactive }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(type.kind).toBe("Enum"); + expect(type.expression).toBe(true); + expect(getDoc(program, type)).toBe("the status"); + }); + + it("applies a decorator to a named model declaration expression", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + inner: @doc("the inner") model Inner { x: string }; + } + `); + const type = Foo.properties.get("inner")!.type as Model; + expect(type.kind).toBe("Model"); + expect(type.name).toBe("Inner"); + expect(type.expression).toBe(true); + expect(getDoc(program, type)).toBe("the inner"); + }); + + it("applies a decorator to a keyword union expression", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: @doc("the value") union { string, int32 }; + } + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.kind).toBe("Union"); + expect(type.expression).toBe(true); + expect(getDoc(program, type)).toBe("the value"); + }); + + it("applies a decorator to a scalar expression", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + unit: @doc("the unit") scalar extends string; + } + `); + const type = Foo.properties.get("unit")!.type as Scalar; + expect(type.kind).toBe("Scalar"); + expect(type.expression).toBe(true); + expect(getDoc(program, type)).toBe("the unit"); + }); + + it("still rejects a decorator before a non-declaration expression", async () => { + const diagnostics = await Tester.diagnose(` + model Foo { + prop: @doc("nope") string; + } + `); + expectDiagnostics(diagnostics, { + code: "invalid-decorator-location", + message: "Cannot decorate expression.", + }); + }); + + it("allows decorators on members inside the expression", async () => { + const { Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: enum { @doc("first") a, b }; + } + `); + const type = Foo.properties.get("value")!.type as Enum; + expect(type.expression).toBe(true); + expect(type.members.has("a")).toBe(true); + }); +}); + +describe("augment decorators", () => { + it("can augment a named model declaration expression via ::type", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + inner: model Inner { x: string }; + } + @@doc(Foo.inner::type, "augmented"); + `); + const type = Foo.properties.get("inner")!.type as Model; + expect(type.kind).toBe("Model"); + expect(getDoc(program, type)).toBe("augmented"); + }); + + it("can augment an anonymous enum declaration expression via ::type", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: enum { active, inactive }; + } + @@doc(Foo.status::type, "augmented"); + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(type.kind).toBe("Enum"); + expect(getDoc(program, type)).toBe("augmented"); + }); + + it("can augment a union declaration expression via ::type", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + value: union { string, int32 }; + } + @@doc(Foo.value::type, "augmented"); + `); + const type = Foo.properties.get("value")!.type as Union; + expect(type.kind).toBe("Union"); + expect(getDoc(program, type)).toBe("augmented"); + }); + + it("can augment a scalar declaration expression via ::type", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + unit: scalar Celsius extends int32; + } + @@doc(Foo.unit::type, "augmented"); + `); + const type = Foo.properties.get("unit")!.type as Scalar; + expect(type.kind).toBe("Scalar"); + expect(getDoc(program, type)).toBe("augmented"); + }); + + it("still rejects augmenting an anonymous model expression", async () => { + const diagnostics = await Tester.diagnose(` + alias M = { x: string }; + @@doc(M, "nope"); + `); + expectDiagnostics(diagnostics, { + code: "augment-decorator-target", + message: "Cannot augment model expressions.", + }); + }); + + it("still rejects augmenting a union expression", async () => { + const diagnostics = await Tester.diagnose(` + alias U = string | int32; + @@doc(U, "nope"); + `); + expectDiagnostics(diagnostics, { + code: "augment-decorator-target", + message: "Cannot augment union expressions.", + }); + }); +}); + +describe("template parameters are not allowed in expression position", () => { + it("reports a diagnostic for a templated model expression", async () => { + const diagnostics = await Tester.diagnose(`alias M = model Foo { x: T };`); + expectDiagnostics(diagnostics, { + code: "templated-declaration-in-expression", + }); + }); + + it("reports a diagnostic for a templated union expression", async () => { + const diagnostics = await Tester.diagnose(`alias U = union Foo { x: T };`); + expectDiagnostics(diagnostics, { + code: "templated-declaration-in-expression", + }); + }); + + it("reports a diagnostic for a templated scalar expression", async () => { + const diagnostics = await Tester.diagnose(`alias S = scalar Foo extends string;`); + expectDiagnostics(diagnostics, { + code: "templated-declaration-in-expression", + }); + }); + + it("still allows template parameters in statement position", async () => { + const diagnostics = await Tester.diagnose(`model Foo { x: T }`); + expectDiagnosticEmpty(diagnostics); + }); +}); +describe("as a decorator argument", () => { + async function captureDecoratorArg(code: string) { + let received: Type | undefined; + const result = await Tester.files({ + "test.js": mockFile.js({ + $useType(_: any, _target: Type, value: Type) { + received = value; + }, + }), + }) + .import("./test.js") + .compile(code); + return { received, program: result.program }; + } + + it("passes an anonymous declaration expression to the decorator", async () => { + const { received } = await captureDecoratorArg(` + extern dec useType(target: unknown, value: unknown); + @useType(enum { red, green }) + model Foo {} + `); + const type = received as Enum; + expect(type.kind).toBe("Enum"); + expect(type.expression).toBe(true); + expect(type.members.has("red")).toBe(true); + }); + + it("passes a named declaration expression to the decorator", async () => { + const { received } = await captureDecoratorArg(` + extern dec useType(target: unknown, value: unknown); + @useType(model Inner { x: string }) + model Foo {} + `); + const type = received as Model; + expect(type.kind).toBe("Model"); + expect(type.name).toBe("Inner"); + expect(type.expression).toBe(true); + expect(type.properties.has("x")).toBe(true); + }); + + it("applies a decorator to a declaration expression used as a decorator argument", async () => { + const { received, program } = await captureDecoratorArg(` + extern dec useType(target: unknown, value: unknown); + @useType(@doc("the versions") enum Versions { v1, v2 }) + model Foo {} + `); + const type = received as Enum; + expect(type.kind).toBe("Enum"); + expect(getDoc(program, type)).toBe("the versions"); + }); +}); + +describe("doc comments", () => { + it("applies an inline doc comment to a declaration expression like @doc", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: /** the status */ enum { active, inactive }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(getDoc(program, type)).toBe("the status"); + }); + + it("lets an explicit @doc override the doc comment on a declaration expression", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + status: /** comment */ @doc("explicit") enum { active, inactive }; + } + `); + const type = Foo.properties.get("status")!.type as Enum; + expect(getDoc(program, type)).toBe("explicit"); + }); + + it("does not apply a leading property doc comment to the declaration expression type", async () => { + const { program, Foo } = await Tester.compile(t.code` + model ${t.model("Foo")} { + /** the status property */ + status: enum { active, inactive }; + } + `); + const prop = Foo.properties.get("status")!; + expect(getDoc(program, prop)).toBe("the status property"); + expect(getDoc(program, prop.type)).toBeUndefined(); + }); +}); + +describe("feature flag", () => { + it("reports an error when the feature is not enabled", async () => { + const diagnostics = await BaseTester.diagnose(` + model Foo { + status: enum { active, inactive }; + } + `); + expectDiagnostics(diagnostics, { + code: "declaration-expression-disabled", + severity: "error", + }); + }); + + it("reports the error for each kind of declaration expression", async () => { + const diagnostics = await BaseTester.diagnose(` + model Foo { + a: model { x: string }; + b: enum { active, inactive }; + c: union { string, int32 }; + d: scalar myStr extends string; + } + `); + const disabled = diagnostics.filter((d) => d.code === "declaration-expression-disabled"); + expect(disabled).toHaveLength(4); + }); + + it("reports the error only once for a declaration expression in a template instantiated multiple times", async () => { + const diagnostics = await BaseTester.diagnose(` + model Foo { + x: model { y: T }; + } + model A is Foo; + model B is Foo; + `); + const disabled = diagnostics.filter((d) => d.code === "declaration-expression-disabled"); + expect(disabled).toHaveLength(1); + }); + + it("does not report the error when the feature is enabled", async () => { + const diagnostics = await Tester.diagnose(` + model Foo { + status: enum { active, inactive }; + } + `); + expectDiagnosticEmpty(diagnostics); + }); +}); + +describe("inside a template", () => { + it("resolves an enum expression decorator argument per instantiation", async () => { + // Regression: an enum declaration expression must be re-checked per template + // instantiation so a decorator that references the template parameter is resolved + // (rather than being frozen at declaration time with the raw template parameter). + const { A, B, program } = await Tester.compile(t.code` + model Box { + e: @doc(T) enum { a }; + } + model ${t.model("A")} is Box<"alpha"> {} + model ${t.model("B")} is Box<"beta"> {} + `); + const aEnum = A.properties.get("e")!.type as Enum; + const bEnum = B.properties.get("e")!.type as Enum; + expect(getDoc(program, aEnum)).toBe("alpha"); + expect(getDoc(program, bEnum)).toBe("beta"); + expect(aEnum).not.toBe(bEnum); + }); +}); diff --git a/packages/compiler/test/core/cli/actions/info.test.ts b/packages/compiler/test/core/cli/actions/info.test.ts index b5ee77abf07..744b20509d2 100644 --- a/packages/compiler/test/core/cli/actions/info.test.ts +++ b/packages/compiler/test/core/cli/actions/info.test.ts @@ -20,9 +20,10 @@ it("lists available compiler features and marks enabled features", () => { expect(output).toEqual([ "Compiler Features", "", - " enabled function-declarations Allows use of function declarations without experimental warnings in project code.", - " disabled auto-decorators Allows use of auto decorator declarations without experimental warnings in project code.", - " disabled type-info-provider Enables the experimental `$provideTypeInfo` provider allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", - " disabled union-extends Enables experimental union `extends` clauses in project code.", + " enabled function-declarations Allows use of function declarations without experimental warnings in project code.", + " disabled declaration-expressions Allows use of declaration expressions (named or anonymous model, scalar, enum and union declarations in expression position) in project code.", + " disabled auto-decorators Allows use of auto decorator declarations without experimental warnings in project code.", + " disabled type-info-provider Enables the experimental `$provideTypeInfo` provider allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", + " disabled union-extends Enables experimental union `extends` clauses in project code.", ]); }); diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index a5db9ba93dd..7853f09231a 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -1790,6 +1790,339 @@ alias Foo = (A & B) | (C & D); }); }); +describe("declaration expressions", () => { + it("formats anonymous enum expression", async () => { + await assertFormat({ + code: `alias E = enum { a, b };`, + expected: ` +alias E = enum { + a, + b, +}; +`, + }); + }); + + it("formats anonymous union expression", async () => { + await assertFormat({ + code: `alias U = union { string, int32 };`, + expected: ` +alias U = union { + string, + int32, +}; +`, + }); + }); + + it("formats anonymous model expression", async () => { + await assertFormat({ + code: `alias M = model { x: string };`, + expected: ` +alias M = model { + x: string; +}; +`, + }); + }); + + it("formats anonymous scalar expression without double semicolon", async () => { + await assertFormat({ + code: `alias S = scalar extends string;`, + expected: `alias S = scalar extends string;`, + }); + }); + + it("formats a model `is` expression without double semicolon", async () => { + await assertFormat({ + code: `alias M = model is Base;`, + expected: `alias M = model is Base;`, + }); + }); + + it("formats a model `is` expression with a body", async () => { + await assertFormat({ + code: `alias M = model is Base { x: string };`, + expected: ` +alias M = model is Base { + x: string; +}; +`, + }); + }); + + it("formats named declaration expression", async () => { + await assertFormat({ + code: `model Foo { nested: model Inner { x: string }; }`, + expected: ` +model Foo { + nested: model Inner { + x: string; + }; +} +`, + }); + }); + + it("formats named enum expression", async () => { + await assertFormat({ + code: `alias E = enum Color {red, green};`, + expected: ` +alias E = enum Color { + red, + green, +}; +`, + }); + }); + + it("formats named union expression", async () => { + await assertFormat({ + code: `alias U = union Choice {string, int32};`, + expected: ` +alias U = union Choice { + string, + int32, +}; +`, + }); + }); + + it("formats union expression with extends", async () => { + await assertFormat({ + code: `alias U = union extends string {"a", "b"};`, + expected: ` +alias U = union extends string { + "a", + "b", +}; +`, + }); + }); + + it("formats named union expression with extends", async () => { + await assertFormat({ + code: `alias U = union Choice extends string {a: "a", b: "b"};`, + expected: ` +alias U = union Choice extends string { + a: "a", + b: "b", +}; +`, + }); + }); + + it("formats named scalar expression", async () => { + await assertFormat({ + code: `alias S = scalar Celsius extends int32;`, + expected: `alias S = scalar Celsius extends int32;`, + }); + }); + + it("formats nested declaration expressions", async () => { + await assertFormat({ + code: `alias N = model { inner: enum { a, b } };`, + expected: ` +alias N = model { + inner: enum { + a, + b, + }; +}; +`, + }); + }); + + it("keeps a decorator inline on an enum expression", async () => { + await assertFormat({ + code: `alias E = @doc("hi")enum { a, b };`, + expected: ` +alias E = @doc("hi") enum { + a, + b, +}; +`, + }); + }); + + it("keeps a decorator inline on a model expression property", async () => { + await assertFormat({ + code: `model Foo { status: @doc("the status") enum { active, inactive }; }`, + expected: ` +model Foo { + status: @doc("the status") enum { + active, + inactive, + }; +} +`, + }); + }); + + it("keeps a decorator inline on a named model expression", async () => { + await assertFormat({ + code: `alias M = @doc("d")model Inner { x: string };`, + expected: ` +alias M = @doc("d") model Inner { + x: string; +}; +`, + }); + }); + + it("keeps a decorator inline on a union expression", async () => { + await assertFormat({ + code: `alias U = @doc("d")union { string, int32 };`, + expected: ` +alias U = @doc("d") union { + string, + int32, +}; +`, + }); + }); + + it("keeps a decorator inline on a scalar expression", async () => { + await assertFormat({ + code: `alias S = @doc("d")scalar Celsius extends int32;`, + expected: `alias S = @doc("d") scalar Celsius extends int32;`, + }); + }); + + it("keeps multiple decorators inline on an enum expression", async () => { + await assertFormat({ + code: `alias E = @doc("hi") @example(1) @friendlyName("E") enum { a, b };`, + expected: ` +alias E = @doc("hi") @example(1) @friendlyName("E") enum { + a, + b, +}; +`, + }); + }); + + it("keeps multiple decorators inline on a model expression property", async () => { + await assertFormat({ + code: `model Foo { status: @a @b @c enum { active, inactive }; }`, + expected: ` +model Foo { + status: @a @b @c enum { + active, + inactive, + }; +} +`, + }); + }); + + it("keeps a doc comment inline on an enum expression like a decorator", async () => { + await assertFormat({ + code: `model Foo { status: /** the status */ enum { active, inactive }; }`, + expected: ` +model Foo { + status: /** the status */ enum { + active, + inactive, + }; +} +`, + }); + }); + + it("keeps a doc comment inline before decorators on a declaration expression", async () => { + await assertFormat({ + code: `alias E = /** doc */ @a @b enum { x, y };`, + expected: ` +alias E = /** doc */ @a @b enum { + x, + y, +}; +`, + }); + }); + + it("formats a declaration expression used as a decorator argument", async () => { + await assertFormat({ + code: `@useType(enum Versions { v1, v2 })\nmodel Foo {}`, + expected: ` +@useType( + enum Versions { + v1, + v2, + } +) +model Foo {} +`, + }); + }); + + it("breaks and indents decorators on a model property when too wide", async () => { + await assertFormat({ + code: `model Foo { status: @summary("a fairly long summary text here") @example("some-default-example-value") enum { active, inactive }; }`, + expected: ` +model Foo { + status: + @summary("a fairly long summary text here") + @example("some-default-example-value") + enum { + active, + inactive, + }; +} +`, + }); + }); + + it("breaks and indents decorators on an alias value when too wide", async () => { + await assertFormat({ + code: `alias E = @summary("a fairly long summary text goes here now") @example("some-default-value") enum { a, b };`, + expected: ` +alias E = + @summary("a fairly long summary text goes here now") + @example("some-default-value") + enum { + a, + b, + }; +`, + }); + }); + + it("breaks and indents a doc comment together with decorators when too wide", async () => { + await assertFormat({ + code: `model Foo { status: /** the current lifecycle status of the entity */ @example("active") enum { active, inactive }; }`, + expected: ` +model Foo { + status: + /** the current lifecycle status of the entity */ + @example("active") + enum { + active, + inactive, + }; +} +`, + }); + }); + + it("stacks decorators inside a decorator argument when too wide", async () => { + await assertFormat({ + code: `@useType(@summary("a long summary for the versions enum value here") @example("v1") enum Versions { v1, v2 })\nmodel Foo {}`, + expected: ` +@useType( + @summary("a long summary for the versions enum value here") + @example("v1") + enum Versions { + v1, + v2, + } +) +model Foo {} +`, + }); + }); +}); + describe("enum", () => { it("format simple enum", async () => { await assertFormat({ diff --git a/packages/compiler/test/parser.test.ts b/packages/compiler/test/parser.test.ts index 70762b3aba2..d442d2f4b35 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -6,6 +6,7 @@ import { formatDiagnostic } from "../src/core/logger/console-sink.js"; import { hasParseError, parse, visitChildren } from "../src/core/parser.js"; import type { IdentifierNode, + ModelStatementNode, Node, ParseOptions, SourceFile, @@ -289,6 +290,71 @@ describe("union declarations", () => { ]); }); +describe("declaration expressions", () => { + parseEach([ + // anonymous keyword declarations in expression position + "alias E = enum { a, b };", + "alias U = union { string, int32 };", + "alias S = scalar extends string;", + "alias M = model { x: string };", + // named keyword declarations in expression position + "alias NE = enum Color { red, green };", + "alias NM = model Inner { x: string };", + // `is` heritage clause in expression position (must not consume the enclosing `;`) + "alias MI = model is Base;", + "alias MIB = model is Base { extra: int32 };", + "model A { value: model is Base }", + "model A { value: model is Base; other: string }", + // nested in model properties + "model A { status: enum { active, inactive } }", + "model A { value: model { x: string } }", + "model A { unit: scalar extends string }", + "model A { value: union { string, int32 } }", + // nested declaration expressions + "alias N = model { inner: enum { a, b } };", + // in decorator arguments + "@useType(enum Versions { v1, v2 }) namespace N {}", + `@example(model { x: 1 }) model A { x: int32; }`, + "@foo(enum { a, b }, model { x: string }) namespace N {}", + "@foo(scalar extends string) namespace N {}", + "@foo(union { string, int32 }) namespace N {}", + // decorators applied to a declaration expression argument + `@useType(@doc("the versions") enum Versions { v1, v2 }) namespace N {}`, + // doc comment applied to a declaration expression argument + `@useType(/** the versions */ enum Versions { v1, v2 }) namespace N {}`, + // in other expression-list positions + "alias T = [enum { a, b }, model { x: string }];", + "model A { x: Template }", + // inline decorators on a declaration expression + `model A { status: @doc("the status") enum { active, inactive } }`, + `model A { status: @a @b @c enum { active, inactive } }`, + // doc comments on a declaration expression (applied like decorators) + `model A { status: /** the status */ enum { active, inactive } }`, + `alias E = /** doc */ @a enum { a, b };`, + ]); + + // interface and operation are intentionally NOT allowed in expression position + parseErrorEach([ + ["alias I = interface { foo(): void };", [/Keyword cannot be used as identifier/]], + ["alias O = op (): void;", [/Keyword cannot be used as identifier/]], + ["model A { x: interface {} }", [/Keyword cannot be used as identifier/]], + ]); + + it("recovers an unclosed expression list at a following declaration keyword", () => { + // The unclosed `@foo(` must not swallow the following `model After` statement: a + // declaration keyword after an element (with no delimiter) ends the list under the + // assumption that the close token is missing. + const tree = parse(`@foo(model Inner { x: string }\nmodel After {}`); + const modelNames = tree.statements + .filter((s): s is ModelStatementNode => s.kind === SyntaxKind.ModelStatement) + .map((s) => s.id.sv); + ok( + modelNames.includes("After"), + `expected 'After' to be recovered as a statement, got [${modelNames.join(", ")}]`, + ); + }); +}); + describe("const statements", () => { parseEach([ `const a = 123;`, @@ -634,7 +700,7 @@ describe("identifiers", () => { (node) => { const statement = node.statements[0]; assert(statement.kind === SyntaxKind.ModelStatement, "Model statement expected."); - assert.strictEqual(statement.id.sv, expected); + assert.strictEqual(statement.id?.sv, expected); }, ]; }), diff --git a/packages/compiler/test/server/colorization.test.ts b/packages/compiler/test/server/colorization.test.ts index 8abdaf25b21..d90d4128942 100644 --- a/packages/compiler/test/server/colorization.test.ts +++ b/packages/compiler/test/server/colorization.test.ts @@ -1157,6 +1157,150 @@ function testColorization(description: string, tokenize: Tokenize) { }); }); + describe("declaration expressions", () => { + it("anonymous enum in alias", async () => { + const tokens = await tokenize("alias Foo = enum { a, b }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.enum, + Token.punctuation.openBrace, + Token.identifiers.variable("a"), + Token.punctuation.comma, + Token.identifiers.variable("b"), + Token.punctuation.closeBrace, + ]); + }); + + it("named enum in alias", async () => { + const tokens = await tokenize("alias Foo = enum Color { red, green }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.enum, + Token.identifiers.type("Color"), + Token.punctuation.openBrace, + Token.identifiers.variable("red"), + Token.punctuation.comma, + Token.identifiers.variable("green"), + Token.punctuation.closeBrace, + ]); + }); + + it("anonymous union in alias", async () => { + const tokens = await tokenize("alias Foo = union { string, int32 }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.union, + Token.punctuation.openBrace, + Token.identifiers.type("string"), + Token.punctuation.comma, + Token.identifiers.type("int32"), + Token.punctuation.closeBrace, + ]); + }); + + it("named union in alias", async () => { + const tokens = await tokenize("alias Foo = union Choice { a: string }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.union, + Token.identifiers.type("Choice"), + Token.punctuation.openBrace, + Token.identifiers.variable("a"), + Token.operators.typeAnnotation, + Token.identifiers.type("string"), + Token.punctuation.closeBrace, + ]); + }); + + it("anonymous union with extends in alias", async () => { + const tokens = await tokenize("alias Foo = union extends string { a: string }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.union, + Token.keywords.extends, + Token.identifiers.type("string"), + Token.punctuation.openBrace, + Token.identifiers.variable("a"), + Token.operators.typeAnnotation, + Token.identifiers.type("string"), + Token.punctuation.closeBrace, + ]); + }); + + it("named union with extends in alias", async () => { + const tokens = await tokenize("alias Foo = union Choice extends string { a: string }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.union, + Token.identifiers.type("Choice"), + Token.keywords.extends, + Token.identifiers.type("string"), + Token.punctuation.openBrace, + Token.identifiers.variable("a"), + Token.operators.typeAnnotation, + Token.identifiers.type("string"), + Token.punctuation.closeBrace, + ]); + }); + + it("anonymous scalar in alias", async () => { + const tokens = await tokenize("alias Foo = scalar extends string"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.scalar, + Token.keywords.extends, + Token.identifiers.type("string"), + ]); + }); + + it("anonymous model in alias", async () => { + const tokens = await tokenize("alias Foo = model { x: string }"); + deepStrictEqual(tokens, [ + Token.keywords.alias, + Token.identifiers.type("Foo"), + Token.operators.assignment, + Token.keywords.model, + Token.punctuation.openBrace, + Token.identifiers.variable("x"), + Token.operators.typeAnnotation, + Token.identifiers.type("string"), + Token.punctuation.closeBrace, + ]); + }); + + it("declaration expression as a model property type", async () => { + const tokens = await tokenize("model Bar { status: enum { active, inactive } }"); + deepStrictEqual(tokens, [ + Token.keywords.model, + Token.identifiers.type("Bar"), + Token.punctuation.openBrace, + Token.identifiers.variable("status"), + Token.operators.typeAnnotation, + Token.keywords.enum, + Token.punctuation.openBrace, + Token.identifiers.variable("active"), + Token.punctuation.comma, + Token.identifiers.variable("inactive"), + Token.punctuation.closeBrace, + Token.punctuation.closeBrace, + ]); + }); + }); + describe("namespaces", () => { it("simple global namespace", async () => { const tokens = await tokenize("namespace Foo;"); diff --git a/packages/compiler/test/server/completion.tspconfig.test.ts b/packages/compiler/test/server/completion.tspconfig.test.ts index 7875e1ada79..574ed0d28be 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -136,6 +136,7 @@ describe("Test completion items for features", () => { config: `features:\n - ┆`, expected: [ '"auto-decorators"', + '"declaration-expressions"', '"function-declarations"', '"type-info-provider"', '"union-extends"', @@ -143,15 +144,32 @@ describe("Test completion items for features", () => { }, { config: `features:\n - "┆"`, - expected: ["auto-decorators", "function-declarations", "type-info-provider", "union-extends"], + expected: [ + "auto-decorators", + "declaration-expressions", + "function-declarations", + "type-info-provider", + "union-extends", + ], }, { config: `features:\n - "function┆"`, - expected: ["auto-decorators", "function-declarations", "type-info-provider", "union-extends"], + expected: [ + "auto-decorators", + "declaration-expressions", + "function-declarations", + "type-info-provider", + "union-extends", + ], }, { config: `features:\n - function-declarations\n - ┆`, - expected: ['"auto-decorators"', '"type-info-provider"', '"union-extends"'], + expected: [ + '"auto-decorators"', + '"declaration-expressions"', + '"type-info-provider"', + '"union-extends"', + ], }, ])("#%# Test features: $config", async ({ config, expected }) => { await checkCompletionItems(config, true, expected); @@ -163,6 +181,7 @@ describe("Test completion items for features", () => { true, [ "Allows use of auto decorator declarations without experimental warnings in project code.", + "Allows use of declaration expressions (named or anonymous model, scalar, enum and union declarations in expression position) in project code.", "Allows use of function declarations without experimental warnings in project code.", "Enables the experimental `$provideTypeInfo` provider allowing libraries to contribute extra information about types to IDE hover and tooling (queried via `program.getTypeInfo`).", "Enables experimental union `extends` clauses in project code.", diff --git a/packages/compiler/test/testing/rule-tester-codefix.test.ts b/packages/compiler/test/testing/rule-tester-codefix.test.ts index f4970ddb1ed..82a4654e2ef 100644 --- a/packages/compiler/test/testing/rule-tester-codefix.test.ts +++ b/packages/compiler/test/testing/rule-tester-codefix.test.ts @@ -54,7 +54,7 @@ it("toEqual with string asserts single-file code fix on main.tsp", async () => { const tester = await createCodeFixRuleTester(({ model, fixContext }) => { const node = model.node!; if (node.kind !== SyntaxKind.ModelStatement) throw new Error("unexpected"); - return fixContext.replaceText(getSourceLocation(node.id), "Bar"); + return fixContext.replaceText(getSourceLocation(node.id!), "Bar"); }); await tester diff --git a/packages/compiler/test/testing/tester.test.ts b/packages/compiler/test/testing/tester.test.ts index 964b6bf2e7f..6cf52870920 100644 --- a/packages/compiler/test/testing/tester.test.ts +++ b/packages/compiler/test/testing/tester.test.ts @@ -281,6 +281,34 @@ describe("marker position", () => { }); }); +describe("compiler options", () => { + const FeatureTester = createTester(resolvePath(import.meta.dirname, "../.."), { + libraries: [], + features: ["declaration-expressions"], + }); + + it("keeps tester features when per-call options provide their own configFile", async () => { + const diagnostics = await FeatureTester.diagnose( + ` + model Foo { + status: enum { a, b }; + } + `, + { + compilerOptions: { + configFile: { + projectRoot: ".", + kind: "project", + diagnostics: [], + outputDir: "tsp-output", + } as any, + }, + }, + ); + expect(diagnostics).toEqual([]); + }); +}); + describe("emitter", () => { const EmitterTester = Tester.files({ "node_modules/dummy-emitter/package.json": JSON.stringify({ diff --git a/packages/compiler/test/typekit/enum.test.ts b/packages/compiler/test/typekit/enum.test.ts index 7cd19d567fe..f38244b415d 100644 --- a/packages/compiler/test/typekit/enum.test.ts +++ b/packages/compiler/test/typekit/enum.test.ts @@ -48,3 +48,37 @@ it("preserves documentation when copying", async () => { expect(getDoc(program, newEnum.members.get("One")!)).toBe("doc-comment for one"); expect(getDoc(program, newEnum.members.get("Two")!)).toBeUndefined(); }); + +it("creates a named enum as a declaration (expression: false)", () => { + const en = $(program).enum.create({ + name: "Foo", + members: { a: 1, b: 2 }, + }); + expect(en.name).toBe("Foo"); + expect(en.expression).toBe(false); +}); + +it("creates an anonymous enum as an expression (expression: true)", () => { + const en = $(program).enum.create({ + name: "", + members: { a: 1, b: 2 }, + }); + expect(en.name).toBe(""); + expect(en.expression).toBe(true); +}); + +it("throws when creating an anonymous enum declaration", () => { + expect(() => $(program).enum.create({ name: "", expression: false, members: { a: 1 } })).toThrow( + /Cannot create an anonymous enum declaration/, + ); +}); + +it("creates a named enum declaration expression when expression is set explicitly", () => { + const en = $(program).enum.create({ + name: "Color", + expression: true, + members: { red: 1, green: 2 }, + }); + expect(en.name).toBe("Color"); + expect(en.expression).toBe(true); +}); diff --git a/packages/compiler/test/typekit/model.test.ts b/packages/compiler/test/typekit/model.test.ts index 801e0411500..859bceab594 100644 --- a/packages/compiler/test/typekit/model.test.ts +++ b/packages/compiler/test/typekit/model.test.ts @@ -82,3 +82,36 @@ it("can get diagnostics from getDiscriminatedUnion", async () => { code: "missing-discriminator-property", }); }); + +it("creates a named model declaration (expression: false) by default", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + const model = tk.model.create({ name: "Foo", properties: {} }); + expect(model.expression).toBe(false); + expect(tk.model.isExpresion(model)).toBe(false); +}); + +it("creates a model expression when given an empty name", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + const model = tk.model.create({ name: "", properties: {} }); + expect(model.expression).toBe(true); + expect(tk.model.isExpresion(model)).toBe(true); +}); + +it("throws when creating an anonymous model declaration", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + expect(() => tk.model.create({ expression: false, properties: {} })).toThrow( + /Cannot create an anonymous model declaration/, + ); +}); + +it("creates a named model declaration expression when expression is set explicitly", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + const model = tk.model.create({ name: "Inner", expression: true, properties: {} }); + expect(model.name).toBe("Inner"); + expect(model.expression).toBe(true); + expect(tk.model.isExpresion(model)).toBe(true); +}); diff --git a/packages/compiler/test/typekit/union.test.ts b/packages/compiler/test/typekit/union.test.ts index 740b783221b..941b2f9cbe9 100644 --- a/packages/compiler/test/typekit/union.test.ts +++ b/packages/compiler/test/typekit/union.test.ts @@ -178,3 +178,36 @@ it("can check if an entity is a union", async () => { expect(tk.union.is(tk.builtin.string)).toBe(false); expect(tk.union.is(tk.value.create("value"))).toBe(false); }); + +it("creates a named union declaration (expression: false) by default", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + const union = tk.union.create({ name: "Foo", variants: { a: "a" } }); + expect(union.expression).toBe(false); + expect(tk.union.isExpression(union)).toBe(false); +}); + +it("creates a union expression when given an empty name", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + const union = tk.union.create({ name: "", variants: { a: "a" } }); + expect(union.expression).toBe(true); + expect(tk.union.isExpression(union)).toBe(true); +}); + +it("throws when creating an anonymous union declaration", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + expect(() => tk.union.create({ expression: false, variants: { a: "a" } })).toThrow( + /Cannot create an anonymous union declaration/, + ); +}); + +it("creates a named union declaration expression when expression is set explicitly", async () => { + const { program } = await Tester.compile(""); + const tk = $(program); + const union = tk.union.create({ name: "Foo", expression: true, variants: { a: "a" } }); + expect(union.name).toBe("Foo"); + expect(union.expression).toBe(true); + expect(tk.union.isExpression(union)).toBe(true); +}); diff --git a/packages/graphql/src/mutation-engine/mutations/model.ts b/packages/graphql/src/mutation-engine/mutations/model.ts index edf2ee040ae..be45d115322 100644 --- a/packages/graphql/src/mutation-engine/mutations/model.ts +++ b/packages/graphql/src/mutation-engine/mutations/model.ts @@ -94,6 +94,7 @@ export class GraphQLModelMutation extends SimpleModelMutation, JSONSchemaEmitterOptions> { #idDuplicateTracker = new DuplicateTracker(); @@ -732,6 +742,15 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche } #createDeclaration(type: JsonSchemaDeclaration, name: string, schema: ObjectBuilder) { + // An *anonymous* declaration expression (e.g. an inline `enum { ... }` or + // `scalar extends string` used as a property type) has an empty name and is not + // registered in a namespace. It must not be hoisted into an (empty-named) `$defs` + // schema or its own file; returning the schema directly inlines it. A *named* + // declaration expression (e.g. `model Inner { ... }`) keeps its name and is hoisted + // like a regular declaration. + if (isAnonymousExpression(type)) { + return schema; + } const decl = this.emitter.result.declaration(name, schema); const sf = (decl.scope as SourceFileScope).sourceFile; sf.meta.shouldEmit = this.#shouldEmitRootSchema(type); @@ -765,6 +784,10 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche } #shouldEmitRootSchema(type: JsonSchemaDeclaration) { + // Anonymous declaration expressions are inlined, never emitted as a root schema. + if (isAnonymousExpression(type)) { + return false; + } return ( this.emitter.getOptions().emitAllRefs || this.emitter.getOptions().emitAllModels || @@ -1115,6 +1138,11 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche } enumDeclarationContext(en: Enum): Context { + // An anonymous `enum { ... }` expression is inlined into the referencing schema, so + // it must not get its own file scope (which would otherwise be left empty). + if (isAnonymousExpression(en)) { + return {}; + } return this.#newFileScope(en); } @@ -1125,6 +1153,9 @@ export class JsonSchemaEmitter extends TypeEmitter, JSONSche scalarDeclarationContext(scalar: Scalar): Context { if (this.#isStdType(scalar)) { return {}; + } else if (isAnonymousExpression(scalar)) { + // An anonymous `scalar extends ...` expression is inlined, so no file scope. + return {}; } else { return this.#newFileScope(scalar); } diff --git a/packages/json-schema/test/declaration-expressions.test.ts b/packages/json-schema/test/declaration-expressions.test.ts new file mode 100644 index 00000000000..b1bc46a78ad --- /dev/null +++ b/packages/json-schema/test/declaration-expressions.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { emitSchema } from "./utils.js"; + +describe("declaration expressions", () => { + it("inlines an anonymous enum used as a property type", async () => { + const schemas = await emitSchema(` + model Foo { + status: enum { active, inactive }; + } + `); + + // The anonymous enum must not be emitted as its own (empty-named) schema file. + expect(Object.keys(schemas)).toEqual(["Foo.json"]); + const status = schemas["Foo.json"].properties.status; + expect(status.$ref).toBeUndefined(); + expect(status.enum).toEqual(["active", "inactive"]); + }); + + it("inlines an anonymous scalar used as a property type", async () => { + const schemas = await emitSchema(` + model Foo { + unit: scalar extends string; + } + `); + + expect(Object.keys(schemas)).toEqual(["Foo.json"]); + const unit = schemas["Foo.json"].properties.unit; + expect(unit.$ref).toBeUndefined(); + expect(unit.type).toBe("string"); + }); + + it("inlines an anonymous union (keyword form) used as a property type", async () => { + const schemas = await emitSchema(` + model Foo { + value: union { string, int32 }; + } + `); + + expect(Object.keys(schemas)).toEqual(["Foo.json"]); + const value = schemas["Foo.json"].properties.value; + expect(value.$ref).toBeUndefined(); + }); + + it("hoists a named declaration expression into its own schema", async () => { + const schemas = await emitSchema(` + model Foo { + inner: model Inner { x: string }; + } + `); + + // A named declaration expression keeps its name and is hoisted/referenced. + expect(Object.keys(schemas).sort()).toEqual(["Foo.json", "Inner.json"]); + expect(schemas["Foo.json"].properties.inner).toEqual({ $ref: "Inner.json" }); + expect(schemas["Inner.json"].type).toBe("object"); + expect(schemas["Inner.json"].properties.x.type).toBe("string"); + }); +}); diff --git a/packages/json-schema/test/utils.ts b/packages/json-schema/test/utils.ts index 2acd510cd6a..96aecbe83f7 100644 --- a/packages/json-schema/test/utils.ts +++ b/packages/json-schema/test/utils.ts @@ -5,6 +5,7 @@ import type { JSONSchemaEmitterOptions } from "../src/lib.js"; export const ApiTester = createTester(resolvePath(import.meta.dirname, ".."), { libraries: ["@typespec/json-schema"], + features: ["declaration-expressions"], }) .import("@typespec/json-schema") .using("JsonSchema"); diff --git a/packages/openapi/src/helpers.ts b/packages/openapi/src/helpers.ts index b24395860b6..9ca67cb2b41 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -37,6 +37,12 @@ import type { ExtensionKey } from "./types.js"; * * A friendly name can be provided by the user using `@friendlyName` * decorator, or chosen by default in simple cases. + * + * Anonymous declaration expressions (e.g. an inline `enum { ... }` or + * `scalar extends string` used as a property type) have no name and are + * inlined. A *named* declaration expression (e.g. `model Inner { ... }` used as a + * property type) keeps its name and is hoisted into a schema like a regular + * declaration. */ export function shouldInline(program: Program, type: Type): boolean { if (getFriendlyName(program, type)) { @@ -46,7 +52,7 @@ export function shouldInline(program: Program, type: Type): boolean { case "Model": return !type.name || isTemplateInstance(type); case "Scalar": - return program.checker.isStdType(type) || isTemplateInstance(type); + return !type.name || program.checker.isStdType(type) || isTemplateInstance(type); case "Enum": case "Union": return !type.name; diff --git a/packages/openapi3/src/schema-emitter.ts b/packages/openapi3/src/schema-emitter.ts index 42cd11ddf37..f67a0a18f0b 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -954,11 +954,6 @@ export class OpenAPI3SchemaEmitterBase< } #createDeclaration(type: Type, name: string, schema: ObjectBuilder) { - const skipNameValidation = type.kind === "Model" && type.templateMapper !== undefined; - if (!skipNameValidation) { - name = ensureValidComponentFixedFieldKey(this.emitter.getProgram(), type, name); - } - const refUrl = getRef(this.emitter.getProgram(), type); if (refUrl) { return { @@ -970,6 +965,11 @@ export class OpenAPI3SchemaEmitterBase< return this.#inlineType(type, schema); } + const skipNameValidation = type.kind === "Model" && type.templateMapper !== undefined; + if (!skipNameValidation) { + name = ensureValidComponentFixedFieldKey(this.emitter.getProgram(), type, name); + } + const title = getSummary(this.emitter.getProgram(), type); if (title) { setProperty(schema, "title", title); diff --git a/packages/openapi3/test/declaration-expressions.test.ts b/packages/openapi3/test/declaration-expressions.test.ts new file mode 100644 index 00000000000..33658fdaef7 --- /dev/null +++ b/packages/openapi3/test/declaration-expressions.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vitest"; +import { supportedVersions, worksFor } from "./works-for.js"; + +worksFor(supportedVersions, ({ oapiForModel }) => { + it("inlines an anonymous enum used as a property type", async () => { + const res = await oapiForModel("Foo", `model Foo { status: enum { active, inactive }; }`); + const status = res.schemas.Foo.properties.status; + expect(status.$ref).toBeUndefined(); + expect(status.enum).toEqual(["active", "inactive"]); + expect(Object.keys(res.schemas)).toEqual(["Foo"]); + }); + + it("inlines an anonymous scalar used as a property type", async () => { + const res = await oapiForModel("Foo", `model Foo { unit: scalar extends string; }`); + const unit = res.schemas.Foo.properties.unit; + expect(unit.$ref).toBeUndefined(); + expect(unit.type).toBe("string"); + // Regression: an anonymous scalar must not be emitted as an empty-named component. + expect(Object.keys(res.schemas)).toEqual(["Foo"]); + }); + + it("inlines an anonymous union (keyword form) used as a property type", async () => { + const res = await oapiForModel("Foo", `model Foo { value: union { string, int32 }; }`); + const value = res.schemas.Foo.properties.value; + expect(value.$ref).toBeUndefined(); + expect(Object.keys(res.schemas)).toEqual(["Foo"]); + }); + + it("hoists a named declaration expression as a component", async () => { + const res = await oapiForModel("Foo", `model Foo { inner: model Inner { x: string }; }`); + const inner = res.schemas.Foo.properties.inner; + // A named declaration expression keeps its name and is hoisted/referenced. + expect(inner.$ref).toBe("#/components/schemas/Inner"); + expect(res.schemas.Inner.type).toBe("object"); + expect(res.schemas.Inner.properties.x.type).toBe("string"); + expect(Object.keys(res.schemas).sort()).toEqual(["Foo", "Inner"]); + }); +}); diff --git a/packages/openapi3/test/test-host.ts b/packages/openapi3/test/test-host.ts index efb75405e56..40a76acc723 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -23,6 +23,7 @@ export const ApiTester = createTester(resolvePath(import.meta.dirname, ".."), { "@typespec/sse", "@typespec/openapi3", ], + features: ["declaration-expressions"], }); export const SimpleTester = ApiTester.import( diff --git a/packages/spec/src/spec.emu.html b/packages/spec/src/spec.emu.html index bc3a8e27a18..7964ff48695 100644 --- a/packages/spec/src/spec.emu.html +++ b/packages/spec/src/spec.emu.html @@ -504,6 +504,10 @@

Syntactic Grammar

ObjectLiteral ArrayLiteral ModelExpression + ModelDeclarationExpression + ScalarDeclarationExpression + EnumDeclarationExpression + UnionDeclarationExpression TupleExpression FunctionTypeExpression : @@ -572,6 +576,20 @@

Syntactic Grammar

ModelExpression : `{` ModelBody? `}` +ModelDeclarationExpression : + DecoratorList? `model` Identifier? TemplateParameters? ExtendsModelHeritage? IsModelHeritage + DecoratorList? `model` Identifier? TemplateParameters? ModelHeritage? `{` ModelBody? `}` + +ScalarDeclarationExpression : + DecoratorList? `scalar` Identifier? TemplateParameters? ScalarExtends? + DecoratorList? `scalar` Identifier? TemplateParameters? ScalarExtends? `{` ScalarBody? `}` + +EnumDeclarationExpression : + DecoratorList? `enum` Identifier? `{` EnumBody? `}` + +UnionDeclarationExpression : + DecoratorList? `union` Identifier? UnionExtends? `{` UnionBody? `}` + TupleExpression : `[` ExpressionList? `]` diff --git a/packages/versioning/src/validate.ts b/packages/versioning/src/validate.ts index a1cbb8535e6..ae2bab1d07a 100644 --- a/packages/versioning/src/validate.ts +++ b/packages/versioning/src/validate.ts @@ -11,6 +11,7 @@ import { type Type, type TypeNameOptions, } from "@typespec/compiler"; +import { SyntaxKind } from "@typespec/compiler/ast"; import { $added, $removed, @@ -266,13 +267,18 @@ function validateTypeAvailability( } } } else if (type.kind === "Union") { + // Only `|`-operator unions (UnionExpression) have anonymous, symbol-less + // variants with no decorators. Keyword-form unions (`union { ... }`) are also + // `expression: true` when used in expression position, but their variants can be + // named and decorated, so they must go through `validateTargetVersionCompatible`. + const isUnionOperatorExpression = type.node?.kind === SyntaxKind.UnionExpression; for (const variant of type.variants.values()) { - if (type.expression) { + if (isUnionOperatorExpression) { // Union expressions don't have decorators applied, // so we need to check the type directly. typesToCheck.push(variant.type); } else { - // Named unions can have decorators applied, + // Named/keyword unions can have decorators applied, // so we need to check that the variant type is valid // for whatever decoration the variant has. validateTargetVersionCompatible(program, variant, variant.type); diff --git a/packages/versioning/test/declaration-expressions.test.ts b/packages/versioning/test/declaration-expressions.test.ts new file mode 100644 index 00000000000..1fcfb2940ce --- /dev/null +++ b/packages/versioning/test/declaration-expressions.test.ts @@ -0,0 +1,65 @@ +import type { TesterInstance } from "@typespec/compiler/testing"; +import { expectDiagnostics } from "@typespec/compiler/testing"; +import { beforeEach, describe, it } from "vitest"; +import { Tester } from "./test-host.js"; + +// A keyword-form union (`union { ... }`) used in expression position is `expression: true`, +// just like an anonymous `|`-operator union. Its variants can be named and decorated, so +// version-compatibility validation must treat it like a named union (going through +// `validateTargetVersionCompatible`) rather than flattening it like a `|`-operator union. +describe("versioning: declaration expression unions", () => { + let runner: TesterInstance; + + beforeEach(async () => { + runner = await Tester.wrap( + (code) => ` + @versioned(Versions) + namespace TestService { + enum Versions {v1, v2, v3, v4} + ${code} + }`, + ).createInstance(); + }); + + it("validates a keyword-form union expression like a named union", async () => { + const diagnostics = await runner.diagnose(` + @added(Versions.v2) + model Updated {} + + alias KwUnion = union { string, Updated }; + + model Test { + @typeChangedFrom(Versions.v2, KwUnion) + prop: string; + } + `); + + // Regression: before the fix this incorrectly took the `|`-union flatten path and + // reported the type-availability diagnostic instead. + expectDiagnostics(diagnostics, { + code: "@typespec/versioning/incompatible-versioned-reference", + message: + "'TestService.Updated' is referencing versioned type 'TestService.Updated' but is not versioned itself.", + }); + }); + + it("still flattens a `|`-operator union expression", async () => { + const diagnostics = await runner.diagnose(` + @added(Versions.v2) + model Updated {} + + alias OpUnion = string | Updated; + + model Test { + @typeChangedFrom(Versions.v2, OpUnion) + prop: string; + } + `); + + expectDiagnostics(diagnostics, { + code: "@typespec/versioning/incompatible-versioned-reference", + message: + "'TestService.Test.prop' is referencing type 'TestService.Updated' which does not exist in version 'v1'.", + }); + }); +}); diff --git a/packages/versioning/test/test-host.ts b/packages/versioning/test/test-host.ts index 2740ddb0fd7..06ac4a5070c 100644 --- a/packages/versioning/test/test-host.ts +++ b/packages/versioning/test/test-host.ts @@ -3,6 +3,7 @@ import { createTester } from "@typespec/compiler/testing"; export const Tester = createTester(resolvePath(import.meta.dirname, ".."), { libraries: ["@typespec/versioning"], + features: ["declaration-expressions"], }) .importLibraries() .using("Versioning"); diff --git a/website/src/content/docs/docs/language-basics/enums.md b/website/src/content/docs/docs/language-basics/enums.md index cdfab7c02da..2dc365b920f 100644 --- a/website/src/content/docs/docs/language-basics/enums.md +++ b/website/src/content/docs/docs/language-basics/enums.md @@ -78,3 +78,50 @@ You can reference enum members using the `.` operator for identifiers. ```typespec alias North = Direction.North; ``` + +## In expression position + +:::warning +Declaration expressions are an experimental TypeSpec feature and are disabled by default. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position is an error unless you enable them by adding `declaration-expressions` to the `features` list in your `tspconfig.yaml`: + +```yaml +kind: project +features: + - declaration-expressions +``` + +::: + +The `enum` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. + +```typespec +model Task { + // anonymous enum in expression position + status: enum { + active, + inactive, + }; + + // named enum in expression position + priority: enum Priority { + low, + medium, + high, + }; +} +``` + +An enum used in expression position is marked as an expression and is **not** registered in the enclosing namespace, even when it is given a name. The name is kept on the resulting type for display purposes only — it cannot be referenced elsewhere. + +You can apply [decorators](./decorators.md) and doc comments to the declaration inline, and augment it through a navigation reference such as `::type`: + +```typespec +model Task { + status: @doc("The current status") enum { + active, + inactive, + }; +} + +@@doc(Task.status::type, "The current status"); +``` diff --git a/website/src/content/docs/docs/language-basics/models.md b/website/src/content/docs/docs/language-basics/models.md index 23c4eb0b9a6..a98b4bdcd61 100644 --- a/website/src/content/docs/docs/language-basics/models.md +++ b/website/src/content/docs/docs/language-basics/models.md @@ -235,3 +235,53 @@ Some model property meta types can be referenced using `::`. | Name | Example | Description | | ---- | ---------------- | ---------------------------------------- | | type | `Pet.name::type` | Reference the type of the model property | + +## In expression position + +:::warning +Declaration expressions are an experimental TypeSpec feature and are disabled by default. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position is an error unless you enable them by adding `declaration-expressions` to the `features` list in your `tspconfig.yaml`: + +```yaml +kind: project +features: + - declaration-expressions +``` + +::: + +The `model` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. This is different from an inline model expression (`{ ... }`) in that it uses the `model` keyword and can therefore carry a name and an `extends`/`is` heritage clause. + +```typespec +model Widget { + // anonymous model in expression position + config: model { + width: int32; + height: int32; + }; + + // named model in expression position + metadata: model Metadata { + author: string; + }; + + // with a heritage clause + point: model extends Position { + z: float64; + }; +} +``` + +A model used in expression position is marked as an expression and is **not** registered in the enclosing namespace, even when it is given a name. The name is kept on the resulting type for display purposes only — it cannot be referenced elsewhere (and a named model expression cannot reference itself recursively). + +You can apply [decorators](./decorators.md) and doc comments to the declaration inline, and augment it through a navigation reference such as `::type`: + +```typespec +model Widget { + /** The current status */ + status: @doc("The current status") model Status { + code: int32; + }; +} + +@@doc(Widget.status::type, "The current status"); +``` diff --git a/website/src/content/docs/docs/language-basics/scalars.md b/website/src/content/docs/docs/language-basics/scalars.md index 71e88410e03..8b27be0f7e8 100644 --- a/website/src/content/docs/docs/language-basics/scalars.md +++ b/website/src/content/docs/docs/language-basics/scalars.md @@ -72,3 +72,32 @@ model Event { time: plainTime = plainTime.now(); } ``` + +## In expression position + +:::warning +Declaration expressions are an experimental TypeSpec feature and are disabled by default. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position is an error unless you enable them by adding `declaration-expressions` to the `features` list in your `tspconfig.yaml`: + +```yaml +kind: project +features: + - declaration-expressions +``` + +::: + +The `scalar` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. + +```typespec +model Measurement { + // anonymous scalar in expression position + temperature: scalar extends float64; + + // named scalar in expression position + distance: scalar Meters extends float64; +} +``` + +A scalar used in expression position is marked as an expression and is **not** registered in the enclosing namespace, even when it is given a name. The name is kept on the resulting type for display purposes only — it cannot be referenced elsewhere. + +You can apply [decorators](./decorators.md) and doc comments to the declaration inline, and augment it through a navigation reference such as `::type`. diff --git a/website/src/content/docs/docs/language-basics/unions.md b/website/src/content/docs/docs/language-basics/unions.md index 5f2ae5c1880..6ec7a645784 100644 --- a/website/src/content/docs/docs/language-basics/unions.md +++ b/website/src/content/docs/docs/language-basics/unions.md @@ -116,3 +116,52 @@ union OperationStatus extends string { ::: `extends` also has no interaction with the [`@discriminator`](../standard-library/built-in-decorators.md#@discriminator) decorator. + +## Keyword unions in expression position + +:::warning +Declaration expressions are an experimental TypeSpec feature and are disabled by default. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position is an error unless you enable them by adding `declaration-expressions` to the `features` list in your `tspconfig.yaml`: + +```yaml +kind: project +features: + - declaration-expressions +``` + +::: + +The `union` keyword can also be used anywhere a type expression is expected — for example as an alias value, a property type, a decorator or template argument, or a tuple element. Unlike a union expression built with the `|` operator, the keyword form can carry a name and named variants. + +```typespec +model Pet { + // anonymous keyword union in expression position + breed: union { + Beagle, + GermanShepherd, + }; + + // named keyword union in expression position + size: union Size { + small: "S", + medium: "M", + large: "L", + }; +} +``` + +A keyword union used in expression position is marked as an expression and is **not** registered in the enclosing namespace, even when it is given a name. The name is kept on the resulting type for display purposes only — it cannot be referenced elsewhere. + +Unlike the `|` operator, a keyword union used as an operand is **not** flattened into the surrounding union. For example, `union { "a", "b" } | "c"` produces a union of the nested `union { "a", "b" }` and `"c"`, preserving the named variants. + +A union in expression position can also constrain its variants with [`extends`](#constraining-a-union-with-extends): + +```typespec +model Pet { + breed: union extends Dog { + Beagle, + GermanShepherd, + }; +} +``` + +You can apply [decorators](./decorators.md) and doc comments to the declaration inline, and augment it through a navigation reference such as `::type`.