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..004ed0606e7 --- /dev/null +++ b/.chronus/changes/decl-expr-declaration-expressions-2026-4-18-0-0-0.md @@ -0,0 +1,25 @@ +--- +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 its `name` is `""`). + +They can be used anywhere an expression is expected, including aliases, model properties, decorator arguments, template arguments, function/call arguments, and tuples. + +```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/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 c29ce411b12..9f6eeec075d 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,27 @@ } ] }, + "union-expression": { + "name": "meta.union-expression.typespec", + "begin": "\\b(union)\\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": "#union-body" + } + ] + }, "union-statement": { "name": "meta.union-statement.typespec", "begin": "(?:(internal)\\s+)?\\b(union)\\b\\s+(\\b[_$[:alpha:]][_$[:alnum:]]*\\b|`(?:[^`\\\\]|\\\\.)*`)", @@ -1392,7 +1500,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" @@ -1413,7 +1521,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" @@ -1431,7 +1539,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" @@ -1452,7 +1560,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 09a5e5b5108..2e869927e9c 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -9,6 +9,7 @@ import { Declaration, DecoratorDeclarationStatementNode, DecoratorImplementations, + EnumDeclarationExpressionNode, EnumMemberNode, EnumStatementNode, FileLibraryMetadata, @@ -19,6 +20,7 @@ import { IntersectionExpressionNode, JsNamespaceDeclarationNode, JsSourceFileNode, + ModelDeclarationExpressionNode, ModelExpressionNode, ModelPropertyNode, ModelStatementNode, @@ -29,6 +31,7 @@ import { NodeFlags, OperationStatementNode, ScalarConstructorNode, + ScalarDeclarationExpressionNode, ScalarStatementNode, ScopeNode, Sym, @@ -37,6 +40,7 @@ import { SyntaxKind, TemplateParameterDeclarationNode, TypeSpecScriptNode, + UnionDeclarationExpressionNode, UnionStatementNode, UnionVariantNode, UsingStatementNode, @@ -301,6 +305,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; @@ -313,6 +320,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; @@ -322,6 +332,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; @@ -331,6 +344,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; @@ -400,6 +416,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); } @@ -420,6 +442,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); } @@ -438,6 +466,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; @@ -457,6 +490,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); } @@ -649,15 +686,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 c1ea436b487..4f0fc62333c 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -76,6 +76,7 @@ import { DocContent, Entity, Enum, + EnumDeclarationExpressionNode, EnumMember, EnumMemberNode, EnumStatementNode, @@ -109,6 +110,7 @@ import { MixedFunctionParameter, MixedParameterConstraint, Model, + ModelDeclarationExpressionNode, ModelExpressionNode, ModelIndexer, ModelProperty, @@ -134,6 +136,7 @@ import { Scalar, ScalarConstructor, ScalarConstructorNode, + ScalarDeclarationExpressionNode, ScalarStatementNode, ScalarValue, SignatureFunctionParameter, @@ -171,6 +174,7 @@ import { TypeReferenceNode, TypeSpecScriptNode, Union, + UnionDeclarationExpressionNode, UnionExpressionNode, UnionStatementNode, UnionVariant, @@ -1021,20 +1025,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: @@ -1095,6 +1107,36 @@ 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: "experimental-feature", + messageId: "declarationExpressions", + target: node, + }), + ); + } + } + /** * Return a fully qualified id of node */ @@ -1102,13 +1144,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 && @@ -1170,7 +1215,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", @@ -2016,7 +2061,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); } @@ -2523,15 +2573,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, @@ -2541,7 +2595,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) { @@ -4477,15 +4535,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"; @@ -5002,15 +5063,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) { @@ -5025,17 +5119,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); @@ -5094,7 +5193,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); } @@ -5210,7 +5309,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkModelProperties( ctx: CheckContext, - node: ModelExpressionNode | ModelStatementNode, + node: ModelExpressionNode | ModelStatementNode | ModelDeclarationExpressionNode, properties: Map, parentModel: Model, ) { @@ -5294,6 +5393,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators: [], derivedModels: [], sourceModels: [], + expression: true, }); for (const prop of properties.values()) { @@ -6453,7 +6553,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) { @@ -6521,7 +6621,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; @@ -6583,7 +6683,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); @@ -7189,7 +7289,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({ @@ -7220,6 +7321,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. */ @@ -7273,7 +7392,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) { @@ -7289,17 +7411,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); @@ -7313,7 +7440,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); @@ -7326,7 +7453,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); @@ -7364,7 +7491,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) { @@ -7542,59 +7669,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 { @@ -7715,7 +7859,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) { @@ -7730,6 +7877,10 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); + checkExpressionDeclarationConstraints(node); + if (ctx.mapper === undefined) { + reportDeclarationExpressionFeature(node); + } const variants = createRekeyableMap(); const unionType: Union = createType({ @@ -7737,12 +7888,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); @@ -7752,12 +7903,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), }); @@ -7766,7 +7919,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) { @@ -7966,6 +8119,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators: [], derivedModels: [], sourceModels: [], + expression: true, }); } @@ -8689,7 +8843,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 01547806fe0..c0b8687e776 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) without experimental warnings 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 1ac92a573ad..447eda75a4c 100644 --- a/packages/compiler/src/core/helpers/type-name-utils.ts +++ b/packages/compiler/src/core/helpers/type-name-utils.ts @@ -162,18 +162,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 === "" && model.properties.size === 0) { return "{}"; } diff --git a/packages/compiler/src/core/inspector/node.ts b/packages/compiler/src/core/inspector/node.ts index af9f783d828..7a616a3adb8 100644 --- a/packages/compiler/src/core/inspector/node.ts +++ b/packages/compiler/src/core/inspector/node.ts @@ -33,12 +33,16 @@ 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.ScalarDeclarationExpression: + return node.id?.sv ?? ""; default: return ""; } diff --git a/packages/compiler/src/core/messages.ts b/packages/compiler/src/core/messages.ts index f4ba1c4fbbd..5af61869fbc 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: { @@ -267,6 +274,8 @@ const diagnostics = { default: paramMessage`${"feature"} is an experimental feature. It may change in the future or be removed. Use with caution and consider providing feedback on this feature.`, functionDeclarations: "Function declarations are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", + declarationExpressions: + "Declaration expressions are an experimental feature that may change in the future. Use with caution and consider providing feedback to the TypeSpec team.", }, }, "auto-decorator-disabled": { diff --git a/packages/compiler/src/core/modifiers.ts b/packages/compiler/src/core/modifiers.ts index a3f1e1c2106..4e287042349 100644 --- a/packages/compiler/src/core/modifiers.ts +++ b/packages/compiler/src/core/modifiers.ts @@ -4,7 +4,16 @@ import { compilerAssert } from "./diagnostics.js"; import { createDiagnostic } from "./messages.js"; import { Program } from "./program.js"; -import { Declaration, Modifier, ModifierFlags, SyntaxKind } from "./types.js"; +import { + Declaration, + EnumDeclarationExpressionNode, + ModelDeclarationExpressionNode, + Modifier, + ModifierFlags, + ScalarDeclarationExpressionNode, + SyntaxKind, + UnionDeclarationExpressionNode, +} from "./types.js"; /** * The compatibility of modifiers for a given declaration node type. @@ -34,7 +43,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, @@ -44,6 +65,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), }; } @@ -1129,6 +1226,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(); @@ -1136,7 +1260,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(); @@ -1181,6 +1310,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) @@ -1707,6 +1854,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()) { @@ -1727,14 +1916,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"); @@ -2014,6 +2225,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. @@ -2238,16 +2457,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 { @@ -2637,15 +2867,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 @@ -2657,7 +2906,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); @@ -2686,7 +2941,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 @@ -2753,11 +3008,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()) + ) ); } @@ -3057,6 +3332,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) || @@ -3066,6 +3350,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: @@ -3076,6 +3368,13 @@ export function visitChildren(node: Node, cb: NodeCallback): T | undefined visitEach(cb, node.templateParameters) || visitEach(cb, node.options) ); + case SyntaxKind.UnionDeclarationExpression: + return ( + visitEach(cb, node.decorators) || + visitNode(cb, node.id) || + visitEach(cb, node.templateParameters) || + visitEach(cb, node.options) + ); case SyntaxKind.UnionVariant: return visitEach(cb, node.decorators) || visitNode(cb, node.id) || visitNode(cb, node.value); case SyntaxKind.EnumStatement: @@ -3085,6 +3384,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: @@ -3430,6 +3733,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 d940a6b4dd5..e9e9bd8846e 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 8e5f471da0c..ef9c11a25c6 100644 --- a/packages/compiler/src/core/type-utils.ts +++ b/packages/compiler/src/core/type-utils.ts @@ -93,8 +93,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 50c36efbf4b..0d16ce545af 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -267,10 +267,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. * @@ -432,12 +443,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. */ @@ -499,9 +516,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. * @@ -670,7 +693,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; /** @@ -1216,6 +1239,10 @@ export enum SyntaxKind { InternalKeyword, AutoKeyword, FunctionTypeExpression, + ModelDeclarationExpression, + ScalarDeclarationExpression, + UnionDeclarationExpression, + EnumDeclarationExpression, } export const enum NodeFlags { @@ -1346,11 +1373,14 @@ export type Node = */ export type TemplateableNode = | ModelStatementNode + | ModelDeclarationExpressionNode | ScalarStatementNode + | ScalarDeclarationExpressionNode | AliasStatementNode | InterfaceStatementNode | OperationStatementNode - | UnionStatementNode; + | UnionStatementNode + | UnionDeclarationExpressionNode; /** * Node types that can have referencable members @@ -1358,11 +1388,15 @@ export type TemplateableNode = export type MemberContainerNode = | ModelStatementNode | ModelExpressionNode + | ModelDeclarationExpressionNode | InterfaceStatementNode | EnumStatementNode + | EnumDeclarationExpressionNode | UnionStatementNode + | UnionDeclarationExpressionNode | IntersectionExpressionNode - | ScalarStatementNode; + | ScalarStatementNode + | ScalarDeclarationExpressionNode; export type MemberNode = | ModelPropertyNode @@ -1447,6 +1481,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 = @@ -1494,6 +1551,10 @@ export type Expression = | ArrayExpressionNode | MemberExpressionNode | ModelExpressionNode + | ModelDeclarationExpressionNode + | EnumDeclarationExpressionNode + | UnionDeclarationExpressionNode + | ScalarDeclarationExpressionNode | ObjectLiteralNode | ArrayLiteralNode | TupleExpressionNode @@ -1574,6 +1635,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; @@ -1583,11 +1660,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 { @@ -1606,12 +1698,25 @@ 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[]; + 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 { @@ -1621,12 +1726,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 { @@ -1683,13 +1799,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 dc9917d4ce1..a479bb5e224 100644 --- a/packages/compiler/src/formatter/print/comment-handler.ts +++ b/packages/compiler/src/formatter/print/comment-handler.ts @@ -85,14 +85,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; @@ -114,7 +118,8 @@ function addEmptyModelComment({ comment }: CommentContext) { if ( enclosingNode && - enclosingNode.kind === SyntaxKind.ModelStatement && + (enclosingNode.kind === SyntaxKind.ModelStatement || + enclosingNode.kind === SyntaxKind.ModelDeclarationExpression) && enclosingNode.properties.length === 0 && precedingNode && (precedingNode === enclosingNode.is || @@ -141,7 +146,8 @@ function addEmptyScalarComment({ comment }: CommentContext) { if ( enclosingNode && - enclosingNode.kind === SyntaxKind.ScalarStatement && + (enclosingNode.kind === SyntaxKind.ScalarStatement || + enclosingNode.kind === SyntaxKind.ScalarDeclarationExpression) && enclosingNode.members.length === 0 && precedingNode && (precedingNode === enclosingNode.id || precedingNode === enclosingNode.extends) diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index b67c72a4b4d..bd700bae9c3 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -44,6 +44,7 @@ import { OperationSignatureDeclarationNode, OperationSignatureReferenceNode, OperationStatementNode, + OptionallyNamedDeclarationNode, ScalarConstructorNode, ScalarStatementNode, Statement, @@ -73,6 +74,7 @@ import { util } from "./util.js"; const { align, breakParent, + conditionalGroup, group, hardline, ifBreak, @@ -110,6 +112,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); @@ -124,6 +131,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; @@ -168,16 +230,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. @@ -587,6 +657,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) { @@ -687,12 +769,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), @@ -740,13 +825,16 @@ 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 = printTemplateParameters(path, options, print, "templateParameters"); return [ decorators, printModifiers(path, options, print), - "union ", + "union", id, generic, " ", @@ -1076,17 +1164,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 = printTemplateParameters(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, @@ -1209,6 +1305,7 @@ function isModelAValue(path: AstPath): boolean { do { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.AliasStatement: case SyntaxKind.OperationStatement: return false; @@ -1263,18 +1360,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 = printTemplateParameters(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, @@ -1607,7 +1712,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 56f0de48010..f0345c780cf 100644 --- a/packages/compiler/src/server/classify.ts +++ b/packages/compiler/src/server/classify.ts @@ -222,10 +222,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); @@ -236,10 +238,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 b8f297694bd..0a02431ccd1 100644 --- a/packages/compiler/src/server/completion.ts +++ b/packages/compiler/src/server/completion.ts @@ -97,7 +97,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.TemplateParameterDeclaration, @@ -112,19 +114,23 @@ 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", }; switch (preNode?.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ScalarStatement: + case SyntaxKind.ScalarDeclarationExpression: case SyntaxKind.OperationStatement: case SyntaxKind.InterfaceStatement: const idEndPos = @@ -172,6 +178,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); } @@ -186,6 +193,7 @@ async function AddCompletionNonTrivia( } break; case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.ObjectLiteral: case SyntaxKind.ModelExpression: await addModelCompletion(context, posDetail); @@ -371,6 +379,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) ) { @@ -527,7 +536,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: @@ -535,8 +546,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 025f146a326..7b158d60cdc 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})`; @@ -712,6 +715,66 @@ const unionStatement: BeginEndRule = { patterns: [token, unionBody], }; +// 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+(${identifier}))?`, + beginCaptures: { + "1": { scope: "keyword.other.tsp" }, + "2": { scope: "entity.name.type.tsp" }, + }, + end: `(?<=\\})|${universalEnd}`, + patterns: [token, unionBody], +}; + const aliasAssignment: BeginEndRule = { key: "alias-id", scope: meta, @@ -937,6 +1000,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 32b1ff806b5..5ce94821899 100644 --- a/packages/compiler/src/server/type-signature.ts +++ b/packages/compiler/src/server/type-signature.ts @@ -204,7 +204,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 dd50e0082a1..5f75950108a 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 { TypeSpecConfig } from "../config/types.js"; import { compilerAssert } from "../core/diagnostics.js"; +import { CompilerFeatureName } from "../core/features.js"; import { getEntityName } from "../core/helpers/type-name-utils.js"; import { NodeHost } from "../core/node-host.js"; import { CompilerOptions } from "../core/options.js"; @@ -38,6 +40,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 @@ -48,9 +61,24 @@ 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 }; +} + function once(fn: () => Promise): () => Promise { let load: Promise | undefined; return () => { @@ -417,11 +445,10 @@ async function createTesterInstance(params: TesterInternalParams): Promise({ name: desc.name, decorators: decoratorApplication(this, desc.decorators), members: createRekeyableMap(), + expression: desc.expression ?? desc.name === "", }); if (Array.isArray(desc.members)) { diff --git a/packages/compiler/src/typekit/kits/model.ts b/packages/compiler/src/typekit/kits/model.ts index 9eb22445d94..aa92dd47fc9 100644 --- a/packages/compiler/src/typekit/kits/model.ts +++ b/packages/compiler/src/typekit/kits/model.ts @@ -27,6 +27,15 @@ export interface ModelDescriptor { */ name?: string; + /** + * Whether the model is used in expression position (`expression: true`). When + * omitted, this defaults to `true` for an anonymous model (no `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). + */ + expression?: boolean; + /** * Decorators to apply to the Model. */ @@ -154,6 +163,7 @@ defineKit({ derivedModels: desc.derivedModels ?? [], sourceModels: desc.sourceModels ?? [], indexer: desc.indexer, + expression: desc.expression ?? desc.name === undefined, }); this.program.checker.finishType(model); @@ -165,7 +175,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 13f040b34fe..7fb5073d4d6 100644 --- a/packages/compiler/src/typekit/kits/union.ts +++ b/packages/compiler/src/typekit/kits/union.ts @@ -20,6 +20,15 @@ export interface UnionDescriptor { */ name?: string; + /** + * Whether the union is used in expression position (`expression: true`). When + * omitted, this defaults to `true` for an anonymous union (no `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). + */ + expression?: boolean; + /** * Decorators to apply to the union. */ @@ -154,7 +163,7 @@ export const UnionKit = defineKit({ get options() { return Array.from(this.variants.values()).map((v) => v.type); }, - expression: descriptor.name === undefined, + expression: descriptor.expression ?? descriptor.name === undefined, }); if (Array.isArray(descriptor.variants)) { @@ -238,7 +247,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/test/checker/declaration-expressions.test.ts b/packages/compiler/test/checker/declaration-expressions.test.ts new file mode 100644 index 00000000000..066f429e0ac --- /dev/null +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -0,0 +1,751 @@ +import { describe, expect, it } from "vitest"; +import { 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"], +}); + +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("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("experimental feature flag", () => { + it("reports an experimental-feature warning when the feature is not enabled", async () => { + const diagnostics = await BaseTester.diagnose(` + model Foo { + status: enum { active, inactive }; + } + `); + expectDiagnostics(diagnostics, { + code: "experimental-feature", + severity: "warning", + }); + }); + + it("reports the warning 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 experimental = diagnostics.filter((d) => d.code === "experimental-feature"); + expect(experimental).toHaveLength(4); + }); + + it("reports the warning 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 experimental = diagnostics.filter((d) => d.code === "experimental-feature"); + expect(experimental).toHaveLength(1); + }); + + it("does not report the warning 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 ad2fb784148..c6d5451c8a0 100644 --- a/packages/compiler/test/core/cli/actions/info.test.ts +++ b/packages/compiler/test/core/cli/actions/info.test.ts @@ -21,8 +21,9 @@ describe("formatCompilerFeatures", () => { 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.", + " 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) without experimental warnings in project code.", + " disabled auto-decorators Allows use of auto decorator declarations without experimental warnings in project code.", ]); }); }); diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index 422ba82be38..f2e5fd1ec1c 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -1791,6 +1791,315 @@ 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 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 4e20f08e7d9..a9ff5f9f05e 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 { IdentifierNode, + ModelStatementNode, Node, ParseOptions, SourceFile, @@ -281,6 +282,71 @@ describe("compiler: parser", () => { parseErrorEach([['union A { @myDec "x" x: number, y: string }', [/';' expected/]]]); }); + 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;`, @@ -626,7 +692,7 @@ describe("compiler: parser", () => { (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 33222abb70c..d350680851b 100644 --- a/packages/compiler/test/server/colorization.test.ts +++ b/packages/compiler/test/server/colorization.test.ts @@ -1091,6 +1091,115 @@ 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 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 09680b8c713..d04796f5968 100644 --- a/packages/compiler/test/server/completion.tspconfig.test.ts +++ b/packages/compiler/test/server/completion.tspconfig.test.ts @@ -134,19 +134,19 @@ describe("Test completion items for features", () => { it.each([ { config: `features:\n - ┆`, - expected: ['"auto-decorators"', '"function-declarations"'], + expected: ['"auto-decorators"', '"declaration-expressions"', '"function-declarations"'], }, { config: `features:\n - "┆"`, - expected: ["auto-decorators", "function-declarations"], + expected: ["auto-decorators", "declaration-expressions", "function-declarations"], }, { config: `features:\n - "function┆"`, - expected: ["auto-decorators", "function-declarations"], + expected: ["auto-decorators", "declaration-expressions", "function-declarations"], }, { config: `features:\n - function-declarations\n - ┆`, - expected: ['"auto-decorators"'], + expected: ['"auto-decorators"', '"declaration-expressions"'], }, ])("#%# Test features: $config", async ({ config, expected }) => { await checkCompletionItems(config, true, expected); @@ -158,6 +158,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) without experimental warnings in project code.", "Allows use of function declarations without experimental warnings in project code.", ], true, diff --git a/packages/compiler/test/testing/rule-tester-codefix.test.ts b/packages/compiler/test/testing/rule-tester-codefix.test.ts index ad96726838e..71bd53f7175 100644 --- a/packages/compiler/test/testing/rule-tester-codefix.test.ts +++ b/packages/compiler/test/testing/rule-tester-codefix.test.ts @@ -53,7 +53,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/typekit/enum.test.ts b/packages/compiler/test/typekit/enum.test.ts index 7d6a6d400f1..e614302203b 100644 --- a/packages/compiler/test/typekit/enum.test.ts +++ b/packages/compiler/test/typekit/enum.test.ts @@ -47,3 +47,31 @@ 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("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 779ea13b922..0a044e1cb6f 100644 --- a/packages/compiler/test/typekit/model.test.ts +++ b/packages/compiler/test/typekit/model.test.ts @@ -82,3 +82,20 @@ 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 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 734f44bc087..35910475cc3 100644 --- a/packages/compiler/test/typekit/union.test.ts +++ b/packages/compiler/test/typekit/union.test.ts @@ -177,3 +177,20 @@ 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 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/html-program-viewer/src/react/type-config.ts b/packages/html-program-viewer/src/react/type-config.ts index be1ce7e5f21..e892ffc411e 100644 --- a/packages/html-program-viewer/src/react/type-config.ts +++ b/packages/html-program-viewer/src/react/type-config.ts @@ -82,11 +82,13 @@ export const TypeConfig: TypeGraphConfig = buildConfig({ properties: "nested-items", sourceModel: "ref", sourceModels: "value", + expression: "value", }, Scalar: { baseScalar: "ref", derivedScalars: "ref", constructors: "nested-items", + expression: "value", }, ModelProperty: { model: "parent", @@ -97,6 +99,7 @@ export const TypeConfig: TypeGraphConfig = buildConfig({ }, Enum: { members: "nested-items", + expression: "value", }, EnumMember: { enum: "parent", diff --git a/packages/json-schema/src/json-schema-emitter.ts b/packages/json-schema/src/json-schema-emitter.ts index b89875dc11f..0476defe5e4 100644 --- a/packages/json-schema/src/json-schema-emitter.ts +++ b/packages/json-schema/src/json-schema-emitter.ts @@ -80,6 +80,16 @@ import { import { type JSONSchemaEmitterOptions, reportDiagnostic } from "./lib.js"; import { includeDerivedModel } from "./utils.js"; +/** + * Whether the type is an anonymous declaration expression (e.g. an inline + * `enum { ... }` or `scalar extends string` used as a property type): it is in + * expression position (`expression: true`) and has no name. Such types are inlined + * into the referencing schema rather than hoisted into their own file/`$defs`. + */ +function isAnonymousExpression(type: JsonSchemaDeclaration): boolean { + return type.expression && type.name === ""; +} + /** @internal */ export class JsonSchemaEmitter extends TypeEmitter, JSONSchemaEmitterOptions> { #idDuplicateTracker = new DuplicateTracker(); @@ -730,6 +740,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); @@ -759,6 +778,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 || @@ -1104,6 +1127,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); } @@ -1114,6 +1142,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 cb79b73abd9..90fb292c141 100644 --- a/packages/openapi/src/helpers.ts +++ b/packages/openapi/src/helpers.ts @@ -35,6 +35,12 @@ import { 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 an empty `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)) { @@ -44,7 +50,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 520d8503af6..9668b1639bf 100644 --- a/packages/openapi3/src/schema-emitter.ts +++ b/packages/openapi3/src/schema-emitter.ts @@ -929,11 +929,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 { @@ -945,6 +940,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 2a3dde4136c..f8076ca3a98 100644 --- a/packages/openapi3/test/test-host.ts +++ b/packages/openapi3/test/test-host.ts @@ -22,6 +22,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 5230dacc946..f207147bad0 100644 --- a/packages/spec/src/spec.emu.html +++ b/packages/spec/src/spec.emu.html @@ -501,6 +501,10 @@

Syntactic Grammar

ObjectLiteral ArrayLiteral ModelExpression + ModelDeclarationExpression + ScalarDeclarationExpression + EnumDeclarationExpression + UnionDeclarationExpression TupleExpression FunctionTypeExpression : @@ -569,6 +573,19 @@

Syntactic Grammar

ModelExpression : `{` ModelBody? `}` +ModelDeclarationExpression : + DecoratorList? `model` Identifier? TemplateParameters? ExtendsModelHeritage? `{` ModelBody? `}` + +ScalarDeclarationExpression : + DecoratorList? `scalar` Identifier? TemplateParameters? ScalarExtends? + DecoratorList? `scalar` Identifier? TemplateParameters? ScalarExtends? `{` ScalarBody? `}` + +EnumDeclarationExpression : + DecoratorList? `enum` Identifier? `{` EnumBody? `}` + +UnionDeclarationExpression : + DecoratorList? `union` Identifier? `{` 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/incompatible-versioning.test.ts b/packages/versioning/test/incompatible-versioning.test.ts index 9ad0bcc008f..748cd9c911d 100644 --- a/packages/versioning/test/incompatible-versioning.test.ts +++ b/packages/versioning/test/incompatible-versioning.test.ts @@ -78,7 +78,7 @@ describe("versioning: validate incompatible references", () => { expectDiagnostics(diagnostics, { code: "@typespec/versioning/incompatible-versioned-reference", message: - "'TestService.{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", + "'{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", }); }); @@ -122,7 +122,7 @@ describe("versioning: validate incompatible references", () => { expectDiagnostics(diagnostics, { code: "@typespec/versioning/incompatible-versioned-reference", message: - "'TestService.{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", + "'{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", }); }); @@ -713,7 +713,7 @@ describe("versioning: validate incompatible references", () => { expectDiagnostics(diagnostics, { code: "@typespec/versioning/incompatible-versioned-reference", message: - "'TestService.{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", + "'{ param: TestService.Foo }.param' is referencing versioned type 'TestService.Foo' but is not versioned itself.", }); }); }); 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..17340795fc4 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. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position yields an `experimental-feature` warning. Enable them without the warning 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..94fc9bada2a 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. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position yields an `experimental-feature` warning. Enable them without the warning 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..2e60c2092d6 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. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position yields an `experimental-feature` warning. Enable them without the warning 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 8a0bbfbbc84..eba92b1a984 100644 --- a/website/src/content/docs/docs/language-basics/unions.md +++ b/website/src/content/docs/docs/language-basics/unions.md @@ -35,3 +35,41 @@ union Breed { ``` The above example is equivalent to the `Breed` alias mentioned earlier, with the difference that emitters can recognize `Breed` as a named entity and also identify the `beagle`, `shepherd`, and `retriever` names for the options. This format also allows the application of [decorators](./decorators.md) to each of the options. + +## Keyword unions in expression position + +:::warning +Declaration expressions are an experimental TypeSpec feature. Using a `model`, `enum`, `union`, or `scalar` declaration in expression position yields an `experimental-feature` warning. Enable them without the warning 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. + +You can apply [decorators](./decorators.md) and doc comments to the declaration inline, and augment it through a navigation reference such as `::type`.