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/declarations-as-expressions-2026-4-18-0-0-0.md b/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md new file mode 100644 index 00000000000..41888209dc6 --- /dev/null +++ b/.chronus/changes/declarations-as-expressions-2026-4-18-0-0-0.md @@ -0,0 +1,20 @@ +--- +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 `""`). + +```tsp +alias Foo = enum { + a, + b, +}; + +model Bar { + status: enum { active, inactive }; + unit: scalar extends string; + inner: model Inner { x: string }; +} +``` 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 5552a5cb762..365f8d2f943 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -75,6 +75,7 @@ import { DocContent, Entity, Enum, + EnumDeclarationExpressionNode, EnumMember, EnumMemberNode, EnumStatementNode, @@ -108,6 +109,7 @@ import { MixedFunctionParameter, MixedParameterConstraint, Model, + ModelDeclarationExpressionNode, ModelExpressionNode, ModelIndexer, ModelProperty, @@ -132,6 +134,7 @@ import { Scalar, ScalarConstructor, ScalarConstructorNode, + ScalarDeclarationExpressionNode, ScalarStatementNode, ScalarValue, SignatureFunctionParameter, @@ -169,6 +172,7 @@ import { TypeReferenceNode, TypeSpecScriptNode, Union, + UnionDeclarationExpressionNode, UnionExpressionNode, UnionStatementNode, UnionVariant, @@ -1019,20 +1023,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: @@ -1100,13 +1112,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 && @@ -1168,7 +1183,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", @@ -2014,7 +2029,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); } @@ -2509,15 +2529,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, @@ -2527,7 +2551,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) { @@ -4463,15 +4491,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"; @@ -4988,15 +5019,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); + } + } + + /** + * 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): Model { + function checkModelStatement( + ctx: CheckContext, + node: ModelStatementNode | ModelDeclarationExpressionNode, + ): Model { const links = getSymbolLinks(node.symbol); if (ctx.mapper === undefined && node.templateParameters.length > 0) { @@ -5011,17 +5075,19 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); + checkExpressionDeclarationConstraints(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); @@ -5080,7 +5146,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); } @@ -5196,7 +5262,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker function checkModelProperties( ctx: CheckContext, - node: ModelExpressionNode | ModelStatementNode, + node: ModelExpressionNode | ModelStatementNode | ModelDeclarationExpressionNode, properties: Map, parentModel: Model, ) { @@ -5280,6 +5346,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators: [], derivedModels: [], sourceModels: [], + expression: true, }); for (const prop of properties.values()) { @@ -6439,7 +6506,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) { @@ -6507,7 +6574,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; @@ -6569,7 +6636,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); @@ -7258,7 +7325,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) { @@ -7274,17 +7344,19 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); + checkExpressionDeclarationConstraints(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); @@ -7298,7 +7370,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); @@ -7311,7 +7383,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); @@ -7349,7 +7421,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) { @@ -7527,16 +7599,20 @@ 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) { checkModifiers(program, node); const enumType: Enum = (links.type = createType({ kind: "Enum", - name: node.id.sv, + name: node.id?.sv ?? "", node, members: createRekeyableMap(), decorators: [], + expression: node.kind === SyntaxKind.EnumDeclarationExpression, })); const memberNames = new Set(); @@ -7573,7 +7649,9 @@ export function createChecker(program: Program, resolver: NameResolver): Checker const namespace = getParentNamespaceType(node); enumType.namespace = namespace; - enumType.namespace?.enums.set(enumType.name!, enumType); + if (!enumType.expression) { + enumType.namespace?.enums.set(enumType.name!, enumType); + } enumType.decorators = checkDecorators(ctx, enumType, node); linkMapper(enumType, ctx.mapper); finishType(enumType); @@ -7700,7 +7778,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) { @@ -7715,6 +7796,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker checkModifiers(program, node); } checkTemplateDeclaration(ctx, node); + checkExpressionDeclarationConstraints(node); const variants = createRekeyableMap(); const unionType: Union = createType({ @@ -7722,12 +7804,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); @@ -7737,12 +7819,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), }); @@ -7751,7 +7835,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) { @@ -7951,6 +8035,7 @@ export function createChecker(program: Program, resolver: NameResolver): Checker decorators: [], derivedModels: [], sourceModels: [], + expression: true, }); } @@ -8674,7 +8759,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/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 9008e0f6be6..137e03cdb17 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: { diff --git a/packages/compiler/src/core/modifiers.ts b/packages/compiler/src/core/modifiers.ts index 4473b051a7c..3818ce922a0 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. @@ -32,7 +41,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, @@ -42,6 +63,10 @@ const SYNTAX_MODIFIERS: Readonly { + 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(); @@ -1178,6 +1272,21 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa }; } + function parseEnumDeclarationExpression(pos: number): 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) @@ -1724,6 +1833,14 @@ function createParser(code: string | SourceFile, options: ParseOptions = {}): Pa return parseNumericLiteral(); case Token.OpenBrace: return parseModelExpression(); + case Token.ModelKeyword: + return parseModelDeclarationExpression(tokenPos()); + case Token.EnumKeyword: + return parseEnumDeclarationExpression(tokenPos()); + case Token.UnionKeyword: + return parseUnionDeclarationExpression(tokenPos()); + case Token.ScalarKeyword: + return parseScalarDeclarationExpression(tokenPos()); case Token.OpenBracket: return parseTupleExpression(); case Token.OpenParen: @@ -2002,6 +2119,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. @@ -3043,6 +3168,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) || @@ -3052,6 +3186,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: @@ -3062,6 +3204,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: @@ -3071,6 +3220,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: @@ -3415,6 +3568,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/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 60ee8493360..c89e92ad6bd 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; /** @@ -1213,6 +1236,10 @@ export enum SyntaxKind { ScalarConstructor, InternalKeyword, FunctionTypeExpression, + ModelDeclarationExpression, + ScalarDeclarationExpression, + UnionDeclarationExpression, + EnumDeclarationExpression, } export const enum NodeFlags { @@ -1343,11 +1370,14 @@ export type Node = */ export type TemplateableNode = | ModelStatementNode + | ModelDeclarationExpressionNode | ScalarStatementNode + | ScalarDeclarationExpressionNode | AliasStatementNode | InterfaceStatementNode | OperationStatementNode - | UnionStatementNode; + | UnionStatementNode + | UnionDeclarationExpressionNode; /** * Node types that can have referencable members @@ -1355,11 +1385,15 @@ export type TemplateableNode = export type MemberContainerNode = | ModelStatementNode | ModelExpressionNode + | ModelDeclarationExpressionNode | InterfaceStatementNode | EnumStatementNode + | EnumDeclarationExpressionNode | UnionStatementNode + | UnionDeclarationExpressionNode | IntersectionExpressionNode - | ScalarStatementNode; + | ScalarStatementNode + | ScalarDeclarationExpressionNode; export type MemberNode = | ModelPropertyNode @@ -1444,6 +1478,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 = @@ -1491,6 +1548,10 @@ export type Expression = | ArrayExpressionNode | MemberExpressionNode | ModelExpressionNode + | ModelDeclarationExpressionNode + | EnumDeclarationExpressionNode + | UnionDeclarationExpressionNode + | ScalarDeclarationExpressionNode | ObjectLiteralNode | ArrayLiteralNode | TupleExpressionNode @@ -1571,6 +1632,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; @@ -1580,11 +1657,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 { @@ -1603,12 +1695,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 { @@ -1618,12 +1723,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 { @@ -1680,13 +1796,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 497155a71ce..db26753ee73 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, @@ -168,16 +169,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. @@ -686,11 +695,11 @@ export function printEnumStatement( print: PrettierChildPrint, ) { const { decorators } = printDecorators(path, options, print, { tryInline: false }); - const id = path.call(print, "id"); + const id = path.node.id ? [" ", path.call(print, "id")] : ""; return [ decorators, printModifiers(path, options, print), - "enum ", + "enum", id, " ", printEnumBlock(path, options, print), @@ -738,13 +747,13 @@ export function printUnionStatement( options: TypeSpecPrettierOptions, print: PrettierChildPrint, ) { - const id = path.call(print, "id"); + const id = path.node.id ? [" ", path.call(print, "id")] : ""; const { decorators } = printDecorators(path, options, print, { tryInline: false }); const generic = printTemplateParameters(path, options, print, "templateParameters"); return [ decorators, printModifiers(path, options, print), - "union ", + "union", id, generic, " ", @@ -1074,7 +1083,7 @@ 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"); @@ -1084,7 +1093,7 @@ export function printModelStatement( return [ printDecorators(path, options, print, { tryInline: false }).decorators, printModifiers(path, options, print), - "model ", + "model", id, generic, heritage, @@ -1207,6 +1216,7 @@ function isModelAValue(path: AstPath): boolean { do { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.ModelDeclarationExpression: case SyntaxKind.AliasStatement: case SyntaxKind.OperationStatement: return false; @@ -1255,24 +1265,44 @@ function isModelExpressionInBlock(path: AstPath) { } } +function isInExpressionPosition(path: AstPath): boolean { + const parent = path.getParentNode(); + if (parent === null || parent === undefined) { + return false; + } + switch (parent.kind) { + case SyntaxKind.NamespaceStatement: + case SyntaxKind.TypeSpecScript: + case SyntaxKind.JsSourceFile: + return false; + default: + return true; + } +} + function printScalarStatement( path: AstPath, options: TypeSpecPrettierOptions, 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 = isInExpressionPosition(path); + const members = shouldPrintBody + ? [" ", printScalarBody(path, options, print)] + : inExpressionPosition + ? "" + : ";"; return [ printDecorators(path, options, print, { tryInline: false }).decorators, printModifiers(path, options, print), - "scalar ", + "scalar", id, template, heritage, @@ -1581,7 +1611,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/typekit/kits/enum.ts b/packages/compiler/src/typekit/kits/enum.ts index bab70398504..1867200f1b1 100644 --- a/packages/compiler/src/typekit/kits/enum.ts +++ b/packages/compiler/src/typekit/kits/enum.ts @@ -11,7 +11,8 @@ import { type UnionKit } from "./union.js"; */ interface EnumDescriptor { /** - * The name of the enum declaration. + * The name of the enum. If a non-empty name is provided, it is an enum + * declaration. An empty string (`""`) produces an enum expression. */ name: string; @@ -78,6 +79,7 @@ defineKit({ name: desc.name, decorators: decoratorApplication(this, desc.decorators), members: createRekeyableMap(), + 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..80dc124abf4 100644 --- a/packages/compiler/src/typekit/kits/model.ts +++ b/packages/compiler/src/typekit/kits/model.ts @@ -154,6 +154,7 @@ defineKit({ derivedModels: desc.derivedModels ?? [], sourceModels: desc.sourceModels ?? [], indexer: desc.indexer, + expression: desc.name === undefined, }); this.program.checker.finishType(model); 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..11790aa1209 --- /dev/null +++ b/packages/compiler/test/checker/declaration-expressions.test.ts @@ -0,0 +1,422 @@ +import { describe, expect, it } from "vitest"; +import { Enum, Model, Scalar, Union } from "../../src/core/types.js"; +import { getTypeName } from "../../src/index.js"; +import { expectDiagnosticEmpty, expectDiagnostics, t } from "../../src/testing/index.js"; +import { Tester } from "../tester.js"; + +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); + }); +}); + +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("cannot decorate the declaration expression itself", async () => { + const diagnostics = await Tester.diagnose(`model Foo { x: @doc("hi") enum { a, b } }`); + 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("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); + }); +}); diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index 7595e57b6d2..a834d472587 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -1791,6 +1791,108 @@ 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 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, + }; +}; +`, + }); + }); + }); + 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 e3d78e94607..3f70e9ca9db 100644 --- a/packages/compiler/test/parser.test.ts +++ b/packages/compiler/test/parser.test.ts @@ -281,6 +281,33 @@ 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 };", + // 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 } };", + ]); + + // 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/]], + ]); + }); + describe("const statements", () => { parseEach([ `const a = 123;`, @@ -626,7 +653,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/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..df4a804d425 100644 --- a/packages/compiler/test/typekit/enum.test.ts +++ b/packages/compiler/test/typekit/enum.test.ts @@ -47,3 +47,21 @@ 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); +}); diff --git a/packages/html-program-viewer/src/react/type-config.ts b/packages/html-program-viewer/src/react/type-config.ts index 566bca5a228..94773c139fb 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/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/spec/src/spec.emu.html b/packages/spec/src/spec.emu.html index 1a74c894e72..1a929f5490e 100644 --- a/packages/spec/src/spec.emu.html +++ b/packages/spec/src/spec.emu.html @@ -504,6 +504,10 @@

Syntactic Grammar

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

Syntactic Grammar

ModelExpression : `{` ModelBody? `}` +ModelDeclarationExpression : + `model` Identifier? TemplateParameters? ExtendsModelHeritage? `{` ModelBody? `}` + +ScalarDeclarationExpression : + `scalar` Identifier? TemplateParameters? ScalarExtends? + `scalar` Identifier? TemplateParameters? ScalarExtends? `{` ScalarBody? `}` + +EnumDeclarationExpression : + `enum` Identifier? `{` EnumBody? `}` + +UnionDeclarationExpression : + `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.", }); }); });