diff --git a/docs/tutorial.md b/docs/tutorial.md index c781a8301..fdeda6e7e 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -96,6 +96,34 @@ model Pet { model Dog extends Pet, Animal { } ``` +### Enums + +Enums define a type which can hold one of a set of constant values. + +``` +enum Color { + Red, + Blue, + Green, +} +``` + +In this case, we haven't specified how the constants will be represented, allowing for different choices in different scenarios. For example, the OpenAPI emitter will choose string values "Red", "Green", "Blue". Another protocol might prefer to assign incrementing numeric values 0, 1, 2. + +We can also specify explicit string or numeric values: +``` +enum Color { + Red: "red", + Blue: "blue" + Green: "green", +} + +enum Priority { + High: 100, + Low: 0, +} +``` + #### Templates It is often useful to let the users of a model fill in certain details. Model templates enable this pattern. Similar to generics found in other languages, model templates declare template parameters that users provide when referencing the model. @@ -111,14 +139,16 @@ model DogPage { } ``` -#### Model Aliases +#### Type Aliases -Sometimes it's convenient to alias a model template instantiation or model produced via type operators (covered later) as a convenient name. Model aliases allow this: +Sometimes it's convenient to alias a model template instantiation or type produced via type operators (covered later) as a convenient name. Aliases allow this: ``` -model DogPage = Page; +alias DogPage = Page; ``` +Unlike `model`, `alias` does not create a new entity, and as such will not change generated code in any way. An alias merely describes a source code shorthand to avoid repeating the right-hand side in multiple places. + ### Type Literals API authors often need to describe API shapes in terms of specific literal values. For example, this operation returns this specific integer status code, or this model member can be one of a few specific string values. It is also often useful to pass specific literal values to decorators. ADL supports string, number, and boolean literal values to support these cases: @@ -153,7 +183,7 @@ ADL supports a few type operators that make it easy to compose new models from o Unions describe a type that must be exactly one of the union's constituents. Create a union with the `|` operator. ``` -model GoodBreeds = 'Beagle' | 'German Shepherd' | 'Golden Retriever'; +alias GoodBreed = Beagle | GermanShepherd | GoldenRetriever; ``` #### Intersection @@ -161,7 +191,7 @@ model GoodBreeds = 'Beagle' | 'German Shepherd' | 'Golden Retriever'; Intersections describe a type that must include all of the intersection's constituents. Create an intersection with the `&` operator. ``` -model Dog = Animal & Pet; +alias Dog = Animal & Pet; ``` #### Array @@ -169,7 +199,7 @@ model Dog = Animal & Pet; Arrays describe lists of things. Create an Array type with the `[]` operator. ``` -model Pack = Dog[]; +alias Pack = Dog[]; ``` ### Operations @@ -228,7 +258,7 @@ namespace A.B; namespace C.D {} namespace C.D.E { model M { }} -model M = A.B.C.D.E.M; +alias M = A.B.C.D.E.M; ``` It can be convenient to add references to a namespace's declarations to your local namespace, especially when namespaces can become deeply nested. The `using` statement lets us do this: @@ -253,11 +283,11 @@ namespace Test { namespace Test2 { using Test; - model B = A; // ok + alias B = A; // ok } -model C = Test2.A; // not ok -model C = Test2.B; // ok +alias C = Test2.A; // not ok +alias C = Test2.B; // ok ``` ### Imports diff --git a/packages/adl-vscode/scripts/generate-third-party-notices.js b/eng/scripts/generate-third-party-notices.js similarity index 99% rename from packages/adl-vscode/scripts/generate-third-party-notices.js rename to eng/scripts/generate-third-party-notices.js index 6837a88c3..5101818ba 100644 --- a/packages/adl-vscode/scripts/generate-third-party-notices.js +++ b/eng/scripts/generate-third-party-notices.js @@ -116,7 +116,7 @@ function getUrl(pkg) { } async function getLicense(packageRoot) { - for (const licenseName of ["LICENSE", "LICENSE.txt", "LICENSE.md"]) { + for (const licenseName of ["LICENSE", "LICENSE.txt", "LICENSE.md", "LICENSE-MIT"]) { const licensePath = join(packageRoot, licenseName); try { let text = (await readFile(licensePath)).toString("utf-8"); diff --git a/eng/scripts/helpers.js b/eng/scripts/helpers.js index d261cabd4..9e10c2cab 100644 --- a/eng/scripts/helpers.js +++ b/eng/scripts/helpers.js @@ -1,6 +1,6 @@ import { spawn, spawnSync } from "child_process"; -import { readFileSync } from "fs"; -import { dirname, resolve } from "path"; +import { statSync, readFileSync } from "fs"; +import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; function read(filename) { @@ -31,7 +31,11 @@ export function forEachProject(onEach) { export function npmForEach(cmd, options) { forEachProject((name, location, project) => { - // checks for the script first + if (cmd === "test-official" && !project.scripts[cmd] && project.scripts["test"]) { + const pj = join(location, "package.json"); + throw new Error(`${pj} has a 'test' script, but no 'test-official' script for CI.`); + } + if (project.scripts[cmd] || cmd === "pack") { const args = cmd === "pack" ? [cmd] : ["run", cmd]; run("npm", args, { cwd: location, ...options }); @@ -105,41 +109,75 @@ export function clearScreen() { } export function runWatch(watch, dir, build, options) { - let lastStartTime; + let lastBuildTime; dir = resolve(dir); - // build once up-front. - runBuild(); + // We need to wait for directory to be created before watching it. This deals + // with races between watchers where one watcher must create a directory + // before another can watch it. + // + // For example, we can't watch for tmlanguage.js changes if the source watcher + // hasn't even created the directory in which tmlanguage.js will be written. + try { + statSync(dir); + } catch (err) { + if (err.code === "ENOENT") { + waitForDirectoryCreation(); + return; + } + throw err; + } - watch.createMonitor(dir, { interval: 0.2, ...options }, (monitor) => { - let handler = function (file) { - if (lastStartTime && monitor?.files[file]?.mtime < lastStartTime) { - // File was changed before last build started so we can ignore it. This - // avoids running the build unnecessarily when a series of input files - // change at the same time. - return; - } - runBuild(file); - }; + // Directory already exists: we can start watching right away. + start(); - monitor.on("created", handler); - monitor.on("changed", handler); - monitor.on("removed", handler); - }); + function waitForDirectoryCreation() { + let dirCreated = false; + let parentDir = dirname(dir); + logWithTime(`Waiting for ${dir} to be created.`); - function runBuild(file) { - runBuildAsync(file).catch((err) => { + watch.createMonitor(parentDir, "created", (monitor) => { + monitor.on("created", (file) => { + if (!dirCreated && file === dir) { + dirCreated = true; // defend against duplicate events. + monitor.stop(); + start(); + } + }); + }); + } + + function start() { + // build once up-front + runBuild(); + + // then build again on any change + watch.createMonitor(dir, { interval: 0.2, ...options }, (monitor) => { + monitor.on("created", (file) => runBuild(`${file} created`)); + monitor.on("removed", (file) => runBuild(`${file} removed`)); + monitor.on("changed", (file) => runBuild(`${file} changed`, monitor.files[file]?.mtime)); + }); + } + + function runBuild(changeDescription, changeTime) { + runBuildAsync(changeDescription, changeTime).catch((err) => { console.error(err.stack); process.exit(1); }); } - async function runBuildAsync(file) { - lastStartTime = Date.now(); - clearScreen(); + async function runBuildAsync(changeDescription, changeTime) { + if (changeTime && lastBuildTime && changeTime < lastBuildTime) { + // Don't rebuild if a change happened before the last build kicked off. + // Defends against duplicate events and building more than once when a + // bunch of files are changed at the same time. + return; + } - if (file) { - logWithTime(`File change detected: ${file}. Running build.`); + lastBuildTime = new Date(); + if (changeDescription) { + clearScreen(); + logWithTime(`File change detected: ${changeDescription}. Running build.`); } else { logWithTime("Starting build in watch mode."); } diff --git a/packages/adl-language/package.json b/packages/adl-language/package.json index 9c7e88fd7..2afe0eafd 100644 --- a/packages/adl-language/package.json +++ b/packages/adl-language/package.json @@ -16,7 +16,7 @@ }, "type": "module", "scripts": { - "build": "ecmarkup src/spec.emu.html ../../docs/spec.html", + "build": "ecmarkup --strict src/spec.emu.html ../../docs/spec.html", "watch": "node scripts/watch-spec.js" }, "dependencies": {}, diff --git a/packages/adl-language/scripts/watch-spec.js b/packages/adl-language/scripts/watch-spec.js index 6c0cb78d9..9a1e7279b 100644 --- a/packages/adl-language/scripts/watch-spec.js +++ b/packages/adl-language/scripts/watch-spec.js @@ -1,6 +1,6 @@ import watch from "watch"; import ecmarkup from "ecmarkup"; -import { readFile } from "fs/promises"; +import { readFile, writeFile } from "fs/promises"; import { runWatch } from "../../../eng/scripts/helpers.js"; import { resolve } from "path"; @@ -10,11 +10,25 @@ async function build() { const fetch = (path) => readFile(path, "utf-8"); try { - await ecmarkup.build(infile, fetch, { outfile }); + const spec = await ecmarkup.build(infile, fetch, { + outfile, + warn, + }); + for (const [file, contents] of spec.generatedFiles) { + await writeFile(file, contents); + } } catch (err) { console.log(`${infile}(1,1): error EMU0001: Error generating spec: ${err.message}`); throw err; } + + function warn(warning) { + const file = warning.file ?? infile; + const line = warning.line ?? 1; + const col = warning.column ?? 1; + const id = "EMU0002" + (warning.ruleId ? `: ${warning.ruleId}` : ""); + console.log(`${file}(${line},${col}): warning ${id}: ${warning.message}`); + } } runWatch(watch, "src", build); diff --git a/packages/adl-language/src/spec.emu.html b/packages/adl-language/src/spec.emu.html index 02cf62296..bc304fb30 100644 --- a/packages/adl-language/src/spec.emu.html +++ b/packages/adl-language/src/spec.emu.html @@ -65,13 +65,16 @@ BooleanLiteral : NumericLiteral : DecimalLiteral HexIntegerLiteral + BinaryIntegerLiteral DecimalLiteral : - DecimalIntegerLiteral `.` DecimalDigits? ExponentPart? + DecimalIntegerLiteral `.` DecimalDigits ExponentPart? DecimalIntegerLiteral ExponentPart? DecimalIntegerLiteral : DecimalDigits + `+` DecimalDigits + `-` DecimalDigits DecimalDigits : DecimalDigit @@ -81,9 +84,9 @@ DecimalDigit : one of `0` `1` `2` `3` `4` `5` `6` `7` `8` `9` ExponentPart : - `e` SignedInteger + `e` DecimalIntegerLiteral -SignedInteger : +DecimalIntegerInteger : DecimalDigits `+` DecimalDigits `-` DecimalDigits @@ -108,15 +111,23 @@ BinaryDigits : BinaryDigit : one of `0` `1` -// TODO: triple-quoted strings not specified yet, tricky to express. - +// NOTE: This does not specify the extra rules about '"""'s going +// on their own lines and having consistent indentation. StringLiteral : `"` StringCharacters? `"` + `"""` TripleQuotedStringCharacters? `"""` StringCharacters : StringCharacter StringCharacters? StringCharacter : + SourceCharacter but not one of `"` or `\` or LineTerminator + `\` EscapeCharacter + +TripleQuotedStringCharacters + TripleQuotedStringCharacter TripleQuotedStringCharacters? + +TripleQuotedStringCharacter : SourceCharacter but not one of `"` or `\` `\` EscapeCharacter @@ -203,6 +214,8 @@ Statement : NamespaceStatement OperationStatement UsingStatement + EnumStatement + AliasStatement `;` UsingStatement : @@ -210,22 +223,10 @@ UsingStatement : ModelStatement : DecoratorList? `model` Identifier TemplateParameters? ModelHeritage? `{` ModelBody? `}` - DecoratorList? `model` Identifier TemplateParameters? `=` Expression `;` ModelHeritage : `extends` ReferenceExpressionList -ReferenceExpressionList : - ReferenceExpression - ReferenceExpressionList `,` ReferenceExpression - -TemplateParameters : - `<` IdentifierList `>` - -IdentifierList : - Identifier - IdentifierList `,` Identifier - ModelBody : ModelPropertyList `,`? ModelPropertyList `;`? @@ -243,6 +244,40 @@ ModelProperty: ModelSpreadProperty : `...` ReferenceExpression +EnumStatement : + DecoratorList? `enum` Identifier `{` EnumBody? `}` + +EnumBody : + EnumMemberList `,`? + EnumMemberList `;`? + +EnumMemberList : + EnumMember + EnumMemberList `,` EnumMember + EnumMemberList `;` EnumMember + +EnumMember : + DecoratorList? Identifier EnumMemberValue? + DecoratorList? StringLiteral EnumMemberValue? + +EnumMemberValue : + `:` StringLiteral + `:` NumericLiteral + +AliasStatement : + `alias` Identifier TemplateParameters? `=` Expression; + +ReferenceExpressionList : + ReferenceExpression + ReferenceExpressionList `,` ReferenceExpression + +TemplateParameters : + `<` IdentifierList `>` + +IdentifierList : + Identifier + IdentifierList `,` Identifier + NamespaceStatement: DecoratorList? `namespace` IdentifierOrMemberExpression `{` StatementList? `}` diff --git a/packages/adl-rest/CHANGELOG.json b/packages/adl-rest/CHANGELOG.json index 8bc162ede..4d76da126 100644 --- a/packages/adl-rest/CHANGELOG.json +++ b/packages/adl-rest/CHANGELOG.json @@ -1,6 +1,43 @@ { "name": "@azure-tools/adl-rest", "entries": [ + { + "version": "0.2.1", + "tag": "@azure-tools/adl-rest_v0.2.1", + "date": "Tue, 18 May 2021 23:43:31 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@azure-tools/adl\" from `0.10.0` to `0.11.0`" + } + ] + } + }, + { + "version": "0.2.0", + "tag": "@azure-tools/adl-rest_v0.2.0", + "date": "Thu, 06 May 2021 14:56:01 GMT", + "comments": { + "minor": [ + { + "comment": "Implement alias and enum, remove model =" + } + ], + "patch": [ + { + "comment": "**Added** New type NoContentResponse" + }, + { + "comment": "Replace several internal compiler errors with diagnostics" + } + ], + "dependency": [ + { + "comment": "Updating dependency \"@azure-tools/adl\" from `0.9.0` to `0.10.0`" + } + ] + } + }, { "version": "0.1.2", "tag": "@azure-tools/adl-rest_v0.1.2", diff --git a/packages/adl-rest/CHANGELOG.md b/packages/adl-rest/CHANGELOG.md index d5cc1b2a0..d1a5970cd 100644 --- a/packages/adl-rest/CHANGELOG.md +++ b/packages/adl-rest/CHANGELOG.md @@ -1,6 +1,23 @@ # Change Log - @azure-tools/adl-rest -This log was last generated on Tue, 20 Apr 2021 15:23:29 GMT and should not be manually modified. +This log was last generated on Tue, 18 May 2021 23:43:31 GMT and should not be manually modified. + +## 0.2.1 +Tue, 18 May 2021 23:43:31 GMT + +_Version update only_ + +## 0.2.0 +Thu, 06 May 2021 14:56:01 GMT + +### Minor changes + +- Implement alias and enum, remove model = + +### Patches + +- **Added** New type NoContentResponse +- Replace several internal compiler errors with diagnostics ## 0.1.2 Tue, 20 Apr 2021 15:23:29 GMT diff --git a/packages/adl-rest/package.json b/packages/adl-rest/package.json index f5ff909da..b07fe5588 100644 --- a/packages/adl-rest/package.json +++ b/packages/adl-rest/package.json @@ -1,6 +1,6 @@ { "name": "@azure-tools/adl-rest", - "version": "0.1.2", + "version": "0.2.1", "author": "Microsoft Corporation", "description": "ADL REST protocol binding", "homepage": "https://github.com/Azure/adl", @@ -32,10 +32,10 @@ "!dist/test/**" ], "dependencies": { - "@azure-tools/adl": "0.9.0" + "@azure-tools/adl": "0.11.0" }, "devDependencies": { "@types/node": "~14.0.27", - "typescript": "~4.2.4" + "typescript": "~4.3.2" } } diff --git a/packages/adl-rest/src/rest.ts b/packages/adl-rest/src/rest.ts index 5a2ed24a6..3d5cfb41d 100644 --- a/packages/adl-rest/src/rest.ts +++ b/packages/adl-rest/src/rest.ts @@ -1,7 +1,6 @@ import { NamespaceType, OperationType, Program, throwDiagnostic, Type } from "@azure-tools/adl"; -const basePaths = new Map(); - +const basePathsKey = Symbol(); export interface HttpOperationType extends OperationType { basePath: string, route: OperationRoute @@ -25,45 +24,46 @@ export function getHttpOperation(operation: OperationType): HttpOperationType | export function resource(program: Program, entity: Type, basePath = "") { if (entity.kind !== "Namespace") return; - basePaths.set(entity, basePath); + program.stateMap(basePathsKey).set(entity, basePath); } -export function getResources() { - return Array.from(basePaths.keys()); +export function getResources(program: Program) { + return Array.from(program.stateMap(basePathsKey).keys()); } -export function isResource(obj: Type) { - return basePaths.has(obj); +export function isResource(program: Program, obj: Type) { + return program.stateMap(basePathsKey).has(obj); } -export function basePathForResource(resource: Type) { - return basePaths.get(resource); +export function basePathForResource(program: Program, resource: Type) { + return program.stateMap(basePathsKey).get(resource); } -const headerFields = new Map(); +const headerFieldsKey = Symbol(); export function header(program: Program, entity: Type, headerName: string) { if (!headerName && entity.kind === "ModelProperty") { headerName = entity.name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase(); } - headerFields.set(entity, headerName); + program.stateMap(headerFieldsKey).set(entity, headerName); } -export function getHeaderFieldName(entity: Type) { - return headerFields.get(entity); +export function getHeaderFieldName(program: Program, entity: Type) { + return program.stateMap(headerFieldsKey).get(entity); } -const queryFields = new Map(); +const queryFieldsKey = Symbol(); export function query(program: Program, entity: Type, queryKey: string) { if (!queryKey && entity.kind === "ModelProperty") { queryKey = entity.name; } - queryFields.set(entity, queryKey); + program.stateMap(queryFieldsKey).set(entity, queryKey); } -export function getQueryParamName(entity: Type) { - return queryFields.get(entity); +export function getQueryParamName(program: Program, entity: Type) { + return program.stateMap(queryFieldsKey).get(entity); } +const pathFieldsKey = Symbol(); export function isQueryParam(entity: Type) { return queryFields.has(entity); } @@ -73,24 +73,25 @@ export function path(program: Program, entity: Type, paramName: string) { if (!paramName && entity.kind === "ModelProperty") { paramName = entity.name; } - pathFields.set(entity, paramName); + program.stateMap(pathFieldsKey).set(entity, paramName); } -export function getPathParamName(entity: Type) { - return pathFields.get(entity); +export function getPathParamName(program: Program, entity: Type) { + return program.stateMap(pathFieldsKey).get(entity); } +const bodyFieldsKey = Symbol(); export function isPathParam(entity: Type) { return pathFields.has(entity); } const bodyFields = new Set(); export function body(program: Program, entity: Type) { - bodyFields.add(entity); + program.stateSet(bodyFieldsKey).add(entity); } -export function isBody(entity: Type) { - return bodyFields.has(entity); +export function isBody(program: Program, entity: Type) { + return program.stateSet(bodyFieldsKey).has(entity); } export type HttpVerb = "get" | "put" | "post" | "patch" | "delete"; @@ -100,47 +101,47 @@ interface OperationRoute { subPath?: string; } -const operationRoutes = new Map(); +const operationRoutesKey = Symbol(); -function setOperationRoute(entity: Type, verb: OperationRoute) { +function setOperationRoute(program: Program, entity: Type, verb: OperationRoute) { if (entity.kind === "Operation") { - if (!operationRoutes.has(entity)) { - operationRoutes.set(entity, verb); + if (!program.stateMap(operationRoutesKey).has(entity)) { + program.stateMap(operationRoutesKey).set(entity, verb); } else { - throwDiagnostic(`HTTP verb already applied to ${entity.name}`, entity); + program.reportDiagnostic(`HTTP verb already applied to ${entity.name}`, entity); } } else { - throwDiagnostic(`Cannot use @${verb} on a ${entity.kind}`, entity); + program.reportDiagnostic(`Cannot use @${verb} on a ${entity.kind}`, entity); } } -export function getOperationRoute(entity: Type): OperationRoute | undefined { - return operationRoutes.get(entity); +export function getOperationRoute(program: Program, entity: Type): OperationRoute | undefined { + return program.stateMap(operationRoutesKey).get(entity); } export function get(program: Program, entity: Type, subPath?: string) { - setOperationRoute(entity, { + setOperationRoute(program, entity, { verb: "get", subPath, }); } export function put(program: Program, entity: Type, subPath?: string) { - setOperationRoute(entity, { + setOperationRoute(program, entity, { verb: "put", subPath, }); } export function post(program: Program, entity: Type, subPath?: string) { - setOperationRoute(entity, { + setOperationRoute(program, entity, { verb: "post", subPath, }); } export function patch(program: Program, entity: Type, subPath?: string) { - setOperationRoute(entity, { + setOperationRoute(program, entity, { verb: "patch", subPath, }); @@ -148,7 +149,7 @@ export function patch(program: Program, entity: Type, subPath?: string) { // BUG #243: How do we deal with reserved words? export function _delete(program: Program, entity: Type, subPath?: string) { - setOperationRoute(entity, { + setOperationRoute(program, entity, { verb: "delete", subPath, }); @@ -156,39 +157,60 @@ export function _delete(program: Program, entity: Type, subPath?: string) { // -- Service-level Metadata -const serviceDetails: { +interface ServiceDetails { namespace?: NamespaceType; title?: string; version?: string; host?: string; } = {}; +const programServiceDetails = new WeakMap(); +function getServiceDetails(program: Program) { + let serviceDetails = programServiceDetails.get(program); + if (!serviceDetails) { + serviceDetails = {}; + programServiceDetails.set(program, serviceDetails); + } -export function _setServiceNamespace(namespace: NamespaceType): void { + return serviceDetails; +} + +export function _setServiceNamespace(program: Program, namespace: NamespaceType): void { + const serviceDetails = getServiceDetails(program); if (serviceDetails.namespace && serviceDetails.namespace !== namespace) { - throwDiagnostic("Cannot set service namespace more than once in an ADL project.", namespace); + program.reportDiagnostic( + "Cannot set service namespace more than once in an ADL project.", + namespace + ); } serviceDetails.namespace = namespace; } -export function _checkIfServiceNamespace(namespace: NamespaceType): boolean { +export function _checkIfServiceNamespace(program: Program, namespace: NamespaceType): boolean { + const serviceDetails = getServiceDetails(program); return serviceDetails.namespace === namespace; } export function serviceTitle(program: Program, entity: Type, title: string) { + const serviceDetails = getServiceDetails(program); if (serviceDetails.title) { - throwDiagnostic("Service title can only be set once per ADL document.", entity); + program.reportDiagnostic("Service title can only be set once per ADL document.", entity); } if (entity.kind !== "Namespace") { - throwDiagnostic("The @serviceTitle decorator can only be applied to namespaces.", entity); + program.reportDiagnostic( + "The @serviceTitle decorator can only be applied to namespaces.", + entity + ); + return; } - _setServiceNamespace(entity); + _setServiceNamespace(program, entity); serviceDetails.title = title; } -export function getServiceTitle(): string { +export function getServiceTitle(program: Program): string { + const serviceDetails = getServiceDetails(program); return serviceDetails.title || "(title)"; } @@ -210,56 +232,63 @@ export function getServiceHost(): string { } export function serviceVersion(program: Program, entity: Type, version: string) { + const serviceDetails = getServiceDetails(program); // TODO: This will need to change once we support multiple service versions if (serviceDetails.version) { - throwDiagnostic("Service version can only be set once per ADL document.", entity); + program.reportDiagnostic("Service version can only be set once per ADL document.", entity); } if (entity.kind !== "Namespace") { - throwDiagnostic("The @serviceVersion decorator can only be applied to namespaces.", entity); + program.reportDiagnostic( + "The @serviceVersion decorator can only be applied to namespaces.", + entity + ); + return; } - _setServiceNamespace(entity); + _setServiceNamespace(program, entity); serviceDetails.version = version; } -export function getServiceVersion(): string { +export function getServiceVersion(program: Program): string { + const serviceDetails = getServiceDetails(program); return serviceDetails.version || "0000-00-00"; } export function getServiceNamespaceString(program: Program): string | undefined { + const serviceDetails = getServiceDetails(program); return ( (serviceDetails.namespace && program.checker!.getNamespaceString(serviceDetails.namespace)) || undefined ); } -const producesTypes = new Map(); +const producesTypesKey = Symbol(); export function produces(program: Program, entity: Type, ...contentTypes: string[]) { if (entity.kind !== "Namespace") { - throwDiagnostic("The @produces decorator can only be applied to namespaces.", entity); + program.reportDiagnostic("The @produces decorator can only be applied to namespaces.", entity); } - const values = getProduces(entity); - producesTypes.set(entity, values.concat(contentTypes)); + const values = getProduces(program, entity); + program.stateMap(producesTypesKey).set(entity, values.concat(contentTypes)); } -export function getProduces(entity: Type): string[] { - return producesTypes.get(entity) || []; +export function getProduces(program: Program, entity: Type): string[] { + return program.stateMap(producesTypesKey).get(entity) || []; } -const consumesTypes = new Map(); +const consumesTypesKey = Symbol(); export function consumes(program: Program, entity: Type, ...contentTypes: string[]) { if (entity.kind !== "Namespace") { - throwDiagnostic("The @consumes decorator can only be applied to namespaces.", entity); + program.reportDiagnostic("The @consumes decorator can only be applied to namespaces.", entity); } - const values = getConsumes(entity); - consumesTypes.set(entity, values.concat(contentTypes)); + const values = getConsumes(program, entity); + program.stateMap(consumesTypesKey).set(entity, values.concat(contentTypes)); } -export function getConsumes(entity: Type): string[] { - return consumesTypes.get(entity) || []; +export function getConsumes(program: Program, entity: Type): string[] { + return program.stateMap(consumesTypesKey).get(entity) || []; } diff --git a/packages/adl-rest/tsconfig.json b/packages/adl-rest/tsconfig.json index 84c460a80..0e8075fa9 100644 --- a/packages/adl-rest/tsconfig.json +++ b/packages/adl-rest/tsconfig.json @@ -1,5 +1,6 @@ { "extends": "../tsconfig.json", + "references": [{ "path": "../adl/tsconfig.json" }], "compilerOptions": { "outDir": "dist", "rootDir": "src" diff --git a/packages/adl-vs/.editorconfig b/packages/adl-vs/.editorconfig deleted file mode 100644 index 4819a04a7..000000000 --- a/packages/adl-vs/.editorconfig +++ /dev/null @@ -1,16 +0,0 @@ -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -insert_final_newline = true -charset = utf-8 - -[*.cs] -csharp_new_line_before_open_brace = none -csharp_new_line_before_catch = false -csharp_new_line_before_else = false -csharp_new_line_before_finally = false -csharp_new_line_before_members_in_anonymous_types = false -csharp_new_line_before_members_in_object_initializers = false diff --git a/packages/adl-vs/CHANGELOG.json b/packages/adl-vs/CHANGELOG.json index 69cbdab2a..d7312cdb6 100644 --- a/packages/adl-vs/CHANGELOG.json +++ b/packages/adl-vs/CHANGELOG.json @@ -1,6 +1,30 @@ { "name": "@azure-tools/adl-vs", "entries": [ + { + "version": "0.1.5", + "tag": "@azure-tools/adl-vs_v0.1.5", + "date": "Tue, 18 May 2021 23:43:31 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"adl-vscode\" from `0.5.0` to `0.5.1`" + } + ] + } + }, + { + "version": "0.1.4", + "tag": "@azure-tools/adl-vs_v0.1.4", + "date": "Thu, 06 May 2021 14:56:02 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"adl-vscode\" from `0.4.5` to `0.5.0`" + } + ] + } + }, { "version": "0.1.3", "tag": "@azure-tools/adl-vs_v0.1.3", diff --git a/packages/adl-vs/CHANGELOG.md b/packages/adl-vs/CHANGELOG.md index 82e1ec58c..fc1ba8160 100644 --- a/packages/adl-vs/CHANGELOG.md +++ b/packages/adl-vs/CHANGELOG.md @@ -1,6 +1,16 @@ # Change Log - @azure-tools/adl-vs -This log was last generated on Tue, 20 Apr 2021 15:23:29 GMT and should not be manually modified. +This log was last generated on Tue, 18 May 2021 23:43:31 GMT and should not be manually modified. + +## 0.1.5 +Tue, 18 May 2021 23:43:31 GMT + +_Version update only_ + +## 0.1.4 +Thu, 06 May 2021 14:56:02 GMT + +_Version update only_ ## 0.1.3 Tue, 20 Apr 2021 15:23:29 GMT diff --git a/packages/adl-vs/package.json b/packages/adl-vs/package.json index 17de4952e..59e684774 100644 --- a/packages/adl-vs/package.json +++ b/packages/adl-vs/package.json @@ -1,7 +1,7 @@ { "name": "@azure-tools/adl-vs", "author": "Microsoft Corporation", - "version": "0.1.3", + "version": "0.1.5", "description": "ADL Language Support for Visual Studio", "homepage": "https://github.com/Azure/adl", "readme": "https://github.com/Azure/adl/blob/master/README.md", @@ -28,6 +28,6 @@ }, "dependencies": {}, "devDependencies": { - "adl-vscode": "0.4.5" + "adl-vscode": "0.5.1" } } diff --git a/packages/adl-vs/scripts/build.js b/packages/adl-vs/scripts/build.js index 0d7fcdee9..9750a3866 100644 --- a/packages/adl-vs/scripts/build.js +++ b/packages/adl-vs/scripts/build.js @@ -40,13 +40,8 @@ if (proc.status != 0 || proc.error || !proc.stdout) { // In official build on Windows, it's an error if VS is not found. console.error(`error: ${message}`); process.exit(1); - } else if (proc.error?.code === "ENOENT") { - // If developer has no version of VS installed, skip build without warning. - console.log(`Skipping adl-vs build: ${message}.`); - process.exit(0); } else { - // If developer has VS but it's not recent enough, skip build with warning. - console.error(`warning: ${message}. Skipping adl-vs build.`); + console.log(`Skipping adl-vs build: ${message}.`); process.exit(0); } } diff --git a/packages/adl-vscode/CHANGELOG.json b/packages/adl-vscode/CHANGELOG.json index e3f6243d6..465ac2d8e 100644 --- a/packages/adl-vscode/CHANGELOG.json +++ b/packages/adl-vscode/CHANGELOG.json @@ -1,6 +1,35 @@ { "name": "adl-vscode", "entries": [ + { + "version": "0.5.1", + "tag": "adl-vscode_v0.5.1", + "date": "Tue, 18 May 2021 23:43:31 GMT", + "comments": { + "patch": [ + { + "comment": "Fix issue launching adl-server on Mac OS" + } + ] + } + }, + { + "version": "0.5.0", + "tag": "adl-vscode_v0.5.0", + "date": "Thu, 06 May 2021 14:56:02 GMT", + "comments": { + "minor": [ + { + "comment": "Implement alias and enum, remove model =" + } + ], + "patch": [ + { + "comment": "Update syntax highlighting for string literal change" + } + ] + } + }, { "version": "0.4.5", "tag": "adl-vscode_v0.4.5", diff --git a/packages/adl-vscode/CHANGELOG.md b/packages/adl-vscode/CHANGELOG.md index 73d0c258e..4cbd5889f 100644 --- a/packages/adl-vscode/CHANGELOG.md +++ b/packages/adl-vscode/CHANGELOG.md @@ -1,6 +1,24 @@ # Change Log - adl-vscode -This log was last generated on Tue, 20 Apr 2021 15:23:29 GMT and should not be manually modified. +This log was last generated on Tue, 18 May 2021 23:43:31 GMT and should not be manually modified. + +## 0.5.1 +Tue, 18 May 2021 23:43:31 GMT + +### Patches + +- Fix issue launching adl-server on Mac OS + +## 0.5.0 +Thu, 06 May 2021 14:56:02 GMT + +### Minor changes + +- Implement alias and enum, remove model = + +### Patches + +- Update syntax highlighting for string literal change ## 0.4.5 Tue, 20 Apr 2021 15:23:29 GMT diff --git a/packages/adl-vscode/package.json b/packages/adl-vscode/package.json index 4868b1af0..e47d0dac9 100644 --- a/packages/adl-vscode/package.json +++ b/packages/adl-vscode/package.json @@ -1,6 +1,6 @@ { "name": "adl-vscode", - "version": "0.4.5", + "version": "0.5.1", "author": "Microsoft Corporation", "description": "ADL Language Support for VS Code", "homepage": "https://github.com/Azure/adl", @@ -75,7 +75,7 @@ "watch-tmlanguage": "node scripts/watch-tmlanguage.js", "dogfood": "node scripts/dogfood.js", "generate-tmlanguage": "node scripts/generate-tmlanguage.js", - "generate-third-party-notices": "node scripts/generate-third-party-notices.js", + "generate-third-party-notices": "node ../../eng/scripts/generate-third-party-notices", "rollup": "rollup --config --failAfterWarnings 2>&1", "package-vsix": "vsce package --yarn" }, @@ -84,10 +84,12 @@ "@azure-tools/tmlanguage-generator": "0.1.4", "@rollup/plugin-commonjs": "~17.1.0", "@rollup/plugin-node-resolve": "~11.2.0", + "@types/mkdirp": "~1.0.1", "@types/node": "~14.0.27", "@types/vscode": "~1.53.0", + "mkdirp": "~1.0.4", "rollup": "~2.41.4", - "typescript": "~4.2.4", + "typescript": "~4.3.2", "vsce": "~1.85.1", "vscode-languageclient": "~7.0.0", "watch": "~1.0.2" diff --git a/packages/adl-vscode/src/extension.ts b/packages/adl-vscode/src/extension.ts index 546f93b39..fbb193c87 100644 --- a/packages/adl-vscode/src/extension.ts +++ b/packages/adl-vscode/src/extension.ts @@ -1,12 +1,20 @@ import { ExtensionContext, workspace } from "vscode"; -import { Executable, LanguageClient, LanguageClientOptions } from "vscode-languageclient/node.js"; +import { + Executable, + ExecutableOptions, + LanguageClient, + LanguageClientOptions, +} from "vscode-languageclient/node.js"; let client: LanguageClient | undefined; export function activate(context: ExtensionContext) { const exe = resolveADLServer(context); const options: LanguageClientOptions = { - documentSelector: [{ scheme: "file", language: "adl" }], + documentSelector: [ + { scheme: "file", language: "adl" }, + { scheme: "untitled", language: "adl" }, + ], }; const name = "ADL"; @@ -19,9 +27,9 @@ function resolveADLServer(context: ExtensionContext): Executable { const nodeOptions = process.env.ADL_SERVER_NODE_OPTIONS; const args = ["--stdio"]; - // In development mode (F5 launch from source), resolve to locally built adl-server.js. + // In development mode (F5 launch from source), resolve to locally built server.js. if (process.env.ADL_DEVELOPMENT_MODE) { - const script = context.asAbsolutePath("../adl/cmd/adl-server.js"); + const script = context.asAbsolutePath("../adl/dist/server/server.js"); // we use CLI instead of NODE_OPTIONS environment variable in this case // because --nolazy is not supported by NODE_OPTIONS. const options = nodeOptions?.split(" ") ?? []; @@ -50,7 +58,14 @@ function resolveADLServer(context: ExtensionContext): Executable { command += ".cmd"; } - return { command, args, options: { env: { NODE_OPTIONS: nodeOptions } } }; + let options: ExecutableOptions | undefined; + if (nodeOptions) { + options = { + env: { ...process.env, NODE_OPTIONS: nodeOptions }, + }; + } + + return { command, args, options }; } export async function deactivate() { diff --git a/packages/adl-vscode/src/tmlanguage.ts b/packages/adl-vscode/src/tmlanguage.ts index fba12ebab..620641329 100644 --- a/packages/adl-vscode/src/tmlanguage.ts +++ b/packages/adl-vscode/src/tmlanguage.ts @@ -3,6 +3,7 @@ import * as tm from "@azure-tools/tmlanguage-generator"; import fs from "fs/promises"; +import mkdirp from "mkdirp"; import { resolve } from "path"; type IncludeRule = tm.IncludeRule; @@ -20,6 +21,7 @@ type ADLScope = | "entity.name.function.adl" | "keyword.other.adl" | "string.quoted.double.adl" + | "string.quoted.triple.adl" | "variable.name.adl"; const meta: typeof tm.meta = tm.meta; @@ -28,7 +30,7 @@ const identifierContinue = "[_$[:alnum:]]"; const beforeIdentifier = `(?=${identifierStart})`; const identifier = `\\b${identifierStart}${identifierContinue}*\\b`; const stringPattern = '\\"(?:[^\\"\\\\]|\\\\.)*\\"'; -const statementKeyword = `\\b(?:namespace|model|op|using|import)\\b`; +const statementKeyword = `\\b(?:namespace|model|op|using|import|enum|alias)\\b`; const universalEnd = `(?=,|;|@|\\)|\\}|${statementKeyword})`; const hexNumber = "\\b(? { + return new Promise((resolve, reject) => { + resolveModule( + "@azure-tools/adl", + { + basedir: process.cwd(), + preserveSymlinks: false, + }, + (err, resolved) => { + let packageRoot: string; + if (err) { + if ((err as any).code === "MODULE_NOT_FOUND") { + // Resolution from cwd failed: use current package. + packageRoot = path.resolve(url.fileURLToPath(import.meta.url), "../../.."); + } else { + reject(err); + return; + } + } else if (!resolved) { + reject(new Error("BUG: Module resolution succeeded, but didn't return a value.")); + return; + } else { + // Resolution succeeded to dist/compiler/index.js in local package. + packageRoot = path.resolve(resolved, "../../.."); + } + const script = path.join(packageRoot, relativePath); + const scriptUrl = url.pathToFileURL(script).toString(); + resolve(import(scriptUrl)); + } + ); + }); +} diff --git a/packages/adl/compiler/binder.ts b/packages/adl/compiler/binder.ts index 6ef281886..38253cda2 100644 --- a/packages/adl/compiler/binder.ts +++ b/packages/adl/compiler/binder.ts @@ -3,7 +3,9 @@ import { visitChildren } from "./parser.js"; import { Program } from "./program.js"; import { ADLScriptNode, + AliasStatementNode, Declaration, + EnumStatementNode, ModelStatementNode, NamespaceStatementNode, Node, @@ -15,7 +17,6 @@ import { TemplateParameterDeclarationNode, UsingStatementNode, } from "./types.js"; -import { reportDuplicateSymbols } from "./util.js"; const SymbolTable = class extends Map implements SymbolTable { duplicates = new Set(); @@ -47,16 +48,23 @@ export interface TypeSymbol { } export interface Binder { - bindSourceFile(program: Program, sourceFile: ADLScriptNode): void; + bindSourceFile(sourceFile: ADLScriptNode): void; + bindNode(node: Node): void; } export function createSymbolTable(): SymbolTable { return new SymbolTable(); } -export function createBinder(): Binder { +export interface BinderOptions { + // Configures the initial parent node to use when calling bindNode. This is + // useful for binding ADL fragments outside the context of a full script node. + initialParentNode?: Node; +} + +export function createBinder(program: Program, options: BinderOptions = {}): Binder { let currentFile: ADLScriptNode; - let parentNode: Node; + let parentNode: Node | undefined = options?.initialParentNode; let globalNamespace: NamespaceStatementNode; let fileNamespace: NamespaceStatementNode; let currentNamespace: NamespaceStatementNode; @@ -65,9 +73,10 @@ export function createBinder(): Binder { let scope: ScopeNode; return { bindSourceFile, + bindNode, }; - function bindSourceFile(program: Program, sourceFile: ADLScriptNode) { + function bindSourceFile(sourceFile: ADLScriptNode) { globalNamespace = program.globalNamespace; fileNamespace = globalNamespace; currentFile = sourceFile; @@ -85,6 +94,12 @@ export function createBinder(): Binder { case SyntaxKind.ModelStatement: bindModelStatement(node); break; + case SyntaxKind.AliasStatement: + bindAliasStatement(node); + break; + case SyntaxKind.EnumStatement: + bindEnumStatement(node); + break; case SyntaxKind.NamespaceStatement: bindNamespaceStatement(node); break; @@ -114,7 +129,7 @@ export function createBinder(): Binder { visitChildren(node, bindNode); if (node.kind !== SyntaxKind.NamespaceStatement) { - reportDuplicateSymbols(node.locals!); + program.reportDuplicateSymbols(node.locals!); } scope = prevScope; @@ -148,6 +163,16 @@ export function createBinder(): Binder { node.locals = new SymbolTable(); } + function bindAliasStatement(node: AliasStatementNode) { + declareSymbol(getContainingSymbolTable(), node, node.id.sv); + // Initialize locals for type parameters + node.locals = new SymbolTable(); + } + + function bindEnumStatement(node: EnumStatementNode) { + declareSymbol(getContainingSymbolTable(), node, node.id.sv); + } + function bindNamespaceStatement(statement: NamespaceStatementNode) { // check if there's an existing symbol for this namespace const existingBinding = currentNamespace.exports!.get(statement.name.sv); @@ -217,6 +242,7 @@ export function createBinder(): Binder { function hasScope(node: Node): node is ScopeNode { switch (node.kind) { case SyntaxKind.ModelStatement: + case SyntaxKind.AliasStatement: return true; case SyntaxKind.NamespaceStatement: return node.statements !== undefined; diff --git a/packages/adl/compiler/character-codes.ts b/packages/adl/compiler/character-codes.ts deleted file mode 100644 index a6efa66fc..000000000 --- a/packages/adl/compiler/character-codes.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { nonAsciiIdentifierContinueMap, nonAsciiIdentifierStartMap } from "./non-ascii-maps.js"; - -export const enum CharacterCodes { - nullCharacter = 0, - maxAsciiCharacter = 0x7f, - - lineFeed = 0x0a, - carriageReturn = 0x0d, - lineSeparator = 0x2028, - paragraphSeparator = 0x2029, - nextLine = 0x0085, - - // Unicode 3.0 space characters - space = 0x0020, - nonBreakingSpace = 0x00a0, - enQuad = 0x2000, - emQuad = 0x2001, - enSpace = 0x2002, - emSpace = 0x2003, - threePerEmSpace = 0x2004, - fourPerEmSpace = 0x2005, - sixPerEmSpace = 0x2006, - figureSpace = 0x2007, - punctuationSpace = 0x2008, - thinSpace = 0x2009, - hairSpace = 0x200a, - zeroWidthSpace = 0x200b, - narrowNoBreakSpace = 0x202f, - ideographicSpace = 0x3000, - mathematicalSpace = 0x205f, - ogham = 0x1680, - - _ = 0x5f, - $ = 0x24, - - _0 = 0x30, - _1 = 0x31, - _2 = 0x32, - _3 = 0x33, - _4 = 0x34, - _5 = 0x35, - _6 = 0x36, - _7 = 0x37, - _8 = 0x38, - _9 = 0x39, - - a = 0x61, - b = 0x62, - c = 0x63, - d = 0x64, - e = 0x65, - f = 0x66, - g = 0x67, - h = 0x68, - i = 0x69, - j = 0x6a, - k = 0x6b, - l = 0x6c, - m = 0x6d, - n = 0x6e, - o = 0x6f, - p = 0x70, - q = 0x71, - r = 0x72, - s = 0x73, - t = 0x74, - u = 0x75, - v = 0x76, - w = 0x77, - x = 0x78, - y = 0x79, - z = 0x7a, - - A = 0x41, - B = 0x42, - C = 0x43, - D = 0x44, - E = 0x45, - F = 0x46, - G = 0x47, - H = 0x48, - I = 0x49, - J = 0x4a, - K = 0x4b, - L = 0x4c, - M = 0x4d, - N = 0x4e, - O = 0x4f, - P = 0x50, - Q = 0x51, - R = 0x52, - S = 0x53, - T = 0x54, - U = 0x55, - V = 0x56, - W = 0x57, - X = 0x58, - Y = 0x59, - Z = 0x5a, - - ampersand = 0x26, - asterisk = 0x2a, - at = 0x40, - backslash = 0x5c, - backtick = 0x60, - bar = 0x7c, - caret = 0x5e, - closeBrace = 0x7d, - closeBracket = 0x5d, - closeParen = 0x29, - colon = 0x3a, - comma = 0x2c, - dot = 0x2e, - doubleQuote = 0x22, - equals = 0x3d, - exclamation = 0x21, - greaterThan = 0x3e, - hash = 0x23, - lessThan = 0x3c, - minus = 0x2d, - openBrace = 0x7b, - openBracket = 0x5b, - openParen = 0x28, - percent = 0x25, - plus = 0x2b, - question = 0x3f, - semicolon = 0x3b, - singleQuote = 0x27, - slash = 0x2f, - tilde = 0x7e, - - backspace = 0x08, - formFeed = 0x0c, - byteOrderMark = 0xfeff, - tab = 0x09, - verticalTab = 0x0b, -} - -/** Does not include line breaks. For that, see isWhiteSpaceLike. */ -export function isWhiteSpaceSingleLine(ch: number): boolean { - // Note: nextLine is in the Zs space, and should be considered to be a whitespace. - // It is explicitly not a line-break as it isn't in the exact set specified by EcmaScript. - return ( - ch === CharacterCodes.space || - ch === CharacterCodes.tab || - ch === CharacterCodes.verticalTab || - ch === CharacterCodes.formFeed || - ch === CharacterCodes.nonBreakingSpace || - ch === CharacterCodes.nextLine || - ch === CharacterCodes.ogham || - (ch >= CharacterCodes.enQuad && ch <= CharacterCodes.zeroWidthSpace) || - ch === CharacterCodes.narrowNoBreakSpace || - ch === CharacterCodes.mathematicalSpace || - ch === CharacterCodes.ideographicSpace || - ch === CharacterCodes.byteOrderMark - ); -} - -export function isLineBreak(ch: number): boolean { - // Other new line or line - // breaking characters are treated as white space but not as line terminators. - return ( - ch === CharacterCodes.lineFeed || - ch === CharacterCodes.carriageReturn || - ch === CharacterCodes.lineSeparator || - ch === CharacterCodes.paragraphSeparator - ); -} - -export function isDigit(ch: number): boolean { - return ch >= CharacterCodes._0 && ch <= CharacterCodes._9; -} - -export function isHexDigit(ch: number): boolean { - return ( - isDigit(ch) || - (ch >= CharacterCodes.A && ch <= CharacterCodes.F) || - (ch >= CharacterCodes.a && ch <= CharacterCodes.f) - ); -} - -export function isBinaryDigit(ch: number): boolean { - return ch === CharacterCodes._0 || ch === CharacterCodes._1; -} - -export function isAsciiIdentifierStart(ch: number): boolean { - return ( - (ch >= CharacterCodes.A && ch <= CharacterCodes.Z) || - (ch >= CharacterCodes.a && ch <= CharacterCodes.z) || - ch === CharacterCodes.$ || - ch === CharacterCodes._ - ); -} - -export function isAsciiIdentifierContinue(ch: number): boolean { - return ( - (ch >= CharacterCodes.A && ch <= CharacterCodes.Z) || - (ch >= CharacterCodes.a && ch <= CharacterCodes.z) || - (ch >= CharacterCodes._0 && ch <= CharacterCodes._9) || - ch === CharacterCodes.$ || - ch === CharacterCodes._ - ); -} - -export function isIdentifierContinue(codePoint: number) { - return ( - isAsciiIdentifierStart(codePoint) || - (codePoint > CharacterCodes.maxAsciiCharacter && isNonAsciiIdentifierContinue(codePoint)) - ); -} - -export function isNonAsciiIdentifierStart(codePoint: number) { - return lookupInNonAsciiMap(codePoint, nonAsciiIdentifierStartMap); -} - -export function isNonAsciiIdentifierContinue(codePoint: number) { - return lookupInNonAsciiMap(codePoint, nonAsciiIdentifierContinueMap); -} - -function lookupInNonAsciiMap(codePoint: number, map: readonly number[]): boolean { - // Bail out quickly if it couldn't possibly be in the map. - if (codePoint < map[0]) { - return false; - } - - // Perform binary search in one of the Unicode range maps - let lo = 0; - let hi: number = map.length; - let mid: number; - - while (lo + 1 < hi) { - mid = lo + (hi - lo) / 2; - // mid has to be even to catch a range's beginning - mid -= mid % 2; - if (map[mid] <= codePoint && codePoint <= map[mid + 1]) { - return true; - } - - if (codePoint < map[mid]) { - hi = mid; - } else { - lo = mid + 2; - } - } - - return false; -} diff --git a/packages/adl/compiler/charcode.ts b/packages/adl/compiler/charcode.ts new file mode 100644 index 000000000..d373b54fa --- /dev/null +++ b/packages/adl/compiler/charcode.ts @@ -0,0 +1,283 @@ +import { nonAsciiIdentifierContinueMap, nonAsciiIdentifierStartMap } from "./nonascii.js"; + +export const enum CharCode { + Null = 0x00, + MaxAscii = 0x7f, + + // ASCII line breaks + LineFeed = 0x0a, + CarriageReturn = 0x0d, + + // Non-ASCII line breaks + LineSeparator = 0x2028, + ParagraphSeparator = 0x2029, + + // ASCII whitespace excluding line breaks + Space = 0x20, + FormFeed = 0x0c, + Tab = 0x09, + VerticalTab = 0x0b, + + // Non-ASCII whitespace excluding line breaks + ByteOrderMark = 0xfeff, // currently allowed anywhere + NextLine = 0x0085, // not considered a line break, mirroring ECMA-262 + NonBreakingSpace = 0x00a0, + EnQuad = 0x2000, + EmQuad = 0x2001, + EnSpace = 0x2002, + EmSpace = 0x2003, + ThreePerEmSpace = 0x2004, + FourPerEmSpace = 0x2005, + SixPerEmSpace = 0x2006, + FigureSpace = 0x2007, + PunctuationSpace = 0x2008, + ThinSpace = 0x2009, + HairSpace = 0x200a, + ZeroWidthSpace = 0x200b, + NarrowNoBreakSpace = 0x202f, + IdeographicSpace = 0x3000, + MathematicalSpace = 0x205f, + Ogham = 0x1680, + + // ASCII Digits + _0 = 0x30, + _1 = 0x31, + _2 = 0x32, + _3 = 0x33, + _4 = 0x34, + _5 = 0x35, + _6 = 0x36, + _7 = 0x37, + _8 = 0x38, + _9 = 0x39, + + // ASCII lowercase letters + a = 0x61, + b = 0x62, + c = 0x63, + d = 0x64, + e = 0x65, + f = 0x66, + g = 0x67, + h = 0x68, + i = 0x69, + j = 0x6a, + k = 0x6b, + l = 0x6c, + m = 0x6d, + n = 0x6e, + o = 0x6f, + p = 0x70, + q = 0x71, + r = 0x72, + s = 0x73, + t = 0x74, + u = 0x75, + v = 0x76, + w = 0x77, + x = 0x78, + y = 0x79, + z = 0x7a, + + // ASCII uppercase letters + A = 0x41, + B = 0x42, + C = 0x43, + D = 0x44, + E = 0x45, + F = 0x46, + G = 0x47, + H = 0x48, + I = 0x49, + J = 0x4a, + K = 0x4b, + L = 0x4c, + M = 0x4d, + N = 0x4e, + O = 0x4f, + P = 0x50, + Q = 0x51, + R = 0x52, + S = 0x53, + T = 0x54, + U = 0x55, + V = 0x56, + W = 0x57, + X = 0x58, + Y = 0x59, + Z = 0x5a, + + // Non-letter, non-digit ASCII characters that are valid in identifiers + _ = 0x5f, + $ = 0x24, + + // ASCII punctuation + Ampersand = 0x26, + Asterisk = 0x2a, + At = 0x40, + Backslash = 0x5c, + Backtick = 0x60, + Bar = 0x7c, + Caret = 0x5e, + CloseBrace = 0x7d, + CloseBracket = 0x5d, + CloseParen = 0x29, + Colon = 0x3a, + Comma = 0x2c, + Dot = 0x2e, + DoubleQuote = 0x22, + Equals = 0x3d, + Exclamation = 0x21, + GreaterThan = 0x3e, + Hash = 0x23, + LessThan = 0x3c, + Minus = 0x2d, + OpenBrace = 0x7b, + OpenBracket = 0x5b, + OpenParen = 0x28, + Percent = 0x25, + Plus = 0x2b, + Question = 0x3f, + Semicolon = 0x3b, + SingleQuote = 0x27, + Slash = 0x2f, + Tilde = 0x7e, +} + +export function utf16CodeUnits(codePoint: number) { + return codePoint >= 0x10000 ? 2 : 1; +} + +export function isAsciiLineBreak(ch: number) { + return ch === CharCode.LineFeed || ch == CharCode.CarriageReturn; +} + +export function isAsciiWhiteSpaceSingleLine(ch: number) { + return ( + ch === CharCode.Space || + ch === CharCode.Tab || + ch === CharCode.VerticalTab || + ch === CharCode.FormFeed + ); +} + +export function isNonAsciiWhiteSpaceSingleLine(ch: number) { + // Note: nextLine is in the Zs space, and should be considered to be a + // whitespace. It is explicitly not a line-break as it isn't in the exact set + // inherited by ADL from JavaScript. + return ( + ch === CharCode.NonBreakingSpace || + ch === CharCode.NextLine || + ch === CharCode.Ogham || + (ch >= CharCode.EnQuad && ch <= CharCode.ZeroWidthSpace) || + ch === CharCode.NarrowNoBreakSpace || + ch === CharCode.MathematicalSpace || + ch === CharCode.IdeographicSpace || + ch === CharCode.ByteOrderMark + ); +} + +export function isNonAsciiLineBreak(ch: number) { + // Other new line or line breaking characters are treated as white space but + // not as line terminators. + return ch === CharCode.ParagraphSeparator || ch === CharCode.LineSeparator; +} + +export function isWhiteSpaceSingleLine(ch: number) { + return ( + isAsciiWhiteSpaceSingleLine(ch) || + (ch > CharCode.MaxAscii && isNonAsciiWhiteSpaceSingleLine(ch)) + ); +} + +export function isLineBreak(ch: number) { + return isAsciiLineBreak(ch) || (ch > CharCode.MaxAscii && isNonAsciiLineBreak(ch)); +} + +export function isDigit(ch: number) { + return ch >= CharCode._0 && ch <= CharCode._9; +} + +export function isHexDigit(ch: number) { + return ( + isDigit(ch) || (ch >= CharCode.A && ch <= CharCode.F) || (ch >= CharCode.a && ch <= CharCode.f) + ); +} + +export function isBinaryDigit(ch: number) { + return ch === CharCode._0 || ch === CharCode._1; +} + +export function isLowercaseAsciiLetter(ch: number) { + return ch >= CharCode.a && ch <= CharCode.z; +} + +export function isAsciiIdentifierStart(ch: number) { + return ( + (ch >= CharCode.A && ch <= CharCode.Z) || + (ch >= CharCode.a && ch <= CharCode.z) || + ch === CharCode.$ || + ch === CharCode._ + ); +} + +export function isAsciiIdentifierContinue(ch: number) { + return ( + (ch >= CharCode.A && ch <= CharCode.Z) || + (ch >= CharCode.a && ch <= CharCode.z) || + (ch >= CharCode._0 && ch <= CharCode._9) || + ch === CharCode.$ || + ch === CharCode._ + ); +} + +export function isIdentifierStart(codePoint: number) { + return ( + isAsciiIdentifierStart(codePoint) || + (codePoint > CharCode.MaxAscii && isNonAsciiIdentifierStart(codePoint)) + ); +} + +export function isIdentifierContinue(codePoint: number) { + return ( + isAsciiIdentifierContinue(codePoint) || + (codePoint > CharCode.MaxAscii && isNonAsciiIdentifierContinue(codePoint)) + ); +} + +export function isNonAsciiIdentifierStart(codePoint: number) { + return lookupInNonAsciiMap(codePoint, nonAsciiIdentifierStartMap); +} + +export function isNonAsciiIdentifierContinue(codePoint: number) { + return lookupInNonAsciiMap(codePoint, nonAsciiIdentifierContinueMap); +} + +function lookupInNonAsciiMap(codePoint: number, map: readonly number[]) { + // Bail out quickly if it couldn't possibly be in the map. + if (codePoint < map[0]) { + return false; + } + + // Perform binary search in one of the Unicode range maps + let lo = 0; + let hi: number = map.length; + let mid: number; + + while (lo + 1 < hi) { + mid = lo + (hi - lo) / 2; + // mid has to be even to catch a range's beginning + mid -= mid % 2; + if (map[mid] <= codePoint && codePoint <= map[mid + 1]) { + return true; + } + + if (codePoint < map[mid]) { + hi = mid; + } else { + lo = mid + 2; + } + } + + return false; +} diff --git a/packages/adl/compiler/checker.ts b/packages/adl/compiler/checker.ts index 0ecc7c83f..873b858d2 100644 --- a/packages/adl/compiler/checker.ts +++ b/packages/adl/compiler/checker.ts @@ -1,14 +1,20 @@ -import { compilerAssert, throwDiagnostic } from "./diagnostics.js"; +import { compilerAssert } from "./diagnostics.js"; +import { hasParseError } from "./parser.js"; import { Program } from "./program.js"; import { ADLScriptNode, + AliasStatementNode, ArrayExpressionNode, BooleanLiteralNode, BooleanLiteralType, DecoratorSymbol, + EnumMemberNode, + EnumMemberType, + EnumStatementNode, + EnumType, + ErrorType, IdentifierNode, IntersectionExpressionNode, - IntrinsicType, LiteralNode, LiteralType, ModelExpressionNode, @@ -39,7 +45,22 @@ import { UnionExpressionNode, UnionType, } from "./types.js"; -import { reportDuplicateSymbols } from "./util.js"; + +export interface Checker { + getTypeForNode(node: Node): Type; + checkProgram(program: Program): void; + checkModelProperty(prop: ModelPropertyNode): ModelTypeProperty; + checkUnionExpression(node: UnionExpressionNode): UnionType; + getGlobalNamespaceType(): NamespaceType; + + getLiteralType(node: StringLiteralNode): StringLiteralType; + getLiteralType(node: NumericLiteralNode): NumericLiteralType; + getLiteralType(node: BooleanLiteralNode): BooleanLiteralType; + getLiteralType(node: LiteralNode): LiteralType; + + getTypeName(type: Type): string; + getNamespaceString(type: NamespaceType | undefined): string; +} /** * A map keyed by a set of objects. @@ -87,12 +108,21 @@ interface PendingModelInfo { type: ModelType; } -export function createChecker(program: Program) { +export function createChecker(program: Program): Checker { let templateInstantiation: Type[] = []; let instantiatingTemplate: Node | undefined; let currentSymbolId = 0; const symbolLinks = new Map(); - const errorType: IntrinsicType = { kind: "Intrinsic", name: "ErrorType" }; + const root = createType({ + kind: "Namespace", + name: "", + node: program.globalNamespace, + models: new Map(), + operations: new Map(), + namespaces: new Map(), + }); + + const errorType: ErrorType = { kind: "Intrinsic", name: "ErrorType" }; // This variable holds on to the model type that is currently // being instantiated in checkModelStatement so that it is @@ -103,9 +133,17 @@ export function createChecker(program: Program) { for (const using of file.usings) { const parentNs = using.parent! as NamespaceStatementNode | ADLScriptNode; const sym = resolveTypeReference(using.name); - if (sym.kind === "decorator") throwDiagnostic("Can't use a decorator", using); + if (!sym) { + continue; + } + if (sym.kind === "decorator") { + program.reportDiagnostic("Can't use a decorator", using); + continue; + } + if (sym.node.kind !== SyntaxKind.NamespaceStatement) { - throwDiagnostic("Using must refer to a namespace", using); + program.reportDiagnostic("Using must refer to a namespace", using); + continue; } for (const [name, binding] of sym.node.exports!) { @@ -117,10 +155,12 @@ export function createChecker(program: Program) { return { getTypeForNode, checkProgram, + checkModelProperty, + checkUnionExpression, getLiteralType, getTypeName, getNamespaceString, - checkOperation, + getGlobalNamespaceType, }; function getTypeForNode(node: Node): Type { @@ -131,6 +171,10 @@ export function createChecker(program: Program) { return checkModel(node); case SyntaxKind.ModelProperty: return checkModelProperty(node); + case SyntaxKind.AliasStatement: + return checkAlias(node); + case SyntaxKind.EnumStatement: + return checkEnum(node); case SyntaxKind.NamespaceStatement: return checkNamespace(node); case SyntaxKind.OperationStatement: @@ -162,6 +206,8 @@ export function createChecker(program: Program) { switch (type.kind) { case "Model": return getModelName(type); + case "Enum": + return getEnumName(type); case "Union": return type.options.map(getTypeName).join(" | "); case "Array": @@ -178,8 +224,12 @@ export function createChecker(program: Program) { function getNamespaceString(type: NamespaceType | undefined): string { if (!type) return ""; const parent = type.namespace; + return parent && parent.name !== "" ? `${getNamespaceString(parent)}.${type.name}` : type.name; + } - return parent ? `${getNamespaceString(parent)}.${type.name}` : type.name; + function getEnumName(e: EnumType): string { + const nsName = getNamespaceString(e.namespace); + return nsName ? `${nsName}.${e.name}` : e.name; } function getModelName(model: ModelType) { @@ -215,18 +265,29 @@ export function createChecker(program: Program) { function checkTypeReference(node: TypeReferenceNode): Type { const sym = resolveTypeReference(node); + if (!sym) { + return errorType; + } + if (sym.kind === "decorator") { - throwDiagnostic("Can't put a decorator in a type", node); + program.reportDiagnostic("Can't put a decorator in a type", node); + return errorType; } const symbolLinks = getSymbolLinks(sym); - const args = node.arguments.map(getTypeForNode); + let args = node.arguments.map(getTypeForNode); - if (sym.node.kind === SyntaxKind.ModelStatement && !sym.node.assignment) { + if ( + sym.node.kind === SyntaxKind.ModelStatement || + sym.node.kind === SyntaxKind.AliasStatement + ) { // model statement, possibly templated if (sym.node.templateParameters.length === 0) { if (args.length > 0) { - throwDiagnostic("Can't pass template arguments to model that is not templated", node); + program.reportDiagnostic( + "Can't pass template arguments to model that is not templated", + node + ); } if (symbolLinks.declaredType) { @@ -235,29 +296,34 @@ export function createChecker(program: Program) { return pendingModelType.type; } - return checkModelStatement(sym.node); + return sym.node.kind === SyntaxKind.ModelStatement + ? checkModelStatement(sym.node) + : checkAlias(sym.node); } else { - // model is templated, lets instantiate. + // declaration is templated, lets instantiate. if (!symbolLinks.declaredType) { // we haven't checked the declared type yet, so do so. - checkModelStatement(sym.node); - } - if (sym.node.templateParameters!.length > node.arguments.length) { - throwDiagnostic("Too few template arguments provided.", node); + sym.node.kind === SyntaxKind.ModelStatement + ? checkModelStatement(sym.node) + : checkAlias(sym.node); } - if (sym.node.templateParameters!.length < node.arguments.length) { - throwDiagnostic("Too many template arguments provided.", node); + const templateParameters = sym.node.templateParameters; + if (args.length < templateParameters.length) { + program.reportDiagnostic("Too few template arguments provided.", node); + args = [...args, ...new Array(templateParameters.length - args.length).fill(errorType)]; + } else if (args.length > templateParameters.length) { + program.reportDiagnostic("Too many template arguments provided.", node); + args = args.slice(0, templateParameters.length); } - return instantiateTemplate(sym.node, args); } } // some other kind of reference if (args.length > 0) { - throwDiagnostic("Can't pass template arguments to non-templated type", node); + program.reportDiagnostic("Can't pass template arguments to non-templated type", node); } if (sym.node.kind === SyntaxKind.TemplateParameterDeclaration) { @@ -285,7 +351,10 @@ export function createChecker(program: Program) { * twice at the same time, or if template parameters from more than one template * are ever in scope at once. */ - function instantiateTemplate(templateNode: ModelStatementNode, args: Type[]): ModelType { + function instantiateTemplate( + templateNode: ModelStatementNode | AliasStatementNode, + args: Type[] + ): Type { const symbolLinks = getSymbolLinks(templateNode.symbol!); const cached = symbolLinks.instantiations!.get(args) as ModelType; if (cached) { @@ -296,22 +365,31 @@ export function createChecker(program: Program) { const oldTemplate = instantiatingTemplate; templateInstantiation = args; instantiatingTemplate = templateNode; - // this cast is invalid once we support templatized `model =`. - const type = getTypeForNode(templateNode) as ModelType; + + const type = getTypeForNode(templateNode); symbolLinks.instantiations!.set(args, type); - - type.templateNode = templateNode; + if (type.kind === "Model") { + type.templateNode = templateNode; + } templateInstantiation = oldTis; instantiatingTemplate = oldTemplate; return type; } function checkUnionExpression(node: UnionExpressionNode): UnionType { + const options = node.options.flatMap((o) => { + const type = getTypeForNode(o); + if (type.kind === "Union") { + return type.options; + } + return type; + }); + return createType({ kind: "Union", node, - options: node.options.map(getTypeForNode), + options, }); } @@ -327,7 +405,8 @@ export function createChecker(program: Program) { function checkIntersectionExpression(node: IntersectionExpressionNode) { const optionTypes = node.options.map(getTypeForNode); if (!allModelTypes(optionTypes)) { - throwDiagnostic("Cannot intersect non-model types (including union types).", node); + program.reportDiagnostic("Cannot intersect non-model types (including union types).", node); + return errorType; } const properties = new Map(); @@ -335,10 +414,11 @@ export function createChecker(program: Program) { const allProps = walkPropertiesInherited(option); for (const prop of allProps) { if (properties.has(prop.name)) { - throwDiagnostic( + program.reportDiagnostic( `Intersection contains duplicate property definitions for ${prop.name}`, node ); + continue; } const newPropType = createType({ @@ -377,19 +457,7 @@ export function createChecker(program: Program) { } if (Array.isArray(node.statements)) { - for (const statement of node.statements.map(getTypeForNode)) { - switch (statement.kind) { - case "Model": - type.models.set(statement.name, statement); - break; - case "Operation": - type.operations.set(statement.name, statement); - break; - case "Namespace": - type.namespaces.set(statement.name, statement); - break; - } - } + node.statements.forEach(getTypeForNode); } else if (node.statements) { const subNs = checkNamespace(node.statements); type.namespaces.set(subNs.name, subNs); @@ -403,16 +471,18 @@ export function createChecker(program: Program) { const symbolLinks = getSymbolLinks(node.symbol); if (!symbolLinks.type) { // haven't seen this namespace before + const namespace = getParentNamespaceType(node); + const name = node.name.sv; const type: NamespaceType = createType({ kind: "Namespace", - name: node.name.sv, - namespace: getParentNamespaceType(node), - node: node, + name, + namespace, + node, models: new Map(), operations: new Map(), namespaces: new Map(), }); - + namespace?.namespaces.set(name, type); symbolLinks.type = type; } else { // seen it before, need to execute the decorators on this node @@ -426,9 +496,10 @@ export function createChecker(program: Program) { } function getParentNamespaceType( - node: ModelStatementNode | NamespaceStatementNode | OperationStatementNode + node: ModelStatementNode | NamespaceStatementNode | OperationStatementNode | EnumStatementNode ): NamespaceType | undefined { - if (!node.namespaceSymbol) return undefined; + if (node === root.node) return undefined; + if (!node.namespaceSymbol) return root; const symbolLinks = getSymbolLinks(node.namespaceSymbol); compilerAssert(symbolLinks.type, "Parent namespace isn't typed yet.", node); @@ -436,14 +507,22 @@ export function createChecker(program: Program) { } function checkOperation(node: OperationStatementNode): OperationType { - return createType({ + const namespace = getParentNamespaceType(node); + const name = node.id.sv; + const type = createType({ kind: "Operation", - name: node.id.sv, - namespace: getParentNamespaceType(node), - node: node, + name, + namespace, + node, parameters: getTypeForNode(node.parameters) as ModelType, returnType: getTypeForNode(node.returnType), }); + namespace?.operations.set(name, type); + return type; + } + + function getGlobalNamespaceType() { + return root; } function checkTupleExpression(node: TupleExpressionNode): TupleType { @@ -480,6 +559,12 @@ export function createChecker(program: Program) { } function resolveIdentifier(node: IdentifierNode) { + if (hasParseError(node)) { + // Don't report synthetic identifiers used for parser error recovery. + // The parse error is the root cause and will already have been logged. + return undefined; + } + let scope: Node | undefined = node.parent; let binding; @@ -509,29 +594,38 @@ export function createChecker(program: Program) { if (binding) return binding; } - throwDiagnostic("Unknown identifier " + node.sv, node); + program.reportDiagnostic("Unknown identifier " + node.sv, node); + return undefined; } - function resolveTypeReference(node: ReferenceExpression): DecoratorSymbol | TypeSymbol { + function resolveTypeReference( + node: ReferenceExpression + ): DecoratorSymbol | TypeSymbol | undefined { if (node.kind === SyntaxKind.TypeReference) { return resolveTypeReference(node.target); } if (node.kind === SyntaxKind.MemberExpression) { const base = resolveTypeReference(node.base); + if (!base) { + return undefined; + } if (base.kind === "type" && base.node.kind === SyntaxKind.NamespaceStatement) { const symbol = resolveIdentifierInTable(node.id, base.node.exports!); if (!symbol) { - throwDiagnostic(`Namespace doesn't have member ${node.id.sv}`, node); + program.reportDiagnostic(`Namespace doesn't have member ${node.id.sv}`, node); + return undefined; } return symbol; } else if (base.kind === "decorator") { - throwDiagnostic(`Cannot resolve '${node.id.sv}' in decorator`, node); + program.reportDiagnostic(`Cannot resolve '${node.id.sv}' in decorator`, node); + return undefined; } else { - throwDiagnostic( + program.reportDiagnostic( `Cannot resolve '${node.id.sv}' in non-namespace node ${base.node.kind}`, node ); + return undefined; } } @@ -555,12 +649,12 @@ export function createChecker(program: Program) { } function checkProgram(program: Program) { - reportDuplicateSymbols(program.globalNamespace.exports!); + program.reportDuplicateSymbols(program.globalNamespace.exports!); for (const file of program.sourceFiles) { - reportDuplicateSymbols(file.locals!); + program.reportDuplicateSymbols(file.locals!); for (const ns of file.namespaces) { - reportDuplicateSymbols(ns.locals!); - reportDuplicateSymbols(ns.exports!); + program.reportDuplicateSymbols(ns.locals!); + program.reportDuplicateSymbols(ns.exports!); initializeTypeForNamespace(ns); } @@ -578,11 +672,7 @@ export function createChecker(program: Program) { function checkModel(node: ModelExpressionNode | ModelStatementNode) { if (node.kind === SyntaxKind.ModelStatement) { - if (node.properties) { - return checkModelStatement(node); - } else { - return checkModelEquals(node); - } + return checkModelStatement(node); } else { return checkModelExpression(node); } @@ -628,6 +718,7 @@ export function createChecker(program: Program) { if (!instantiatingThisTemplate) { links.declaredType = type; links.instantiations = new TypeInstantiationMap(); + type.namespace?.models.set(type.name, type); } // The model is fully created now @@ -661,7 +752,8 @@ export function createChecker(program: Program) { for (const newProp of newProperties) { if (properties.has(newProp.name)) { - throwDiagnostic(`Model already has a property named ${newProp.name}`, node); + program.reportDiagnostic(`Model already has a property named ${newProp.name}`, node); + continue; } properties.set(newProp.name, newProp); @@ -672,37 +764,21 @@ export function createChecker(program: Program) { return properties; } - function checkModelEquals(node: ModelStatementNode) { - // model = - // this will likely have to change, as right now `model =` is really just - // alias and so disappears. That means you can't easily rename symbols. - const assignmentType = getTypeForNode(node.assignment!); - - if (assignmentType.kind === "Model") { - const type: ModelType = createType({ - ...assignmentType, - node: node, - name: node.id.sv, - assignmentType, - namespace: getParentNamespaceType(node), - }); - - return type; - } - - return assignmentType; - } - function checkClassHeritage(heritage: ReferenceExpression[]): ModelType[] { - return heritage.map((heritageRef) => { + let baseModels = []; + for (let heritageRef of heritage) { const heritageType = getTypeForNode(heritageRef); - - if (heritageType.kind !== "Model") { - throwDiagnostic("Models must extend other models.", heritageRef); + if (isErrorType(heritageType)) { + compilerAssert(program.hasError(), "Should already have reported an error.", heritageRef); + continue; } - - return heritageType; - }); + if (heritageType.kind !== "Model") { + program.reportDiagnostic("Models must extend other models.", heritageRef); + continue; + } + baseModels.push(heritageType); + } + return baseModels; } function checkSpreadProperty(targetNode: ReferenceExpression): ModelTypeProperty[] { @@ -711,7 +787,8 @@ export function createChecker(program: Program) { if (targetType.kind != "TemplateParameter") { if (targetType.kind !== "Model") { - throwDiagnostic("Cannot spread properties of non-model type.", targetNode); + program.reportDiagnostic("Cannot spread properties of non-model type.", targetNode); + return props; } // copy each property @@ -761,6 +838,57 @@ export function createChecker(program: Program) { } } + function checkAlias(node: AliasStatementNode): Type { + const links = getSymbolLinks(node.symbol!); + const instantiatingThisTemplate = instantiatingTemplate === node; + + if (links.declaredType && !instantiatingThisTemplate) { + return links.declaredType; + } + + const type = getTypeForNode(node.value); + if (!instantiatingThisTemplate) { + links.declaredType = type; + links.instantiations = new TypeInstantiationMap(); + } + + return type; + } + + function checkEnum(node: EnumStatementNode): Type { + const links = getSymbolLinks(node.symbol!); + + if (!links.type) { + const enumType: EnumType = { + kind: "Enum", + name: node.id.sv, + node, + members: [], + namespace: getParentNamespaceType(node), + }; + + node.members.map((m) => enumType.members.push(checkEnumMember(enumType, m))); + + createType(enumType); + + links.type = enumType; + } + + return links.type; + } + + function checkEnumMember(parentEnum: EnumType, node: EnumMemberNode): EnumMemberType { + const name = node.id.kind === SyntaxKind.Identifier ? node.id.sv : node.id.value; + const value = node.value ? node.value.value : undefined; + return createType({ + kind: "EnumMember", + enum: parentEnum, + name, + node, + value, + }); + } + // the types here aren't ideal and could probably be refactored. function createType(typeDef: T): T { (typeDef as any).templateArguments = templateInstantiation; @@ -772,6 +900,7 @@ export function createChecker(program: Program) { function getLiteralType(node: StringLiteralNode): StringLiteralType; function getLiteralType(node: NumericLiteralNode): NumericLiteralType; function getLiteralType(node: BooleanLiteralNode): BooleanLiteralType; + function getLiteralType(node: LiteralNode): LiteralType; function getLiteralType(node: LiteralNode): LiteralType { let type = program.literalTypes.get(node.value); if (type) { @@ -794,3 +923,7 @@ export function createChecker(program: Program) { return type; } } + +function isErrorType(type: Type): type is ErrorType { + return type.kind === "Intrinsic" && type.name === "ErrorType"; +} diff --git a/packages/adl/compiler/cli.ts b/packages/adl/compiler/cli.ts index 3d5816f89..bf1d314cf 100644 --- a/packages/adl/compiler/cli.ts +++ b/packages/adl/compiler/cli.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "child_process"; +import { spawnSync, SpawnSyncOptions } from "child_process"; import { mkdtemp, readdir, rmdir } from "fs/promises"; import mkdirp from "mkdirp"; import os from "os"; @@ -7,17 +7,19 @@ import url from "url"; import yargs from "yargs"; import { CompilerOptions } from "../compiler/options.js"; import { compile } from "../compiler/program.js"; -import { compilerAssert, DiagnosticError, dumpError, logDiagnostics } from "./diagnostics.js"; +import { loadADLConfigInDir } from "../config/index.js"; +import { compilerAssert, dumpError, logDiagnostics } from "./diagnostics.js"; +import { formatADLFiles } from "./formatter.js"; import { adlVersion, NodeHost } from "./util.js"; const args = yargs(process.argv.slice(2)) .scriptName("adl") .help() .strict() - .command("compile ", "Compile a directory of ADL files.", (cmd) => { + .command("compile ", "Compile ADL source.", (cmd) => { return cmd .positional("path", { - description: "The path to folder containing .adl files", + description: "The path to the main.adl file or directory containing main.adl.", type: "string", }) .option("output-path", { @@ -38,11 +40,9 @@ const args = yargs(process.argv.slice(2)) describe: "Don't load the ADL standard library.", }); }) - .command( - "generate ", - "Generate client and server code from a directory of ADL files.", - (cmd) => { - return cmd + .command("generate ", "Generate client code from ADL source.", (cmd) => { + return ( + cmd .positional("path", { description: "The path to folder containing .adl files", type: "string", @@ -67,9 +67,13 @@ const args = yargs(process.argv.slice(2)) string: true, describe: "Key/value pairs that can be passed to ADL components. The format is 'key=value'. This parameter can be used multiple times to add more options.", - }); - } - ) + }) + // we can't generate anything but a client yet + .demandOption("client") + // and language is required to do so + .demandOption("language") + ); + }) .command("code", "Manage VS Code Extension.", (cmd) => { return cmd .demandCommand(1, "No command specified.") @@ -83,6 +87,14 @@ const args = yargs(process.argv.slice(2)) .command("install", "Install Visual Studio Extension.") .command("uninstall", "Uninstall VS Extension"); }) + .command("format ", "Format given list of adl files.", (cmd) => { + return cmd.positional("include", { + description: "Wildcard pattern of the list of files.", + type: "string", + array: true, + }); + }) + .command("info", "Show information about current ADL compiler.") .option("debug", { type: "boolean", description: "Output debug log messages.", @@ -90,18 +102,16 @@ const args = yargs(process.argv.slice(2)) .version(adlVersion) .demandCommand(1, "You must use one of the supported commands.").argv; -async function compileInput(compilerOptions: CompilerOptions) { - try { - await compile(args.path!, NodeHost, compilerOptions); - } catch (err) { - if (err instanceof DiagnosticError) { - logDiagnostics(err.diagnostics, console.error); - if (args.debug) { - console.error(`Stack trace:\n\n${err.stack}`); - } - process.exit(1); - } - throw err; // let non-diagnostic errors go to top-level bug handler. +async function compileInput(compilerOptions: CompilerOptions, printSuccess = true) { + const program = await compile(args.path!, NodeHost, compilerOptions); + logDiagnostics(program.diagnostics, console.error); + if (program.hasError()) { + process.exit(1); + } + if (printSuccess) { + console.log( + `Compilation completed successfully, output files are in ${compilerOptions.outputPath}.` + ); } } @@ -123,6 +133,7 @@ async function getCompilerOptions(): Promise { return { miscOptions, + outputPath, swaggerOutputFile: resolve(args["output-path"], "openapi.json"), nostdlib: args["nostdlib"], }; @@ -134,7 +145,8 @@ async function generateClient(options: CompilerOptions) { const autoRestPath = new url.URL(`../../node_modules/.bin/${autoRestBin}`, import.meta.url); // Execute AutoRest on the output file - const result = spawnSync( + console.log(); //newline between compilation output and generation output + const result = run( url.fileURLToPath(autoRestPath), [ `--${args.language}`, @@ -144,15 +156,14 @@ async function generateClient(options: CompilerOptions) { `--input-file=${options.swaggerOutputFile}`, ], { - stdio: "inherit", shell: true, } ); if (result.status === 0) { - console.log(`Generation completed successfully, output files are in ${options.outputPath}.`); + console.log(`\nGeneration completed successfully, output files are in ${clientPath}.`); } else { - console.error("\nAn error occurred during client generation."); + console.error("\nClient generation failed."); process.exit(result.status || 1); } } @@ -160,7 +171,20 @@ async function generateClient(options: CompilerOptions) { async function installVsix(pkg: string, install: (vsixPath: string) => void) { // download npm package to temporary directory const temp = await mkdtemp(path.join(os.tmpdir(), "adl")); - run("npm", ["install", "--silent", "--prefix", temp, pkg]); + const npmArgs = ["install"]; + + // hide npm output unless --debug was passed to adl + if (!args.debug) { + npmArgs.push("--silent"); + } + + // NOTE: Using cwd=temp with `--prefix .` instead of `--prefix ${temp}` to + // workaround https://github.com/npm/cli/issues/3256. It's still important + // to pass --prefix even though we're using cwd as otherwise, npm might + // find a package.json file in a parent directory and install to that + // directory. + npmArgs.push("--prefix", ".", pkg); + run("npm", npmArgs, { cwd: temp }); // locate .vsix const dir = path.join(temp, "node_modules", pkg); @@ -182,14 +206,21 @@ async function installVsix(pkg: string, install: (vsixPath: string) => void) { await rmdir(temp, { recursive: true }); } +async function runCode(codeArgs: string[]) { + await run(args.insiders ? "code-insiders" : "code", codeArgs, { + // VS Code's CLI emits node warnings that we can't do anything about. Suppress them. + extraEnv: { NODE_NO_WARNINGS: "1" }, + }); +} + async function installVSCodeExtension() { await installVsix("adl-vscode", (vsix) => { - run(args.insiders ? "code-insiders" : "code", ["--install-extension", vsix]); + runCode(["--install-extension", vsix]); }); } async function uninstallVSCodeExtension() { - run(args.insiders ? "code-insiders" : "code", ["--uninstall-extension", "microsoft.adl-vscode"]); + await runCode(["--uninstall-extension", "microsoft.adl-vscode"]); } function getVsixInstallerPath(): string { @@ -217,13 +248,49 @@ async function uninstallVSExtension() { run(vsixInstaller, ["/uninstall:88b9492f-c019-492c-8aeb-f325a7e4cf23"]); } +/** + * Print the resolved adl configuration. + */ +async function printInfo() { + const cwd = process.cwd(); + console.log(`Module: ${url.fileURLToPath(import.meta.url)}`); + + const config = await loadADLConfigInDir(cwd); + const jsyaml = await import("js-yaml"); + const excluded = ["diagnostics", "filename"]; + const replacer = (key: string, value: any) => (excluded.includes(key) ? undefined : value); + + console.log(`User Config: ${config.filename ?? "No config file found"}`); + console.log("-----------"); + console.log(jsyaml.dump(config, { replacer })); + console.log("-----------"); + logDiagnostics(config.diagnostics, console.error); + if (config.diagnostics.some((d) => d.severity === "error")) { + process.exit(1); + } +} + // NOTE: We could also use { shell: true } to let windows find the .cmd, but that breaks // ENOENT checking and handles spaces poorly in some cases. const isCmdOnWindows = ["code", "code-insiders", "npm"]; -function run(command: string, commandArgs: string[]) { +interface RunOptions extends SpawnSyncOptions { + extraEnv?: NodeJS.ProcessEnv; +} + +function run(command: string, commandArgs: string[], options?: RunOptions) { if (args.debug) { - console.log(`> ${command} ${commandArgs.join(" ")}`); + if (options) { + console.log(options); + } + console.log(`> ${command} ${commandArgs.join(" ")}\n`); + } + + if (options?.extraEnv) { + options.env = { + ...(options?.env ?? process.env), + ...options.extraEnv, + }; } const baseCommandName = path.basename(command); @@ -233,8 +300,7 @@ function run(command: string, commandArgs: string[]) { const proc = spawnSync(command, commandArgs, { stdio: "inherit", - // VS Code's CLI emits node warnings that we can't do anything about. Suppress them. - env: { ...process.env, NODE_NO_WARNINGS: "1" }, + ...(options ?? {}), }); if (proc.error) { @@ -255,8 +321,10 @@ function run(command: string, commandArgs: string[]) { proc.status }.` ); - process.exit(proc.status ?? 1); + process.exit(proc.status || 1); } + + return proc; } async function main() { @@ -266,13 +334,16 @@ async function main() { let action: string | number; switch (command) { + case "info": + printInfo(); + break; case "compile": options = await getCompilerOptions(); await compileInput(options); break; case "generate": options = await getCompilerOptions(); - await compileInput(options); + await compileInput(options, false); if (args.client) { await generateClient(options); } @@ -298,18 +369,27 @@ async function main() { await uninstallVSExtension(); break; } + break; + case "format": + await formatADLFiles(args["include"]!, { debug: args.debug }); + break; } } -main() - .then(() => {}) - .catch((err) => { - // NOTE: An expected error, like one thrown for bad input, shouldn't reach - // here, but be handled somewhere else. If we reach here, it should be - // considered a bug and therefore we should not suppress the stack trace as - // that risks losing it in the case of a bug that does not repro easily. - console.error("Internal compiler error!"); - console.error("File issue at https://github.com/azure/adl"); - dumpError(err, console.error); - process.exit(1); - }); +function internalCompilerError(error: Error) { + // NOTE: An expected error, like one thrown for bad input, shouldn't reach + // here, but be handled somewhere else. If we reach here, it should be + // considered a bug and therefore we should not suppress the stack trace as + // that risks losing it in the case of a bug that does not repro easily. + console.error("Internal compiler error!"); + console.error("File issue at https://github.com/azure/adl"); + dumpError(error, console.error); + process.exit(1); +} + +process.on("unhandledRejection", (error: Error) => { + console.error("Unhandled promise rejection!"); + internalCompilerError(error); +}); + +main().catch(internalCompilerError); diff --git a/packages/adl/compiler/diagnostics.ts b/packages/adl/compiler/diagnostics.ts index 7eee855cd..a26da296c 100644 --- a/packages/adl/compiler/diagnostics.ts +++ b/packages/adl/compiler/diagnostics.ts @@ -1,23 +1,10 @@ import { AssertionError } from "assert"; -import { CharacterCodes } from "./character-codes.js"; +import { CharCode, isNonAsciiLineBreak } from "./charcode.js"; import { Message } from "./messages.js"; import { Diagnostic, Node, SourceFile, SourceLocation, Sym, SyntaxKind, Type } from "./types.js"; export { Message } from "./messages.js"; -/** - * Represents an error in the code input that is fatal and bails the compilation. - * - * This isn't meant to be kept long term, but we currently do this on all errors. - */ -export class DiagnosticError extends Error { - constructor(public readonly diagnostics: readonly Diagnostic[]) { - super("Code diagnostics. See diagnostics array."); - // Tests don't have our catch-all handler so log the diagnostic now. - logVerboseTestOutput((log) => logDiagnostics(diagnostics, log)); - } -} - /** * Represents a failure with multiple errors. */ @@ -32,38 +19,25 @@ export class AggregateError extends Error { } } +export const NoTarget = Symbol("NoTarget"); export type DiagnosticTarget = Node | Type | Sym | SourceLocation; export type WriteLine = (text?: string) => void; - -export type ErrorHandler = ( - message: Message | string, - target: DiagnosticTarget, - args?: (string | number)[] -) => void; - -export const throwOnError: ErrorHandler = throwDiagnostic; - -export function throwDiagnostic( - message: Message | string, - target: DiagnosticTarget, - args?: (string | number)[] -): never { - throw new DiagnosticError([createDiagnostic(message, target, args)]); -} +export type DiagnosticHandler = (diagnostic: Diagnostic) => void; export function createDiagnostic( message: Message | string, - target: DiagnosticTarget, + target: DiagnosticTarget | typeof NoTarget, args?: (string | number)[] ): Diagnostic { - let location: SourceLocation; + let location: Partial = {}; let locationError: Error | undefined; - - try { - location = getSourceLocation(target); - } catch (err) { - locationError = err; - location = createDummySourceLocation(); + if (target !== NoTarget) { + try { + location = getSourceLocation(target); + } catch (err) { + locationError = err; + location = createDummySourceLocation(); + } } if (typeof message === "string") { @@ -80,7 +54,10 @@ export function createDiagnostic( }; if (locationError || formatError) { - throw new AggregateError(new DiagnosticError([diagnostic]), locationError, formatError); + const diagnosticError = new Error( + "Error(s) occurred trying to report diagnostic: " + diagnostic.message + ); + throw new AggregateError(diagnosticError, locationError, formatError); } return diagnostic; @@ -94,12 +71,17 @@ export function logDiagnostics(diagnostics: readonly Diagnostic[], writeLine: Wr export function formatDiagnostic(diagnostic: Diagnostic) { const code = diagnostic.code ? ` ADL${diagnostic.code}` : ""; - const pos = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.pos); - const line = pos.line + 1; - const col = pos.character + 1; const severity = diagnostic.severity; - const path = diagnostic.file.path; - return `${path}:${line}:${col} - ${severity}${code}: ${diagnostic.message}`; + const content = `${severity}${code}: ${diagnostic.message}`; + if (diagnostic.file) { + const pos = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.pos ?? 0); + const line = pos.line + 1; + const col = pos.character + 1; + const path = diagnostic.file.path; + return `${path}:${line}:${col} - ${content}`; + } else { + return content; + } } export function createSourceFile(text: string, path: string): SourceFile { @@ -113,7 +95,7 @@ export function createSourceFile(text: string, path: string): SourceFile { }; function getLineStarts() { - return (lineStarts = lineStarts ?? scanLineStarts()); + return (lineStarts = lineStarts ?? scanLineStarts(text)); } function getLineAndCharacterOfPosition(position: number) { @@ -136,57 +118,6 @@ export function createSourceFile(text: string, path: string): SourceFile { character: position - starts[line], }; } - - function scanLineStarts() { - const starts = []; - let start = 0; - let pos = 0; - - while (pos < text.length) { - const ch = text.charCodeAt(pos); - pos++; - switch (ch) { - case CharacterCodes.carriageReturn: - if (text.charCodeAt(pos) === CharacterCodes.lineFeed) { - pos++; - } - // fallthrough - case CharacterCodes.lineFeed: - case CharacterCodes.lineSeparator: - case CharacterCodes.paragraphSeparator: - starts.push(start); - start = pos; - break; - } - } - - starts.push(start); - return starts; - } - - /** - * Search sorted array of numbers for the given value. If found, return index - * in array where value was found. If not found, return a negative number that - * is the bitwise complement of the index where value would need to be inserted - * to keep the array sorted. - */ - function binarySearch(array: readonly number[], value: number) { - let low = 0; - let high = array.length - 1; - while (low <= high) { - const middle = low + ((high - low) >> 1); - const v = array[middle]; - if (v < value) { - low = middle + 1; - } else if (v > value) { - high = middle - 1; - } else { - return middle; - } - } - - return ~low; - } } export function getSourceLocation(target: DiagnosticTarget): SourceLocation { @@ -250,10 +181,7 @@ export function logVerboseTestOutput(messageOrCallback: string | ((log: WriteLin } export function dumpError(error: Error, writeLine: WriteLine) { - if (error instanceof DiagnosticError) { - logDiagnostics(error.diagnostics, writeLine); - writeLine(error.stack); - } else if (error instanceof AggregateError) { + if (error instanceof AggregateError) { for (const inner of error.errors) { dumpError(inner, writeLine); } @@ -328,3 +256,58 @@ function format(text: string, args?: (string | number)[]): [string, Error?] { function isNotUndefined(value: T | undefined): value is T { return value !== undefined; } + +function scanLineStarts(text: string): number[] { + const starts = []; + let start = 0; + let pos = 0; + + while (pos < text.length) { + const ch = text.charCodeAt(pos); + pos++; + switch (ch) { + case CharCode.CarriageReturn: + if (text.charCodeAt(pos) === CharCode.LineFeed) { + pos++; + } + // fallthrough + case CharCode.LineFeed: + starts.push(start); + start = pos; + break; + default: + if (ch > CharCode.MaxAscii && isNonAsciiLineBreak(ch)) { + starts.push(start); + start = pos; + break; + } + } + } + + starts.push(start); + return starts; +} + +/** + * Search sorted array of numbers for the given value. If found, return index + * in array where value was found. If not found, return a negative number that + * is the bitwise complement of the index where value would need to be inserted + * to keep the array sorted. + */ +function binarySearch(array: readonly number[], value: number) { + let low = 0; + let high = array.length - 1; + while (low <= high) { + const middle = low + ((high - low) >> 1); + const v = array[middle]; + if (v < value) { + low = middle + 1; + } else if (v > value) { + high = middle - 1; + } else { + return middle; + } + } + + return ~low; +} diff --git a/packages/adl/compiler/formatter.ts b/packages/adl/compiler/formatter.ts new file mode 100644 index 000000000..edd6f0082 --- /dev/null +++ b/packages/adl/compiler/formatter.ts @@ -0,0 +1,55 @@ +import { readFile, writeFile } from "fs/promises"; +import glob from "glob"; +import prettier from "prettier"; +import * as adlPrettierPlugin from "../formatter/index.js"; +import { PrettierParserError } from "../formatter/parser.js"; + +export async function formatADL(code: string): Promise { + const output = prettier.format(code, { + parser: "adl", + plugins: [adlPrettierPlugin], + }); + return output; +} + +/** + * Format all the adl files. + * @param patterns List of wildcard pattern searching for adl files. + */ +export async function formatADLFiles(patterns: string[], { debug }: { debug?: boolean }) { + const files = await findFiles(patterns); + for (const file of files) { + try { + await formatADLFile(file); + } catch (e) { + if (e instanceof PrettierParserError) { + const details = debug ? e.message : ""; + console.error(`File '${file}' failed to fromat. ${details}`); + } else { + throw e; + } + } + } +} + +export async function formatADLFile(filename: string) { + const content = await readFile(filename); + const formattedContent = await formatADL(content.toString()); + await writeFile(filename, formattedContent); +} + +async function findFilesFromPattern(pattern: string): Promise { + return new Promise((resolve, reject) => { + glob(pattern, (err, matches) => { + if (err) { + reject(err); + } + resolve(matches); + }); + }); +} + +async function findFiles(include: string[]): Promise { + const result = await Promise.all(include.map((path) => findFilesFromPattern(path))); + return result.flat(); +} diff --git a/packages/adl/compiler/index.ts b/packages/adl/compiler/index.ts index b86579f84..7454d5880 100644 --- a/packages/adl/compiler/index.ts +++ b/packages/adl/compiler/index.ts @@ -1,4 +1,8 @@ export * from "../lib/decorators.js"; export * from "./diagnostics.js"; +export * from "./mutators.js"; +export * from "./parser.js"; export * from "./program.js"; export * from "./types.js"; +import * as formatter from "../formatter/index.js"; +export const ADLPrettierPlugin = formatter; diff --git a/packages/adl/compiler/messages.ts b/packages/adl/compiler/messages.ts index 0ff64ea6c..4bee35a4a 100644 --- a/packages/adl/compiler/messages.ts +++ b/packages/adl/compiler/messages.ts @@ -8,50 +8,50 @@ export const Message = { DigitExpected: { code: 1100, severity: "error", - text: "Digit expected (0-9)", + text: "Digit expected.", } as const, HexDigitExpected: { code: 1101, severity: "error", - text: "Hex Digit expected (0-F)", + text: "Hexadecimal digit expected.", } as const, BinaryDigitExpected: { code: 1102, severity: "error", - text: "Binary Digit expected (0,1)", + text: "Binary digit expected.", } as const, - UnexpectedEndOfFile: { + Unterminated: { code: 1103, severity: "error", - text: "Unexpected end of file while searching for '{0}'", + text: "Unterminated {0}.", } as const, InvalidEscapeSequence: { code: 1104, severity: "error", - text: "Invalid escape sequence", + text: "Invalid escape sequence.", } as const, NoNewLineAtStartOfTripleQuotedString: { code: 1105, severity: "error", - text: "String content in triple quotes must begin on a new line", + text: "String content in triple quotes must begin on a new line.", } as const, NoNewLineAtEndOfTripleQuotedString: { code: 1106, severity: "error", - text: "Closing triple quotes must begin on a new line", + text: "Closing triple quotes must begin on a new line.", } as const, InconsistentTripleQuoteIndentation: { code: 1107, severity: "error", text: - "All lines in triple-quoted string lines must have the same indentation as closing triple quotes", + "All lines in triple-quoted string lines must have the same indentation as closing triple quotes.", } as const, InvalidCharacter: { @@ -59,6 +59,12 @@ export const Message = { severity: "error", text: "Invalid character.", } as const, + + FileNotFound: { + code: 1109, + text: `File {0} not found.`, + severity: "error", + } as const, }; // Static assert: this won't compile if one of the entries above is invalid. diff --git a/packages/adl/compiler/mutators.ts b/packages/adl/compiler/mutators.ts new file mode 100644 index 000000000..11073742c --- /dev/null +++ b/packages/adl/compiler/mutators.ts @@ -0,0 +1,186 @@ +import { createBinder } from "./binder.js"; +import { parse } from "./parser.js"; +import { Program } from "./program.js"; +import { + ModelExpressionNode, + ModelPropertyNode, + ModelStatementNode, + ModelType, + ModelTypeProperty, + Node, + OperationStatementNode, + OperationType, + SyntaxKind, + UnionExpressionNode, +} from "./types.js"; + +function addProperty( + program: Program, + model: ModelType, + modelNode: ModelStatementNode | ModelExpressionNode, + parentNode: Node, + propertyName: string, + propertyTypeName: string, + insertIndex?: number +): ModelTypeProperty | undefined { + // Parse a temporary model type to extract its property + const fakeNode = parse(`model Fake { ${propertyName}: ${propertyTypeName}}`); + if (fakeNode.parseDiagnostics.length > 0) { + program.reportDiagnostic( + `Could not add property/parameter "${propertyName}" of type "${propertyTypeName}"`, + model + ); + program.reportDiagnostics(fakeNode.parseDiagnostics); + + return undefined; + } + + const firstStatement = fakeNode.statements[0] as ModelStatementNode; + const graftProperty = firstStatement.properties![0] as ModelPropertyNode; + + // Fix up the source location of the nodes to match the model node that + // contains the new property since we can't update the entire file's node + // positions. + graftProperty.pos = modelNode.pos; + graftProperty.end = modelNode.end; + + // Create a binder to wire up the grafted property + const binder = createBinder(program, { + initialParentNode: parentNode, + }); + binder.bindNode(graftProperty); + + // Evaluate the new property with the checker + const newProperty = program.checker!.checkModelProperty(graftProperty); + + // Put the property back into the node + modelNode.properties.splice(insertIndex || modelNode.properties.length, 0, graftProperty); + if (insertIndex !== undefined) { + // Insert the property by adding it in the right order to a new Map + let i = 0; + const newProperties = new Map(); + for (let [name, prop] of model.properties.entries()) { + if (i === insertIndex) { + newProperties.set(newProperty.name, newProperty); + } + newProperties.set(name, prop); + model.properties = newProperties; + i++; + } + } else { + model.properties.set(newProperty.name, newProperty); + } + + return newProperty; +} + +export function addModelProperty( + program: Program, + model: ModelType, + propertyName: string, + propertyTypeName: string +): ModelTypeProperty | undefined { + if (model.node.kind !== SyntaxKind.ModelStatement) { + program.reportDiagnostic( + "Cannot add a model property to anything except a model statement.", + model + ); + + return; + } + + // Create the property and add it to the type + const newProperty = addProperty( + program, + model, + model.node, + model.node, + propertyName, + propertyTypeName + ); + + if (newProperty) { + model.properties.set(propertyName, newProperty); + return newProperty; + } + + return undefined; +} + +export interface NewParameterOptions { + // Insert the parameter at the specified index. If `undefined`, add the + // parameter to the end of the parameter list. + insertIndex?: number; +} + +export function addOperationParameter( + program: Program, + operation: OperationType, + parameterName: string, + parameterTypeName: string, + options?: NewParameterOptions +): ModelTypeProperty | undefined { + if (operation.node.kind !== SyntaxKind.OperationStatement) { + program.reportDiagnostic( + "Cannot add a parameter to anything except an operation statement.", + operation + ); + + return; + } + + // Create the property and add it to the type + return addProperty( + program, + operation.parameters, + operation.node.parameters, + operation.node, + parameterName, + parameterTypeName, + options?.insertIndex + ); +} + +export function addOperationResponseType( + program: Program, + operation: OperationType, + responseTypeName: string +): any { + if (operation.node.kind !== SyntaxKind.OperationStatement) { + program.reportDiagnostic( + "Cannot add a response to anything except an operation statement.", + operation + ); + + return; + } + + // Parse a temporary operation to extract its response type + const opNode = parse(`op Fake(): string | ${responseTypeName};`); + if (opNode.parseDiagnostics.length > 0) { + program.reportDiagnostic( + `Could not add response type "${responseTypeName}" to operation ${operation.name}"`, + operation + ); + program.reportDiagnostics(opNode.parseDiagnostics); + + return undefined; + } + + const graftUnion = (opNode.statements[0] as OperationStatementNode) + .returnType as UnionExpressionNode; + + // Graft the union into the operation + const originalResponse = operation.node.returnType; + graftUnion.options[0] = originalResponse; + operation.node.returnType = graftUnion; + + // Create a binder to wire up the grafted property + const binder = createBinder(program, { + initialParentNode: operation.node, + }); + binder.bindNode(graftUnion); + + // Evaluate the new response type with the checker + operation.returnType = program.checker!.checkUnionExpression(graftUnion); +} diff --git a/packages/adl/compiler/non-ascii-maps.ts b/packages/adl/compiler/nonascii.ts similarity index 98% rename from packages/adl/compiler/non-ascii-maps.ts rename to packages/adl/compiler/nonascii.ts index 8f8d19b33..a20ca06ab 100644 --- a/packages/adl/compiler/non-ascii-maps.ts +++ b/packages/adl/compiler/nonascii.ts @@ -4,15 +4,19 @@ // // Based on: // - http://www.unicode.org/reports/tr31/ -// - https://www.ecma-international.org/ecma-262/6.0/#sec-names-and-keywords +// - https://www.ecma-international.org/ecma-262/11.0/#sec-names-and-keywords // // ADL's identifier naming rules are currently the same as JavaScript's. +// /** * @internal * * Map of non-ascii characters that are valid at the start of an identifier. * Each pair of numbers represents an inclusive range of code points. + * + * Corresponds to code points outside the ASCII range with property ID_Start or + * Other_ID_Start. */ // prettier-ignore export const nonAsciiIdentifierStartMap: readonly number[] = [ @@ -641,8 +645,12 @@ export const nonAsciiIdentifierStartMap: readonly number[] = [ /** * @internal * - * Map of non-ascii chacters that are valid after the first character in and identifier. - * Each pair of numbers represents an inclusive range of code points. + * Map of non-ascii chacters that are valid after the first character in and + * identifier. Each pair of numbers represents an inclusive range of code + * points. + * + * Corresponds to code points outside the ASCII range with property ID_Continue, + * Other_ID_Start, or Other_ID_Continue, plus ZWNJ and ZWJ. */ //prettier-ignore export const nonAsciiIdentifierContinueMap: readonly number[] = [ @@ -943,6 +951,7 @@ export const nonAsciiIdentifierContinueMap: readonly number[] = [ 0x1fe0, 0x1fec, 0x1ff2, 0x1ff4, 0x1ff6, 0x1ffc, + 0x200c, 0x200d, 0x203f, 0x2040, 0x2054, 0x2054, 0x2071, 0x2071, diff --git a/packages/adl/compiler/options.ts b/packages/adl/compiler/options.ts index b6e964e66..d7cdd2cde 100644 --- a/packages/adl/compiler/options.ts +++ b/packages/adl/compiler/options.ts @@ -1,6 +1,5 @@ export interface CompilerOptions { miscOptions?: any; - mainFile?: string; outputPath?: string; swaggerOutputFile?: string; nostdlib?: boolean; diff --git a/packages/adl/compiler/parser.ts b/packages/adl/compiler/parser.ts index c55356223..8803ad0eb 100644 --- a/packages/adl/compiler/parser.ts +++ b/packages/adl/compiler/parser.ts @@ -1,7 +1,8 @@ import { createSymbolTable } from "./binder.js"; -import { compilerAssert, createDiagnostic, DiagnosticTarget, Message } from "./diagnostics.js"; +import { compilerAssert, createDiagnostic } from "./diagnostics.js"; import { createScanner, + isComment, isKeyword, isPunctuation, isStatementKeyword, @@ -11,10 +12,14 @@ import { } from "./scanner.js"; import { ADLScriptNode, + AliasStatementNode, BooleanLiteralNode, + Comment, DecoratorExpressionNode, Diagnostic, EmptyStatementNode, + EnumMemberNode, + EnumStatementNode, Expression, IdentifierNode, ImportStatementNode, @@ -127,6 +132,10 @@ namespace ListKind { toleratedDelimiter: Token.Comma, } as const; + export const EnumMembers = { + ...ModelProperties, + } as const; + const ExpresionsBase = { allowEmpty: true, delimiter: Token.Comma, @@ -162,13 +171,21 @@ namespace ListKind { } as const; } -export function parse(code: string | SourceFile) { +export interface ParseOptions { + /** + * Include comments in resulting output. + */ + comments?: boolean; +} + +export function parse(code: string | SourceFile, options: ParseOptions = {}): ADLScriptNode { let parseErrorInNextFinishedNode = false; let previousTokenEnd = -1; let realPositionOfLastError = -1; let missingIdentifierCounter = 0; const parseDiagnostics: Diagnostic[] = []; const scanner = createScanner(code, reportDiagnostic); + const comments: Comment[] = []; nextToken(); return parseADLScript(); @@ -186,7 +203,8 @@ export function parse(code: string | SourceFile) { locals: createSymbolTable(), inScopeNamespaces: [], parseDiagnostics, - ...finishNode({}, 0), + comments, + ...finishNode(0), }; } @@ -212,6 +230,13 @@ export function parse(code: string | SourceFile) { case Token.OpKeyword: item = parseOperationStatement(decorators); break; + case Token.EnumKeyword: + item = parseEnumStatement(decorators); + break; + case Token.AliasKeyword: + reportInvalidDecorators(decorators, "alias statement"); + item = parseAliasStatement(); + break; case Token.UsingKeyword: reportInvalidDecorators(decorators, "using statement"); item = parseUsingStatement(); @@ -275,12 +300,19 @@ export function parse(code: string | SourceFile) { case Token.OpKeyword: stmts.push(parseOperationStatement(decorators)); break; + case Token.EnumKeyword: + stmts.push(parseEnumStatement(decorators)); + break; + case Token.AliasKeyword: + reportInvalidDecorators(decorators, "alias statement"); + stmts.push(parseAliasStatement()); + break; case Token.UsingKeyword: reportInvalidDecorators(decorators, "using statement"); stmts.push(parseUsingStatement()); break; case Token.EndOfFile: - error("End of file reached without '}'."); + parseExpected(Token.CloseBrace); return stmts; case Token.Semicolon: reportInvalidDecorators(decorators, "empty statement"); @@ -316,8 +348,6 @@ export function parse(code: string | SourceFile) { } nsSegments.push(currentName); - let parameters: ModelExpressionNode | undefined; - const nextTok = parseExpectedOneOf(Token.Semicolon, Token.OpenBrace); let statements: Statement[] | undefined; @@ -326,28 +356,22 @@ export function parse(code: string | SourceFile) { parseExpected(Token.CloseBrace); } - let outerNs: NamespaceStatementNode = finishNode( - { - kind: SyntaxKind.NamespaceStatement, - decorators, - name: nsSegments[0], - parameters, - statements, - }, - nsSegments[0].pos - ); + let outerNs: NamespaceStatementNode = { + kind: SyntaxKind.NamespaceStatement, + decorators, + name: nsSegments[0], + statements, + ...finishNode(nsSegments[0].pos), + }; for (let i = 1; i < nsSegments.length; i++) { - outerNs = finishNode( - { - kind: SyntaxKind.NamespaceStatement, - decorators: [], - name: nsSegments[i], - parameters, - statements: outerNs, - }, - nsSegments[i].pos - ); + outerNs = { + kind: SyntaxKind.NamespaceStatement, + decorators: [], + name: nsSegments[i], + statements: outerNs, + ...finishNode(nsSegments[i].pos), + }; } return outerNs; @@ -359,13 +383,11 @@ export function parse(code: string | SourceFile) { const name = parseIdentifierOrMemberExpression(); parseExpected(Token.Semicolon); - return finishNode( - { - kind: SyntaxKind.UsingStatement, - name, - }, - pos - ); + return { + kind: SyntaxKind.UsingStatement, + name, + ...finishNode(pos), + }; } function parseOperationStatement(decorators: DecoratorExpressionNode[]): OperationStatementNode { @@ -379,28 +401,24 @@ export function parse(code: string | SourceFile) { const returnType = parseExpression(); parseExpected(Token.Semicolon); - return finishNode( - { - kind: SyntaxKind.OperationStatement, - id, - parameters, - returnType, - decorators, - }, - pos - ); + return { + kind: SyntaxKind.OperationStatement, + id, + parameters, + returnType, + decorators, + ...finishNode(pos), + }; } function parseOperationParameters(): ModelExpressionNode { const pos = tokenPos(); const properties = parseList(ListKind.OperationParameters, parseModelPropertyOrSpread); - const parameters: ModelExpressionNode = finishNode( - { - kind: SyntaxKind.ModelExpression, - properties, - }, - pos - ); + const parameters: ModelExpressionNode = { + kind: SyntaxKind.ModelExpression, + properties, + ...finishNode(pos), + }; return parameters; } @@ -416,37 +434,18 @@ export function parse(code: string | SourceFile) { expectTokenIsOneOf(Token.OpenBrace, Token.Equals, Token.ExtendsKeyword); - if (parseOptional(Token.Equals)) { - const assignment = parseExpression(); - parseExpected(Token.Semicolon); + const heritage: ReferenceExpression[] = parseOptionalModelHeritage(); + const properties = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); - return finishNode( - { - kind: SyntaxKind.ModelStatement, - id, - heritage: [], - templateParameters, - assignment, - decorators, - }, - pos - ); - } else { - const heritage: ReferenceExpression[] = parseOptionalModelHeritage(); - const properties = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); - - return finishNode( - { - kind: SyntaxKind.ModelStatement, - id, - heritage, - templateParameters, - decorators, - properties, - }, - pos - ); - } + return { + kind: SyntaxKind.ModelStatement, + id, + heritage, + templateParameters, + decorators, + properties, + ...finishNode(pos), + }; } function parseOptionalModelHeritage() { @@ -463,13 +462,11 @@ export function parse(code: string | SourceFile) { ): TemplateParameterDeclarationNode { reportInvalidDecorators(decorators, "template parameter"); const id = parseIdentifier(); - return finishNode( - { - kind: SyntaxKind.TemplateParameterDeclaration, - id, - }, - pos - ); + return { + kind: SyntaxKind.TemplateParameterDeclaration, + id, + ...finishNode(pos), + }; } function parseModelPropertyOrSpread(pos: number, decorators: DecoratorExpressionNode[]) { @@ -489,20 +486,18 @@ export function parse(code: string | SourceFile) { // This could be broadened to allow any type expression const target = parseReferenceExpression(); - return finishNode( - { - kind: SyntaxKind.ModelSpreadProperty, - target, - }, - pos - ); + return { + kind: SyntaxKind.ModelSpreadProperty, + target, + ...finishNode(pos), + }; } function parseModelProperty( pos: number, decorators: DecoratorExpressionNode[] ): ModelPropertyNode | ModelSpreadPropertyNode { - let id = + const id = token() === Token.StringLiteral ? parseStringLiteral() : parseIdentifier("Property expected."); @@ -511,16 +506,76 @@ export function parse(code: string | SourceFile) { parseExpected(Token.Colon); const value = parseExpression(); - return finishNode( - { - kind: SyntaxKind.ModelProperty, - id, - decorators, - value, - optional, - }, - pos + return { + kind: SyntaxKind.ModelProperty, + id, + decorators, + value, + optional, + ...finishNode(pos), + }; + } + + function parseEnumStatement(decorators: DecoratorExpressionNode[]): EnumStatementNode { + const pos = tokenPos(); + parseExpected(Token.EnumKeyword); + const id = parseIdentifier(); + const members = parseList(ListKind.EnumMembers, parseEnumMember); + return { + kind: SyntaxKind.EnumStatement, + id, + decorators, + members, + ...finishNode(pos), + }; + } + + function parseEnumMember(pos: number, decorators: DecoratorExpressionNode[]): EnumMemberNode { + const id = + token() === Token.StringLiteral + ? parseStringLiteral() + : parseIdentifier("Enum member expected."); + + let value: StringLiteralNode | NumericLiteralNode | undefined; + if (parseOptional(Token.Colon)) { + const expr = parseExpression(); + + if (expr.kind === SyntaxKind.StringLiteral || expr.kind === SyntaxKind.NumericLiteral) { + value = expr; + } else if (getFlag(expr, NodeFlags.ThisNodeHasError)) { + parseErrorInNextFinishedNode = true; + } else { + error("Expected numeric or string literal", expr); + } + } + + return { + kind: SyntaxKind.EnumMember, + id, + value, + decorators, + ...finishNode(pos), + }; + } + + function parseAliasStatement(): AliasStatementNode { + const pos = tokenPos(); + parseExpected(Token.AliasKeyword); + const id = parseIdentifier(); + const templateParameters = parseOptionalList( + ListKind.TemplateParameters, + parseTemplateParameter ); + parseExpected(Token.Equals); + const value = parseExpression(); + parseExpected(Token.Semicolon); + return { + kind: SyntaxKind.AliasStatement, + id, + templateParameters, + value, + ...finishNode(pos), + }; } function parseExpression(): Expression { @@ -536,22 +591,17 @@ export function parse(code: string | SourceFile) { return node; } - node = finishNode( - { - kind: SyntaxKind.UnionExpression, - options: [node], - }, - pos - ); - + const options = [node]; while (parseOptional(Token.Bar)) { const expr = parseIntersectionExpressionOrHigher(); - node.options.push(expr); + options.push(expr); } - node.end = tokenPos(); - - return node; + return { + kind: SyntaxKind.UnionExpression, + options, + ...finishNode(pos), + }; } function parseIntersectionExpressionOrHigher(): Expression { @@ -563,22 +613,17 @@ export function parse(code: string | SourceFile) { return node; } - node = finishNode( - { - kind: SyntaxKind.IntersectionExpression, - options: [node], - }, - pos - ); - + const options = [node]; while (parseOptional(Token.Ampersand)) { const expr = parseArrayExpressionOrHigher(); - node.options.push(expr); + options.push(expr); } - node.end = tokenPos(); - - return node; + return { + kind: SyntaxKind.IntersectionExpression, + options, + ...finishNode(pos), + }; } function parseArrayExpressionOrHigher(): Expression { @@ -588,13 +633,11 @@ export function parse(code: string | SourceFile) { while (parseOptional(Token.OpenBracket)) { parseExpected(Token.CloseBracket); - expr = finishNode( - { - kind: SyntaxKind.ArrayExpression, - elementType: expr, - }, - pos - ); + expr = { + kind: SyntaxKind.ArrayExpression, + elementType: expr, + ...finishNode(pos), + }; } return expr; @@ -605,14 +648,12 @@ export function parse(code: string | SourceFile) { const target = parseIdentifierOrMemberExpression(); const args = parseOptionalList(ListKind.TemplateArguments, parseExpression); - return finishNode( - { - kind: SyntaxKind.TypeReference, - target, - arguments: args, - }, - pos - ); + return { + kind: SyntaxKind.TypeReference, + target, + arguments: args, + ...finishNode(pos), + }; } function parseImportStatement(): ImportStatementNode { @@ -622,13 +663,11 @@ export function parse(code: string | SourceFile) { const path = parseStringLiteral(); parseExpected(Token.Semicolon); - return finishNode( - { - kind: SyntaxKind.ImportStatement, - path, - }, - pos - ); + return { + kind: SyntaxKind.ImportStatement, + path, + ...finishNode(pos), + }; } function parseDecoratorExpression(): DecoratorExpressionNode { @@ -637,14 +676,12 @@ export function parse(code: string | SourceFile) { const target = parseIdentifierOrMemberExpression(); const args = parseOptionalList(ListKind.DecoratorArguments, parseExpression); - return finishNode( - { - kind: SyntaxKind.DecoratorExpression, - arguments: args, - target, - }, - pos - ); + return { + kind: SyntaxKind.DecoratorExpression, + arguments: args, + target, + ...finishNode(pos), + }; } function parseIdentifierOrMemberExpression(): IdentifierNode | MemberExpressionNode { @@ -652,14 +689,12 @@ export function parse(code: string | SourceFile) { while (parseOptional(Token.Dot)) { const pos = tokenPos(); - base = finishNode( - { - kind: SyntaxKind.MemberExpression, - base, - id: parseIdentifier(), - }, - pos - ); + base = { + kind: SyntaxKind.MemberExpression, + base, + id: parseIdentifier(), + ...finishNode(pos), + }; } return base; @@ -698,44 +733,38 @@ export function parse(code: string | SourceFile) { parseExpected(Token.OpenParen); const expr = parseExpression(); parseExpected(Token.CloseParen); - return finishNode(expr, pos); + return { ...expr, ...finishNode(pos) }; } function parseTupleExpression(): TupleExpressionNode { const pos = tokenPos(); const values = parseList(ListKind.Tuple, parseExpression); - return finishNode( - { - kind: SyntaxKind.TupleExpression, - values, - }, - pos - ); + return { + kind: SyntaxKind.TupleExpression, + values, + ...finishNode(pos), + }; } function parseModelExpression(): ModelExpressionNode { const pos = tokenPos(); const properties = parseList(ListKind.ModelProperties, parseModelPropertyOrSpread); - return finishNode( - { - kind: SyntaxKind.ModelExpression, - properties, - }, - pos - ); + return { + kind: SyntaxKind.ModelExpression, + properties, + ...finishNode(pos), + }; } function parseStringLiteral(): StringLiteralNode { const pos = tokenPos(); const value = tokenValue(); parseExpected(Token.StringLiteral); - return finishNode( - { - kind: SyntaxKind.StringLiteral, - value, - }, - pos - ); + return { + kind: SyntaxKind.StringLiteral, + value, + ...finishNode(pos), + }; } function parseNumericLiteral(): NumericLiteralNode { @@ -744,27 +773,22 @@ export function parse(code: string | SourceFile) { const value = Number(text); parseExpected(Token.NumericLiteral); - return finishNode( - { - kind: SyntaxKind.NumericLiteral, - text, - value, - }, - pos - ); + return { + kind: SyntaxKind.NumericLiteral, + value, + ...finishNode(pos), + }; } function parseBooleanLiteral(): BooleanLiteralNode { const pos = tokenPos(); const token = parseExpectedOneOf(Token.TrueKeyword, Token.FalseKeyword); - const value = token == Token.TrueKeyword; - return finishNode( - { - kind: SyntaxKind.BooleanLiteral, - value, - }, - pos - ); + const value = token === Token.TrueKeyword; + return { + kind: SyntaxKind.BooleanLiteral, + value, + ...finishNode(pos), + }; } function parseIdentifier(message?: string): IdentifierNode { @@ -781,13 +805,11 @@ export function parse(code: string | SourceFile) { const sv = tokenValue(); nextToken(); - return finishNode( - { - kind: SyntaxKind.Identifier, - sv, - }, - pos - ); + return { + kind: SyntaxKind.Identifier, + sv, + ...finishNode(pos), + }; } // utility functions @@ -803,34 +825,49 @@ export function parse(code: string | SourceFile) { return scanner.tokenPosition; } + function tokenEndPos() { + return scanner.position; + } + function nextToken() { // keep track of the previous token end separately from the current scanner // position as these will differ when the previous token had trailing // trivia, and we don't want to squiggle the trivia. previousTokenEnd = scanner.position; - do { + for (;;) { scanner.scan(); - } while (isTrivia(token())); + if (isTrivia(token())) { + if (options.comments && isComment(token())) { + comments.push({ + kind: + token() === Token.SingleLineComment + ? SyntaxKind.LineComment + : SyntaxKind.BlockComment, + pos: tokenPos(), + end: tokenEndPos(), + }); + } + } else { + break; + } + } } function createMissingIdentifier(): IdentifierNode { missingIdentifierCounter++; - return finishNode( - { - kind: SyntaxKind.Identifier, - sv: "" + missingIdentifierCounter, - }, - tokenPos() - ); + return { + kind: SyntaxKind.Identifier, + sv: "" + missingIdentifierCounter, + ...finishNode(tokenPos()), + }; } - function finishNode(o: T, pos: number): T & TextRange & { flags: NodeFlags } { + function finishNode(pos: number): TextRange & { flags: NodeFlags } { const flags = parseErrorInNextFinishedNode ? NodeFlags.ThisNodeHasError : NodeFlags.None; parseErrorInNextFinishedNode = false; return { - ...o, pos, end: previousTokenEnd, flags, @@ -931,7 +968,7 @@ export function parse(code: string | SourceFile) { * open token is present. Otherwise, return an empty list. */ function parseOptionalList(kind: SurroundedListKind, parseItem: ParseListItem): T[] { - return token() == kind.open ? parseList(kind, parseItem) : []; + return token() === kind.open ? parseList(kind, parseItem) : []; } function parseOptionalDelimiter(kind: ListKind) { @@ -953,7 +990,7 @@ export function parse(code: string | SourceFile) { function parseEmptyStatement(): EmptyStatementNode { const pos = tokenPos(); parseExpected(Token.Semicolon); - return finishNode({ kind: SyntaxKind.EmptyStatement }, pos); + return { kind: SyntaxKind.EmptyStatement, ...finishNode(pos) }; } function parseInvalidStatement(): InvalidStatementNode { @@ -972,7 +1009,7 @@ export function parse(code: string | SourceFile) { ); error("Statement expected.", { pos, end: previousTokenEnd }); - return finishNode({ kind: SyntaxKind.InvalidStatement }, pos); + return { kind: SyntaxKind.InvalidStatement, ...finishNode(pos) }; } function error(message: string, target?: TextRange & { realPos?: number }) { @@ -990,18 +1027,15 @@ export function parse(code: string | SourceFile) { if (realPositionOfLastError === realPos) { return; } - realPositionOfLastError = realPos; - parseErrorInNextFinishedNode = true; - reportDiagnostic(message, location); + const diagnostic = createDiagnostic(message, location); + reportDiagnostic(diagnostic); } - function reportDiagnostic( - message: Message | string, - target: DiagnosticTarget, - args?: (string | number)[] - ) { - const diagnostic = createDiagnostic(message, target, args); + function reportDiagnostic(diagnostic: Diagnostic) { + if (diagnostic.severity === "error") { + parseErrorInNextFinishedNode = true; + } parseDiagnostics.push(diagnostic); } @@ -1121,9 +1155,20 @@ export function visitChildren(node: Node, cb: NodeCb): T | undefined { visitNode(cb, node.id) || visitEach(cb, node.templateParameters) || visitEach(cb, node.heritage) || - visitNode(cb, node.assignment) || visitEach(cb, node.properties) ); + case SyntaxKind.EnumStatement: + 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.AliasStatement: + return ( + visitNode(cb, node.id) || + visitEach(cb, node.templateParameters) || + visitNode(cb, node.value) + ); case SyntaxKind.NamedImport: return visitNode(cb, node.id); case SyntaxKind.TypeReference: diff --git a/packages/adl/compiler/program.ts b/packages/adl/compiler/program.ts index 1963f74db..e01920990 100644 --- a/packages/adl/compiler/program.ts +++ b/packages/adl/compiler/program.ts @@ -1,8 +1,9 @@ -import { dirname, extname, isAbsolute, join, resolve } from "path"; +import { dirname, extname, isAbsolute, resolve } from "path"; import resolveModule from "resolve"; import { createBinder, createSymbolTable } from "./binder.js"; -import { createChecker } from "./checker.js"; -import { createSourceFile, DiagnosticError, throwDiagnostic } from "./diagnostics.js"; +import { Checker, createChecker } from "./checker.js"; +import { createDiagnostic, createSourceFile, DiagnosticTarget, NoTarget } from "./diagnostics.js"; +import { Message } from "./messages.js"; import { CompilerOptions } from "./options.js"; import { parse } from "./parser.js"; import { @@ -10,14 +11,18 @@ import { CompilerHost, DecoratorExpressionNode, DecoratorSymbol, + Diagnostic, IdentifierNode, LiteralType, ModelStatementNode, ModelType, NamespaceStatementNode, + Sym, + SymbolTable, SyntaxKind, Type, } from "./types.js"; +import { doIO, loadFile } from "./util.js"; export interface Program { compilerOptions: CompilerOptions; @@ -25,46 +30,73 @@ export interface Program { sourceFiles: ADLScriptNode[]; literalTypes: Map; host: CompilerHost; - checker?: ReturnType; + checker?: Checker; + readonly diagnostics: readonly Diagnostic[]; evalAdlScript(adlScript: string, filePath?: string): void; onBuild(cb: (program: Program) => void): Promise | void; getOption(key: string): string | undefined; executeModelDecorators(type: ModelType): void; executeDecorators(type: Type): void; executeDecorator(node: DecoratorExpressionNode, program: Program, type: Type): void; + stateSet(key: Symbol): Set; + stateMap(key: Symbol): Map; + hasError(): boolean; + reportDiagnostic( + message: Message | string, + target: DiagnosticTarget | typeof NoTarget, + args?: (string | number)[] + ): void; + reportDiagnostic(diagnostic: Diagnostic): void; + reportDiagnostics(diagnostics: Diagnostic[]): void; + reportDuplicateSymbols(symbols: SymbolTable): void; } export async function createProgram( host: CompilerHost, - options: CompilerOptions + mainFile: string, + options: CompilerOptions = {} ): Promise { const buildCbs: any = []; - + const stateMaps = new Map>(); + const stateSets = new Map>(); + const diagnostics: Diagnostic[] = []; const seenSourceFiles = new Set(); + const duplicateSymbols = new Set(); + let error = false; + const program: Program = { - compilerOptions: options || {}, + compilerOptions: options, globalNamespace: createGlobalNamespace(), sourceFiles: [], literalTypes: new Map(), host, + diagnostics, evalAdlScript, executeModelDecorators, executeDecorators, executeDecorator, getOption, + stateMap, + stateSet, + reportDiagnostic, + reportDiagnostics, + reportDuplicateSymbols, + hasError() { + return error; + }, onBuild(cb) { buildCbs.push(cb); }, }; let virtualFileCount = 0; - const binder = createBinder(); + const binder = createBinder(program); if (!options?.nostdlib) { await loadStandardLibrary(program); } - await loadMain(options); + await loadMain(mainFile, options); const checker = (program.checker = createChecker(program)); program.checker.checkProgram(program); @@ -124,14 +156,16 @@ export async function createProgram( function executeDecorator(dec: DecoratorExpressionNode, program: Program, type: Type) { if (dec.target.kind !== SyntaxKind.Identifier) { - throwDiagnostic("Decorator must be identifier", dec); + program.reportDiagnostic("Decorator must be identifier", dec); + return; } const decName = dec.target.sv; const args = dec.arguments.map((a) => toJSON(checker.getTypeForNode(a))); const decBinding = program.globalNamespace.locals!.get(decName) as DecoratorSymbol; if (!decBinding) { - throwDiagnostic(`Can't find decorator ${decName}`, dec); + program.reportDiagnostic(`Can't find decorator ${decName}`, dec); + return; } const decFn = decBinding.value; decFn(program, type, ...args); @@ -142,7 +176,7 @@ export async function createProgram( * just the raw type objects, but literals are treated specially. */ function toJSON(type: Type): Type | string | number | boolean { - if ("value" in type) { + if (type.kind === "Number" || type.kind === "String" || type.kind === "Boolean") { return type.value; } @@ -155,36 +189,40 @@ export async function createProgram( } } - async function loadDirectory(rootDir: string) { - const dir = await host.readDir(rootDir); - for (const entry of dir) { - if (entry.isFile()) { - const path = join(rootDir, entry.name); - if (entry.name.endsWith(".js")) { - await loadJsFile(path); - } else if (entry.name.endsWith(".adl")) { - await loadAdlFile(path); - } - } - } + async function loadDirectory(dir: string, diagnosticTarget?: DiagnosticTarget) { + const pkgJsonPath = resolve(dir, "package.json"); + let [pkg] = await loadFile(host.readFile, pkgJsonPath, JSON.parse, program.reportDiagnostic, { + allowFileNotFound: true, + diagnosticTarget, + }); + const mainFile = resolve(dir, pkg?.adlMain ?? "main.adl"); + await loadAdlFile(mainFile, diagnosticTarget); } - async function loadAdlFile(path: string) { + async function loadAdlFile(path: string, diagnosticTarget?: DiagnosticTarget) { if (seenSourceFiles.has(path)) { return; } seenSourceFiles.add(path); - const contents = await host.readFile(path); - if (!contents) { - throw new Error("Couldn't load ADL file " + path); - } + const contents = await doIO(host.readFile, path, program.reportDiagnostic, { + diagnosticTarget, + }); - await evalAdlScript(contents, path); + if (contents) { + await evalAdlScript(contents, path); + } } - async function loadJsFile(path: string) { - const exports = await host.getJsImport(path); + async function loadJsFile(path: string, diagnosticTarget: DiagnosticTarget) { + const exports = await doIO(host.getJsImport, path, program.reportDiagnostic, { + diagnosticTarget, + jsDiagnosticTarget: { file: createSourceFile("", path), pos: 0, end: 0 }, + }); + + if (!exports) { + return; + } for (const match of Object.keys(exports)) { // bind JS files early since this is the only work @@ -212,13 +250,9 @@ export async function createProgram( const unparsedFile = createSourceFile(adlScript, filePath); const sourceFile = parse(unparsedFile); - // We don't attempt to evaluate yet when there are parse errors. - if (sourceFile.parseDiagnostics.length > 0) { - throw new DiagnosticError(sourceFile.parseDiagnostics); - } - + program.reportDiagnostics(sourceFile.parseDiagnostics); program.sourceFiles.push(sourceFile); - binder.bindSourceFile(program, sourceFile); + binder.bindSourceFile(sourceFile); await evalImports(sourceFile); } @@ -240,7 +274,8 @@ export async function createProgram( target = await resolveModuleSpecifier(path, basedir); } catch (e) { if (e.code === "MODULE_NOT_FOUND") { - throwDiagnostic(`Couldn't find library "${path}"`, stmt); + program.reportDiagnostic(`Couldn't find library "${path}"`, stmt); + continue; } else { throw e; } @@ -250,14 +285,13 @@ export async function createProgram( const ext = extname(target); if (ext === "") { - // look for a main.adl - await loadAdlFile(join(target, "main.adl")); - } else if (ext === ".js") { - await loadJsFile(target); + await loadDirectory(target, stmt); + } else if (ext === ".js" || ext === ".mjs") { + await loadJsFile(target, stmt); } else if (ext === ".adl") { - await loadAdlFile(target); + await loadAdlFile(target, stmt); } else { - throwDiagnostic( + program.reportDiagnostic( "Import paths must reference either a directory, a .adl file, or .js file", stmt ); @@ -313,7 +347,13 @@ export async function createProgram( host .realpath(path) .then((p) => cb(null, p)) - .catch((e) => cb(e)); + .catch((e) => { + if (e.code === "ENOENT" || e.code === "ENOTDIR") { + cb(null, path); + } else { + cb(e); + } + }); }, packageFilter(pkg) { // this lets us follow node resolve semantics more-or-less exactly @@ -326,8 +366,7 @@ export async function createProgram( if (err) { rejectP(err); } else if (!resolved) { - // I don't know when this happens - rejectP(new Error("Couldn't resolve module")); + rejectP(new Error("BUG: Module resolution succeeded but didn't return a value.")); } else { resolveP(resolved); } @@ -336,15 +375,12 @@ export async function createProgram( }); } - async function loadMain(options: CompilerOptions) { - if (!options.mainFile) { - throw new Error("Must specify a main file"); + async function loadMain(mainFile: string, options: CompilerOptions) { + const mainPath = resolve(host.getCwd(), mainFile); + const mainStat = await doIO(host.stat, mainPath, program.reportDiagnostic); + if (!mainStat) { + return; } - - const mainPath = resolve(host.getCwd(), options.mainFile); - - const mainStat = await host.stat(mainPath); - if (mainStat.isDirectory()) { await loadDirectory(mainPath); } else { @@ -355,8 +391,69 @@ export async function createProgram( function getOption(key: string): string | undefined { return (options.miscOptions || {})[key]; } + + function stateMap(key: Symbol): Map { + let m = stateMaps.get(key); + if (!m) { + m = new Map(); + stateMaps.set(key, m); + } + + return m; + } + + function stateSet(key: Symbol): Set { + let s = stateSets.get(key); + if (!s) { + s = new Set(); + stateSets.set(key, s); + } + + return s; + } + + function reportDiagnostic(diagnostic: Diagnostic): void; + + function reportDiagnostic( + message: Message | string, + target: DiagnosticTarget | typeof NoTarget, + args?: (string | number)[] + ): void; + + function reportDiagnostic( + diagnostic: Message | string | Diagnostic, + target?: DiagnosticTarget | typeof NoTarget, + args?: (string | number)[] + ): void { + if (typeof diagnostic === "string" || "text" in diagnostic) { + diagnostic = createDiagnostic(diagnostic, target!, args); + } + if (diagnostic.severity === "error") { + error = true; + } + diagnostics.push(diagnostic); + } + + function reportDiagnostics(newDiagnostics: Diagnostic[]) { + for (const diagnostic of newDiagnostics) { + reportDiagnostic(diagnostic); + } + } + + function reportDuplicateSymbols(symbols: SymbolTable) { + for (const symbol of symbols.duplicates) { + if (!duplicateSymbols.has(symbol)) { + duplicateSymbols.add(symbol); + reportDiagnostic("Duplicate name: " + symbol.name, symbol); + } + } + } } -export async function compile(rootDir: string, host: CompilerHost, options?: CompilerOptions) { - const program = await createProgram(host, { mainFile: rootDir, ...options }); +export async function compile( + mainFile: string, + host: CompilerHost, + options?: CompilerOptions +): Promise { + return await createProgram(host, mainFile, options); } diff --git a/packages/adl/compiler/scanner.ts b/packages/adl/compiler/scanner.ts index 53b43b42d..d103edcd7 100644 --- a/packages/adl/compiler/scanner.ts +++ b/packages/adl/compiler/scanner.ts @@ -1,5 +1,5 @@ import { - CharacterCodes, + CharCode, isAsciiIdentifierContinue, isAsciiIdentifierStart, isBinaryDigit, @@ -7,11 +7,15 @@ import { isHexDigit, isIdentifierContinue, isLineBreak, + isLowercaseAsciiLetter, isNonAsciiIdentifierContinue, isNonAsciiIdentifierStart, + isNonAsciiLineBreak, + isNonAsciiWhiteSpaceSingleLine, isWhiteSpaceSingleLine, -} from "./character-codes.js"; -import { createSourceFile, Message, throwOnError } from "./diagnostics.js"; + utf16CodeUnits, +} from "./charcode.js"; +import { createDiagnostic, createSourceFile, DiagnosticHandler, Message } from "./diagnostics.js"; import { SourceFile } from "./types.js"; // All conflict markers consist of the same character repeated seven times. If it is @@ -56,6 +60,7 @@ export enum Token { Question = 25, Colon = 26, At = 27, + // Update MaxPunctuation if anything is added right above here // Identifiers Identifier = 28, @@ -66,11 +71,15 @@ export enum Token { NamespaceKeyword = 31, UsingKeyword = 32, OpKeyword = 33, + EnumKeyword = 34, + AliasKeyword = 35, + // Update MaxStatementKeyword if anything is added right above here // Other keywords - ExtendsKeyword = 34, - TrueKeyword = 35, - FalseKeyword = 36, + ExtendsKeyword = 36, + TrueKeyword = 37, + FalseKeyword = 38, + // Update MaxKeyword if anything is added right above here } const MinKeyword = Token.ImportKeyword; @@ -80,19 +89,20 @@ const MinPunctuation = Token.OpenBrace; const MaxPunctuation = Token.At; const MinStatementKeyword = Token.ImportKeyword; -const MaxStatementKeyword = Token.OpKeyword; +const MaxStatementKeyword = Token.AliasKeyword; +/** @internal */ export const TokenDisplay: readonly string[] = [ - "", - "", - "", - "", - "", - "", - "", - "", - "", - "", + "none", + "invalid", + "end of file", + "single-line comment", + "multi-line comment", + "newline", + "whitespace", + "conflict marker", + "numeric literal", + "string literal", "'{'", "'}'", "'('", @@ -111,29 +121,56 @@ export const TokenDisplay: readonly string[] = [ "'?'", "':'", "'@'", - "", + "identifier", "'import'", "'model'", "'namespace'", "'using'", "'op'", + "'enum'", + "'alias'", "'extends'", "'true'", "'false'", ]; -export const Keywords: ReadonlyMap = new Map([ +/** @internal */ +export const Keywords: readonly [string, Token][] = [ ["import", Token.ImportKeyword], ["model", Token.ModelKeyword], ["namespace", Token.NamespaceKeyword], ["using", Token.UsingKeyword], ["op", Token.OpKeyword], ["extends", Token.ExtendsKeyword], + ["enum", Token.EnumKeyword], + ["alias", Token.AliasKeyword], ["true", Token.TrueKeyword], ["false", Token.FalseKeyword], -]); +]; -export const maxKeywordLength = 9; +/** @internal */ +export const enum KeywordLimit { + MinLength = 2, + // If this ever exceeds 10, we will overflow the keyword map key, needing 11*5 + // = 55 bits or more, exceeding the JavaScript safe integer range. We would + // have to change the keyword lookup algorithm in that case. + MaxLength = 9, +} + +const KeywordMap: ReadonlyMap = new Map( + Keywords.map((e) => [keywordKey(e[0]), e[1]]) +); + +// Since keywords are short and all lowercase, we can pack the whole string into +// a single number by using 5 bits for each letter, and use that as the map key. +// This lets us lookup keywords without making temporary substrings. +function keywordKey(keyword: string) { + let key = 0; + for (let i = 0; i < keyword.length; i++) { + key = (key << 5) | (keyword.charCodeAt(i) - CharCode.a); + } + return key; +} export interface Scanner { /** The source code being scanned. */ @@ -168,9 +205,9 @@ export interface Scanner { const enum TokenFlags { None = 0, - HasCrlf = 1 << 0, - Escaped = 1 << 1, - TripleQuoted = 1 << 2, + Escaped = 1 << 0, + TripleQuoted = 1 << 1, + Unterminated = 1 << 2, } export function isLiteral(token: Token) { @@ -191,6 +228,10 @@ export function isTrivia(token: Token) { ); } +export function isComment(token: Token) { + return token === Token.SingleLineComment || token === Token.MultiLineComment; +} + export function isKeyword(token: Token) { return token >= MinKeyword && token <= MaxKeyword; } @@ -203,13 +244,15 @@ export function isStatementKeyword(token: Token) { return token >= MinStatementKeyword && token <= MaxStatementKeyword; } -export function createScanner(source: string | SourceFile, onError = throwOnError): Scanner { +export function createScanner( + source: string | SourceFile, + diagnosticHandler: DiagnosticHandler +): Scanner { const file = typeof source === "string" ? createSourceFile(source, "") : source; const input = file.text; let position = 0; - let token = Token.Invalid; + let token = Token.None; let tokenPosition = -1; - let tokenValue: string | undefined = undefined; let tokenFlags = TokenFlags.None; return { @@ -233,165 +276,187 @@ export function createScanner(source: string | SourceFile, onError = throwOnErro return position >= input.length; } - function next(t: Token, count = 1) { - position += count; - return (token = t); - } - - function utf16CodeUnits(codePoint: number) { - return codePoint >= 0x10000 ? 2 : 1; - } - function getTokenText() { return input.substring(tokenPosition, position); } + function getTokenValue() { + return token === Token.StringLiteral ? getStringTokenValue() : getTokenText(); + } + function lookAhead(offset: number) { return input.charCodeAt(position + offset); } function scan(): Token { tokenPosition = position; - tokenValue = undefined; tokenFlags = TokenFlags.None; if (!eof()) { const ch = input.charCodeAt(position); switch (ch) { - case CharacterCodes.carriageReturn: - if (lookAhead(1) === CharacterCodes.lineFeed) { + case CharCode.CarriageReturn: + if (lookAhead(1) === CharCode.LineFeed) { position++; } // fallthrough - case CharacterCodes.lineFeed: - case CharacterCodes.lineSeparator: - case CharacterCodes.paragraphSeparator: + case CharCode.LineFeed: return next(Token.NewLine); - case CharacterCodes.tab: - case CharacterCodes.verticalTab: - case CharacterCodes.formFeed: - case CharacterCodes.space: - case CharacterCodes.nonBreakingSpace: - case CharacterCodes.ogham: - case CharacterCodes.enQuad: - case CharacterCodes.emQuad: - case CharacterCodes.enSpace: - case CharacterCodes.emSpace: - case CharacterCodes.threePerEmSpace: - case CharacterCodes.fourPerEmSpace: - case CharacterCodes.sixPerEmSpace: - case CharacterCodes.figureSpace: - case CharacterCodes.punctuationSpace: - case CharacterCodes.thinSpace: - case CharacterCodes.hairSpace: - case CharacterCodes.zeroWidthSpace: - case CharacterCodes.narrowNoBreakSpace: - case CharacterCodes.mathematicalSpace: - case CharacterCodes.ideographicSpace: - case CharacterCodes.byteOrderMark: + case CharCode.Space: + case CharCode.Tab: + case CharCode.VerticalTab: + case CharCode.FormFeed: return scanWhitespace(); - case CharacterCodes.openParen: + case CharCode.OpenParen: return next(Token.OpenParen); - case CharacterCodes.closeParen: + case CharCode.CloseParen: return next(Token.CloseParen); - case CharacterCodes.comma: + case CharCode.Comma: return next(Token.Comma); - case CharacterCodes.colon: + case CharCode.Colon: return next(Token.Colon); - case CharacterCodes.semicolon: + case CharCode.Semicolon: return next(Token.Semicolon); - case CharacterCodes.openBracket: + case CharCode.OpenBracket: return next(Token.OpenBracket); - case CharacterCodes.closeBracket: + case CharCode.CloseBracket: return next(Token.CloseBracket); - case CharacterCodes.openBrace: + case CharCode.OpenBrace: return next(Token.OpenBrace); - case CharacterCodes.closeBrace: + case CharCode.CloseBrace: return next(Token.CloseBrace); - case CharacterCodes.at: + case CharCode.At: return next(Token.At); - case CharacterCodes.question: + case CharCode.Question: return next(Token.Question); - case CharacterCodes.ampersand: + case CharCode.Ampersand: return next(Token.Ampersand); - case CharacterCodes.dot: - return lookAhead(1) === CharacterCodes.dot && lookAhead(2) === CharacterCodes.dot + case CharCode.Dot: + return lookAhead(1) === CharCode.Dot && lookAhead(2) === CharCode.Dot ? next(Token.Elipsis, 3) : next(Token.Dot); - case CharacterCodes.slash: + case CharCode.Slash: switch (lookAhead(1)) { - case CharacterCodes.slash: + case CharCode.Slash: return scanSingleLineComment(); - case CharacterCodes.asterisk: + case CharCode.Asterisk: return scanMultiLineComment(); } return scanInvalidCharacter(); - case CharacterCodes._0: + case CharCode.Plus: + case CharCode.Minus: + return isDigit(lookAhead(1)) ? scanSignedNumber() : scanInvalidCharacter(); + + case CharCode._0: switch (lookAhead(1)) { - case CharacterCodes.x: + case CharCode.x: return scanHexNumber(); - case CharacterCodes.b: + case CharCode.b: return scanBinaryNumber(); } // fallthrough - case CharacterCodes._1: - case CharacterCodes._2: - case CharacterCodes._3: - case CharacterCodes._4: - case CharacterCodes._5: - case CharacterCodes._6: - case CharacterCodes._7: - case CharacterCodes._8: - case CharacterCodes._9: + case CharCode._1: + case CharCode._2: + case CharCode._3: + case CharCode._4: + case CharCode._5: + case CharCode._6: + case CharCode._7: + case CharCode._8: + case CharCode._9: return scanNumber(); - case CharacterCodes.lessThan: + case CharCode.LessThan: return isConflictMarker() ? next(Token.ConflictMarker, mergeConflictMarkerLength) : next(Token.LessThan); - case CharacterCodes.greaterThan: + case CharCode.GreaterThan: return isConflictMarker() ? next(Token.ConflictMarker, mergeConflictMarkerLength) : next(Token.GreaterThan); - case CharacterCodes.equals: + case CharCode.Equals: return isConflictMarker() ? next(Token.ConflictMarker, mergeConflictMarkerLength) : next(Token.Equals); - case CharacterCodes.bar: + case CharCode.Bar: return isConflictMarker() ? next(Token.ConflictMarker, mergeConflictMarkerLength) : next(Token.Bar); - case CharacterCodes.doubleQuote: - return scanString(); + case CharCode.DoubleQuote: + return lookAhead(1) === CharCode.DoubleQuote && lookAhead(2) === CharCode.DoubleQuote + ? scanTripleQuotedString() + : scanString(); default: - return scanIdentifierOrKeyword(); + if (isLowercaseAsciiLetter(ch)) { + return scanIdentifierOrKeyword(); + } + + if (isAsciiIdentifierStart(ch)) { + return scanIdentifier(); + } + + if (ch <= CharCode.MaxAscii) { + return scanInvalidCharacter(); + } + + return scanNonAsciiToken(); } } return (token = Token.EndOfFile); } + function next(t: Token, count = 1) { + position += count; + return (token = t); + } + + function unterminated(t: Token) { + tokenFlags |= TokenFlags.Unterminated; + error(Message.Unterminated, [TokenDisplay[t]]); + return (token = t); + } + + function scanNonAsciiToken() { + const ch = input.charCodeAt(position); + + if (isNonAsciiLineBreak(ch)) { + return next(Token.NewLine); + } + + if (isNonAsciiWhiteSpaceSingleLine(ch)) { + return scanWhitespace(); + } + + let cp = input.codePointAt(position)!; + if (isNonAsciiIdentifierStart(cp)) { + return scanNonAsciiIdentifier(cp); + } + + return scanInvalidCharacter(); + } + function scanInvalidCharacter() { const codePoint = input.codePointAt(position)!; token = next(Token.Invalid, utf16CodeUnits(codePoint)); @@ -409,10 +474,7 @@ export function createScanner(source: string | SourceFile, onError = throwOnErro return false; } } - return ( - ch === CharacterCodes.equals || - lookAhead(mergeConflictMarkerLength) === CharacterCodes.space - ); + return ch === CharCode.Equals || lookAhead(mergeConflictMarkerLength) === CharCode.Space; } } @@ -420,267 +482,274 @@ export function createScanner(source: string | SourceFile, onError = throwOnErro } function error(msg: Message, args?: (string | number)[]) { - onError(msg, { file, pos: tokenPosition, end: position }, args); + const diagnostic = createDiagnostic(msg, { file, pos: tokenPosition, end: position }, args); + diagnosticHandler(diagnostic); } function scanWhitespace(): Token { do { position++; - } while (isWhiteSpaceSingleLine(input.charCodeAt(position))); + } while (!eof() && isWhiteSpaceSingleLine(input.charCodeAt(position))); return (token = Token.Whitespace); } - function scanDigits() { - while (isDigit(input.charCodeAt(position))) { - position++; - } + function scanSignedNumber() { + position++; // consume '+/-' + return scanNumber(); } function scanNumber() { - scanDigits(); - - let ch = input.charCodeAt(position); - - if (ch === CharacterCodes.dot) { + scanKnownDigits(); + if (!eof() && input.charCodeAt(position) === CharCode.Dot) { position++; - scanDigits(); + scanRequiredDigits(); } - - ch = input.charCodeAt(position); - if (ch === CharacterCodes.e) { + if (!eof() && input.charCodeAt(position) === CharCode.e) { position++; - ch = input.charCodeAt(position); - if (ch === CharacterCodes.plus || ch == CharacterCodes.minus) { + const ch = input.charCodeAt(position); + if (ch === CharCode.Plus || ch === CharCode.Minus) { position++; - ch = input.charCodeAt(position); - } - - if (isDigit(ch)) { - position++; - scanDigits(); - } else { - error(Message.DigitExpected); } + scanRequiredDigits(); } - return (token = Token.NumericLiteral); } - function scanHexNumber() { - if (!isHexDigit(lookAhead(2))) { - error(Message.HexDigitExpected); - return next(Token.NumericLiteral, 2); - } + function scanKnownDigits() { + do { + position++; + } while (!eof() && isDigit(input.charCodeAt(position))); + } + + function scanRequiredDigits() { + if (eof() || !isDigit(input.charCodeAt(position))) { + error(Message.DigitExpected); + return; + } + scanKnownDigits(); + } + + function scanHexNumber() { + position += 2; // consume '0x' + + if (eof() || !isHexDigit(input.charCodeAt(position))) { + error(Message.HexDigitExpected); + return (token = Token.NumericLiteral); + } + do { + position++; + } while (!eof() && isHexDigit(input.charCodeAt(position))); - position += 2; - scanUntil((ch) => !isHexDigit(ch), "Hex Digit"); return (token = Token.NumericLiteral); } function scanBinaryNumber() { - if (!isBinaryDigit(lookAhead(2))) { - error(Message.BinaryDigitExpected); - return next(Token.NumericLiteral, 2); - } + position += 2; // consume '0b' + + if (eof() || !isBinaryDigit(input.charCodeAt(position))) { + error(Message.BinaryDigitExpected); + return (token = Token.NumericLiteral); + } + do { + position++; + } while (!eof() && isBinaryDigit(input.charCodeAt(position))); - position += 2; - scanUntil((ch) => !isBinaryDigit(ch), "Binary Digit"); return (token = Token.NumericLiteral); } - function scanUntil( - predicate: (char: number) => boolean, - expectedClose?: string, - consumeClose?: number - ) { - let ch: number; + function scanSingleLineComment() { + position += 2; // consume '//' - do { - position++; - - if (eof()) { - if (expectedClose) { - error(Message.UnexpectedEndOfFile, [expectedClose]); - } + for (; !eof(); position++) { + if (isLineBreak(input.charCodeAt(position))) { break; } - - ch = input.charCodeAt(position); - } while (!predicate(ch)); - - if (consumeClose) { - position += consumeClose; } - } - function scanSingleLineComment() { - scanUntil(isLineBreak); return (token = Token.SingleLineComment); } function scanMultiLineComment() { - scanUntil( - (ch) => ch === CharacterCodes.asterisk && lookAhead(1) === CharacterCodes.slash, - "*/", - 2 - ); - return (token = Token.MultiLineComment); + position += 2; // consume '/*' + + for (; !eof(); position++) { + if (input.charCodeAt(position) === CharCode.Asterisk && lookAhead(1) === CharCode.Slash) { + position += 2; + return (token = Token.MultiLineComment); + } + } + + return unterminated(Token.MultiLineComment); } function scanString() { - let quoteLength = 1; - let closing = '"'; - let isEscaping = false; + position++; // consume '"' - const tripleQuoted = - lookAhead(1) === CharacterCodes.doubleQuote && lookAhead(2) === CharacterCodes.doubleQuote; - - if (tripleQuoted) { - tokenFlags |= TokenFlags.TripleQuoted; - quoteLength = 3; - position += 2; - closing = '"""'; + loop: for (; !eof(); position++) { + const ch = input.charCodeAt(position); + switch (ch) { + case CharCode.Backslash: + tokenFlags |= TokenFlags.Escaped; + position++; + if (eof()) { + break loop; + } + continue; + case CharCode.DoubleQuote: + position++; + return (token = Token.StringLiteral); + case CharCode.CarriageReturn: + case CharCode.LineFeed: + break loop; + default: + if (ch > CharCode.MaxAscii && isNonAsciiLineBreak(ch)) { + break loop; + } + continue; + } } - scanUntil( - (ch) => { - if (isEscaping) { - isEscaping = false; - return false; - } - - switch (ch) { - case CharacterCodes.carriageReturn: - if (lookAhead(1) === CharacterCodes.lineFeed) { - tokenFlags |= TokenFlags.HasCrlf; - } - return false; - - case CharacterCodes.backslash: - isEscaping = true; - tokenFlags |= TokenFlags.Escaped; - return false; - - case CharacterCodes.doubleQuote: - if (tripleQuoted) { - return ( - lookAhead(1) === CharacterCodes.doubleQuote && - lookAhead(2) === CharacterCodes.doubleQuote - ); - } - return true; - - default: - return false; - } - }, - closing, - quoteLength - ); - - return (token = Token.StringLiteral); + return unterminated(Token.StringLiteral); } - function getTokenValue() { - if (tokenValue !== undefined) { - return tokenValue; + function scanTripleQuotedString() { + tokenFlags |= TokenFlags.TripleQuoted; + position += 3; // consume '"""' + + for (; !eof(); position++) { + if ( + input.charCodeAt(position) === CharCode.DoubleQuote && + lookAhead(1) === CharCode.DoubleQuote && + lookAhead(2) === CharCode.DoubleQuote + ) { + position += 3; + return (token = Token.StringLiteral); + } } - if (token !== Token.StringLiteral) { - return (tokenValue = getTokenText()); - } + return unterminated(Token.StringLiteral); + } - // strip quotes + function getStringTokenValue() { const quoteLength = tokenFlags & TokenFlags.TripleQuoted ? 3 : 1; - let value = input.substring(tokenPosition + quoteLength, position - quoteLength); - - // Normalize CRLF to LF when interpreting value of multi-line string - // literals. Matches JavaScript behavior and ensures program behavior does - // not change due to line-ending conversion. - if (tokenFlags & TokenFlags.HasCrlf) { - value = value.replace(/\r\n/g, "\n"); - } + const start = tokenPosition + quoteLength; + const end = tokenFlags & TokenFlags.Unterminated ? position : position - quoteLength; if (tokenFlags & TokenFlags.TripleQuoted) { - value = unindentTripleQuoteString(value); + return unindentAndUnescapeTripleQuotedString(start, end); } if (tokenFlags & TokenFlags.Escaped) { - value = unescapeString(value); + return unescapeString(start, end); } - return (tokenValue = value); + return input.substring(start, end); } - function unindentTripleQuoteString(text: string) { - let start = 0; - let end = text.length; - + function unindentAndUnescapeTripleQuotedString(start: number, end: number) { // ignore leading whitespace before required initial line break - while (start < end && isWhiteSpaceSingleLine(text.charCodeAt(start))) { + while (start < end && isWhiteSpaceSingleLine(input.charCodeAt(start))) { start++; } // remove required initial line break - if (isLineBreak(text.charCodeAt(start))) { + if (isLineBreak(input.charCodeAt(start))) { + if (isCrlf(start, start, end)) { + start++; + } start++; } else { error(Message.NoNewLineAtStartOfTripleQuotedString); } - // remove whitespace before closing delimiter and record it as - // required indentation for all lines. - while (end > start && isWhiteSpaceSingleLine(text.charCodeAt(end - 1))) { + // remove whitespace before closing delimiter and record it as required + // indentation for all lines + const indentationEnd = end; + while (end > start && isWhiteSpaceSingleLine(input.charCodeAt(end - 1))) { end--; } - const indentation = text.substring(end, text.length); + const indentationStart = end; // remove required final line break - if (isLineBreak(text.charCodeAt(end - 1))) { + if (isLineBreak(input.charCodeAt(end - 1))) { + if (isCrlf(end - 2, start, end)) { + end--; + } end--; } else { error(Message.NoNewLineAtEndOfTripleQuotedString); } - // remove required matching indentation from each line - return removeMatchingIndentation(text, start, end, indentation); - } - - function removeMatchingIndentation( - text: string, - start: number, - end: number, - indentation: string - ) { + // remove required matching indentation from each line and unescape in the + // process of doing so let result = ""; let pos = start; - while (pos < end) { - start = skipMatchingIndentation(text, pos, end, indentation); - while (pos < end && !isLineBreak(text.charCodeAt(pos))) { - pos++; + // skip indentation at start of line + start = skipMatchingIndentation(pos, end, indentationStart, indentationEnd); + let ch; + + while (pos < end && !isLineBreak((ch = input.charCodeAt(pos)))) { + if (ch !== CharCode.Backslash) { + pos++; + continue; + } + result += input.substring(start, pos); + if (pos === end - 1) { + error(Message.InvalidEscapeSequence); + pos++; + } else { + result += unescapeOne(pos); + pos += 2; + } + start = pos; } + if (pos < end) { - pos++; // include line break + if (isCrlf(pos, start, end)) { + // CRLF in multi-line string is normalized to LF in string value. + // This keeps program behavior unchanged by line-eding conversion. + result += input.substring(start, pos); + result += "\n"; + pos += 2; + } else { + pos++; // include non-CRLF newline + result += input.substring(start, pos); + } + start = pos; } - result += text.substring(start, pos); } + result += input.substring(start, pos); return result; } - function skipMatchingIndentation(text: string, pos: number, end: number, indentation: string) { - end = Math.min(end, pos + indentation.length); + function isCrlf(pos: number, start: number, end: number) { + return ( + pos >= start && + pos < end - 1 && + input.charCodeAt(pos) === CharCode.CarriageReturn && + input.charCodeAt(pos + 1) === CharCode.LineFeed + ); + } + + function skipMatchingIndentation( + pos: number, + end: number, + indentationStart: number, + indentationEnd: number + ) { + let indentationPos = indentationStart; + end = Math.min(end, pos + (indentationEnd - indentationStart)); - let indentationPos = 0; while (pos < end) { - const ch = text.charCodeAt(pos); + const ch = input.charCodeAt(pos); if (isLineBreak(ch)) { // allow subset of indentation if line has only whitespace break; } - if (ch != indentation.charCodeAt(indentationPos)) { + if (ch !== input.charCodeAt(indentationPos)) { error(Message.InconsistentTripleQuoteIndentation); break; } @@ -691,78 +760,86 @@ export function createScanner(source: string | SourceFile, onError = throwOnErro return pos; } - function unescapeString(text: string) { + function unescapeString(start: number, end: number) { let result = ""; - let start = 0; - let pos = 0; - const end = text.length; + let pos = start; while (pos < end) { - let ch = text.charCodeAt(pos); - if (ch != CharacterCodes.backslash) { + let ch = input.charCodeAt(pos); + if (ch !== CharCode.Backslash) { pos++; continue; } - result += text.substring(start, pos); - pos++; - ch = text.charCodeAt(pos); - - switch (ch) { - case CharacterCodes.r: - result += "\r"; - break; - case CharacterCodes.n: - result += "\n"; - break; - case CharacterCodes.t: - result += "\t"; - break; - case CharacterCodes.doubleQuote: - result += '"'; - break; - case CharacterCodes.backslash: - result += "\\"; - break; - default: - error(Message.InvalidEscapeSequence); - result += String.fromCharCode(ch); - break; + if (pos === end - 1) { + error(Message.InvalidEscapeSequence); + break; } - pos++; + result += input.substring(start, pos); + result += unescapeOne(pos); + pos += 2; start = pos; } - result += text.substring(start, pos); + result += input.substring(start, pos); return result; } + function unescapeOne(pos: number) { + const ch = input.charCodeAt(pos + 1); + switch (ch) { + case CharCode.r: + return "\r"; + case CharCode.n: + return "\n"; + case CharCode.t: + return "\t"; + case CharCode.DoubleQuote: + return '"'; + case CharCode.Backslash: + return "\\"; + default: + error(Message.InvalidEscapeSequence); + return String.fromCharCode(ch); + } + } + function scanIdentifierOrKeyword() { + let key = 0; + let count = 0; let ch = input.charCodeAt(position); - if (!isAsciiIdentifierStart(ch)) { - return scanNonAsciiIdentifier(); - } - - do { + while (true) { position++; + count++; + key = (key << 5) | (ch - CharCode.a); + if (eof()) { break; } - ch = input.charCodeAt(position); - } while (isAsciiIdentifierContinue(ch)); - if (!eof() && ch > CharacterCodes.maxAsciiCharacter) { - const codePoint = input.codePointAt(position)!; - if (isNonAsciiIdentifierContinue(codePoint)) { - return scanNonAsciiIdentifierContinue(codePoint); + ch = input.charCodeAt(position); + if (count < KeywordLimit.MaxLength && isLowercaseAsciiLetter(ch)) { + continue; } + + if (isAsciiIdentifierContinue(ch)) { + return scanIdentifier(); + } + + if (ch > CharCode.MaxAscii) { + const cp = input.codePointAt(position)!; + if (isNonAsciiIdentifierContinue(cp)) { + return scanNonAsciiIdentifier(cp); + } + } + + break; } - if (position - tokenPosition <= maxKeywordLength) { - const value = getTokenValue(); - const keyword = Keywords.get(value); + if (count >= KeywordLimit.MinLength && count <= KeywordLimit.MaxLength) { + const keyword = KeywordMap.get(key); if (keyword) { return (token = keyword); } @@ -771,23 +848,31 @@ export function createScanner(source: string | SourceFile, onError = throwOnErro return (token = Token.Identifier); } - function scanNonAsciiIdentifier() { - let codePoint = input.codePointAt(position)!; - return isNonAsciiIdentifierStart(codePoint) - ? scanNonAsciiIdentifierContinue(codePoint) - : scanInvalidCharacter(); - } - - function scanNonAsciiIdentifierContinue(startCodePoint: number) { - let codePoint = startCodePoint; + function scanIdentifier() { + let ch: number; do { - position += utf16CodeUnits(codePoint); + position++; if (eof()) { - break; + return (token = Token.Identifier); } - codePoint = input.codePointAt(position)!; - } while (isIdentifierContinue(codePoint)); + } while (isAsciiIdentifierContinue((ch = input.charCodeAt(position)))); + + if (ch > CharCode.MaxAscii) { + let cp = input.codePointAt(position)!; + if (isNonAsciiIdentifierContinue(cp)) { + return scanNonAsciiIdentifier(cp); + } + } + + return (token = Token.Identifier); + } + + function scanNonAsciiIdentifier(startCodePoint: number) { + let cp = startCodePoint; + do { + position += utf16CodeUnits(cp); + } while (!eof() && isIdentifierContinue((cp = input.codePointAt(position)!))); return (token = Token.Identifier); } diff --git a/packages/adl/compiler/types.ts b/packages/adl/compiler/types.ts index 8b95ff9c5..79d20cb61 100644 --- a/packages/adl/compiler/types.ts +++ b/packages/adl/compiler/types.ts @@ -10,6 +10,8 @@ export interface BaseType { export type Type = | ModelType | ModelTypeProperty + | EnumType + | EnumMemberType | TemplateParameterType | NamespaceType | OperationType @@ -26,6 +28,10 @@ export interface IntrinsicType extends BaseType { name: string; } +export interface ErrorType extends IntrinsicType { + name: "ErrorType"; +} + export interface ModelType extends BaseType { kind: "Model"; name: string; @@ -35,7 +41,6 @@ export interface ModelType extends BaseType { baseModels: ModelType[]; templateArguments?: Type[]; templateNode?: Node; - assignmentType?: Type; } export interface ModelTypeProperty { @@ -49,12 +54,28 @@ export interface ModelTypeProperty { optional: boolean; } +export interface EnumType extends BaseType { + kind: "Enum"; + name: string; + node: EnumStatementNode; + namespace?: NamespaceType; + members: EnumMemberType[]; +} + +export interface EnumMemberType extends BaseType { + kind: "EnumMember"; + name: string; + enum: EnumType; + node: EnumMemberNode; + value?: string | number; +} + export interface OperationType { kind: "Operation"; node: OperationStatementNode; name: string; namespace?: NamespaceType; - parameters?: ModelType; + parameters: ModelType; returnType: Type; } @@ -166,6 +187,9 @@ export enum SyntaxKind { ModelExpression, ModelProperty, ModelSpreadProperty, + EnumStatement, + EnumMember, + AliasStatement, UnionExpression, IntersectionExpression, TupleExpression, @@ -177,6 +201,8 @@ export enum SyntaxKind { TemplateParameterDeclaration, EmptyStatement, InvalidStatement, + LineComment, + BlockComment, } export interface BaseNode extends TextRange { @@ -190,12 +216,21 @@ export type Node = | ModelPropertyNode | OperationStatementNode | NamedImportNode - | ModelPropertyNode + | EnumMemberNode | ModelSpreadPropertyNode | DecoratorExpressionNode | Statement | Expression; +export type Comment = LineComment | BlockComment; + +export interface LineComment extends TextRange { + kind: SyntaxKind.LineComment; +} +export interface BlockComment extends TextRange { + kind: SyntaxKind.BlockComment; +} + export interface ADLScriptNode extends BaseNode { kind: SyntaxKind.ADLScript; statements: Statement[]; @@ -206,6 +241,7 @@ export interface ADLScriptNode extends BaseNode { namespaces: NamespaceStatementNode[]; // list of namespaces in this file (initialized during binding) locals: SymbolTable; usings: UsingStatementNode[]; + comments: Comment[]; parseDiagnostics: Diagnostic[]; } @@ -214,6 +250,8 @@ export type Statement = | ModelStatementNode | NamespaceStatementNode | UsingStatementNode + | EnumStatementNode + | AliasStatementNode | OperationStatementNode | EmptyStatementNode | InvalidStatementNode; @@ -227,9 +265,15 @@ export type Declaration = | ModelStatementNode | NamespaceStatementNode | OperationStatementNode - | TemplateParameterDeclarationNode; + | TemplateParameterDeclarationNode + | EnumStatementNode + | AliasStatementNode; -export type ScopeNode = NamespaceStatementNode | ModelStatementNode | ADLScriptNode; +export type ScopeNode = + | NamespaceStatementNode + | ModelStatementNode + | AliasStatementNode + | ADLScriptNode; export interface ImportStatementNode extends BaseNode { kind: SyntaxKind.ImportStatement; @@ -298,14 +342,35 @@ export interface OperationStatementNode extends BaseNode, DeclarationNode { export interface ModelStatementNode extends BaseNode, DeclarationNode { kind: SyntaxKind.ModelStatement; id: IdentifierNode; - properties?: (ModelPropertyNode | ModelSpreadPropertyNode)[]; + properties: (ModelPropertyNode | ModelSpreadPropertyNode)[]; heritage: ReferenceExpression[]; - assignment?: Expression; templateParameters: TemplateParameterDeclarationNode[]; locals?: SymbolTable; decorators: DecoratorExpressionNode[]; } +export interface EnumStatementNode extends BaseNode, DeclarationNode { + kind: SyntaxKind.EnumStatement; + id: IdentifierNode; + members: EnumMemberNode[]; + decorators: DecoratorExpressionNode[]; +} + +export interface EnumMemberNode extends BaseNode { + kind: SyntaxKind.EnumMember; + id: IdentifierNode | StringLiteralNode; + value?: StringLiteralNode | NumericLiteralNode; + decorators: DecoratorExpressionNode[]; +} + +export interface AliasStatementNode extends BaseNode, DeclarationNode { + kind: SyntaxKind.AliasStatement; + id: IdentifierNode; + value: Expression; + templateParameters: TemplateParameterDeclarationNode[]; + locals?: SymbolTable; +} + export interface InvalidStatementNode extends BaseNode { kind: SyntaxKind.InvalidStatement; } @@ -446,7 +511,7 @@ export interface SourceLocation extends TextRange { file: SourceFile; } -export interface Diagnostic extends SourceLocation { +export interface Diagnostic extends Partial { message: string; code?: number; severity: "warning" | "error"; @@ -460,7 +525,7 @@ export interface Dirent { export interface CompilerHost { // read a utf-8 encoded file - readFile(path: string): Promise; + readFile(path: string): Promise; // read the contents of a directory readDir(path: string): Promise; diff --git a/packages/adl/compiler/util.ts b/packages/adl/compiler/util.ts index b620078a4..6b48ab1d5 100644 --- a/packages/adl/compiler/util.ts +++ b/packages/adl/compiler/util.ts @@ -2,8 +2,15 @@ import fs from "fs"; import { readdir, readFile, realpath, stat, writeFile } from "fs/promises"; import { join, resolve } from "path"; import { fileURLToPath, pathToFileURL, URL } from "url"; -import { createDiagnostic, DiagnosticError } from "./diagnostics.js"; -import { CompilerHost, Diagnostic, Sym, SymbolTable } from "./types.js"; +import { + createDiagnostic, + createSourceFile, + DiagnosticHandler, + DiagnosticTarget, + Message, + NoTarget, +} from "./diagnostics.js"; +import { CompilerHost, SourceFile } from "./types.js"; export const adlVersion = getVersion(); @@ -13,33 +20,99 @@ function getVersion(): string { return packageJson.version; } -export function reportDuplicateSymbols(symbols: SymbolTable) { - let reported = new Set(); - let diagnostics: Diagnostic[] = []; - - for (const symbol of symbols.duplicates) { - report(symbol); - } - - if (diagnostics.length > 0) { - // TODO: We're now reporting all duplicates up to the binding of the first file - // that introduced one, but still bailing the compilation rather than - // recovering and reporting other issues including the possibility of more - // duplicates. - // - // That said, decorators are entered into the global symbol table before - // any source file is bound and therefore this will include all duplicate - // decorator implementations. - throw new DiagnosticError(diagnostics); - } - - function report(symbol: Sym) { - if (!reported.has(symbol)) { - reported.add(symbol); - const diagnostic = createDiagnostic("Duplicate name: " + symbol.name, symbol); - diagnostics.push(diagnostic); +export function deepFreeze(value: T): T { + if (Array.isArray(value)) { + value.map(deepFreeze); + } else if (typeof value === "object") { + for (const prop in value) { + deepFreeze(value[prop]); } } + + return Object.freeze(value); +} + +export function deepClone(value: T): T { + if (Array.isArray(value)) { + return value.map(deepClone) as any; + } + + if (typeof value === "object") { + const obj: any = {}; + for (const prop in value) { + obj[prop] = deepClone(value[prop]); + } + return obj; + } + + return value; +} + +export interface FileHandlingOptions { + allowFileNotFound?: boolean; + diagnosticTarget?: DiagnosticTarget; + jsDiagnosticTarget?: DiagnosticTarget; +} + +export async function doIO( + action: (path: string) => Promise, + path: string, + reportDiagnostic: DiagnosticHandler, + options?: FileHandlingOptions +): Promise { + let result; + try { + result = await action(path); + } catch (e) { + let diagnostic; + let target = options?.diagnosticTarget ?? NoTarget; + + // blame the JS file, not the ADL import statement for JS syntax errors. + if (e instanceof SyntaxError && options?.jsDiagnosticTarget) { + target = options.jsDiagnosticTarget; + } + + switch (e.code) { + case "ENOENT": + if (options?.allowFileNotFound) { + return undefined; + } + diagnostic = createDiagnostic(Message.FileNotFound, target, [path]); + break; + default: + diagnostic = createDiagnostic(e.message, target); + break; + } + + reportDiagnostic(diagnostic); + return undefined; + } + + return result; +} + +export async function loadFile( + read: (path: string) => Promise, + path: string, + load: (contents: string) => T, + reportDiagnostic: DiagnosticHandler, + options?: FileHandlingOptions +): Promise<[T | undefined, SourceFile]> { + const contents = await doIO(read, path, reportDiagnostic, options); + if (!contents) { + return [undefined, createSourceFile("", path)]; + } + + const file = createSourceFile(contents, path); + let data: T; + try { + data = load(contents); + } catch (e) { + reportDiagnostic({ message: e.message, severity: "error", file }); + return [undefined, file]; + } + + return [data, file]; } export const NodeHost: CompilerHost = { @@ -51,7 +124,7 @@ export const NodeHost: CompilerHost = { getJsImport: (path: string) => import(pathToFileURL(path).href), getLibDirs() { const rootDir = this.getExecutionRoot(); - return [join(rootDir, "lib"), join(rootDir, "dist/lib")]; + return [join(rootDir, "lib")]; }, stat(path: string) { return stat(path); diff --git a/packages/adl/config/config-loader.ts b/packages/adl/config/config-loader.ts new file mode 100644 index 000000000..054bd00f8 --- /dev/null +++ b/packages/adl/config/config-loader.ts @@ -0,0 +1,121 @@ +import { readFile } from "fs/promises"; +import { basename, extname, join } from "path"; +import { Message } from "../compiler/diagnostics.js"; +import { Diagnostic } from "../compiler/types.js"; +import { deepClone, deepFreeze, loadFile } from "../compiler/util.js"; +import { ConfigValidator } from "./config-validator.js"; +import { ADLConfig } from "./types.js"; + +const configFilenames = [".adlrc.yaml", ".adlrc.yml", ".adlrc.json", "package.json"]; +const defaultConfig: ADLConfig = deepFreeze({ + plugins: [], + diagnostics: [], + emitters: {}, + lint: { + extends: [], + rules: {}, + }, +}); + +/** + * Load ADL Configuration if present. + * @param directoryPath Current working directory where the config should be. + */ +export async function loadADLConfigInDir(directoryPath: string): Promise { + for (const filename of configFilenames) { + const filePath = join(directoryPath, filename); + const config = await loadADLConfigFile(filePath); + if ( + config.diagnostics.length === 1 && + config.diagnostics[0].code === Message.FileNotFound.code + ) { + continue; + } + return config; + } + return deepClone(defaultConfig); +} + +/** + * Load given file as an adl configuration + */ +export async function loadADLConfigFile(filePath: string): Promise { + switch (extname(filePath)) { + case ".json": + if (basename(filePath) === "package.json") { + return loadPackageJSONConfigFile(filePath); + } + return loadJSONConfigFile(filePath); + + case ".yaml": + case ".yml": + return loadYAMLConfigFile(filePath); + + default: + // This is not a diagnostic because the compiler only tries the + // well-known config file names. + throw new RangeError("Config file must have .yaml, .yml, or .json extension."); + } +} + +export async function loadPackageJSONConfigFile(filePath: string): Promise { + return await loadConfigFile(filePath, (content) => JSON.parse(content).adl ?? {}); +} + +export async function loadJSONConfigFile(filePath: string): Promise { + return await loadConfigFile(filePath, JSON.parse); +} + +export async function loadYAMLConfigFile(filePath: string): Promise { + // Lazy load. + const jsyaml = await import("js-yaml"); + return await loadConfigFile(filePath, jsyaml.load); +} + +const configValidator = new ConfigValidator(); + +async function loadConfigFile( + filePath: string, + loadData: (content: string) => any +): Promise { + const diagnostics: Diagnostic[] = []; + const reportDiagnostic = (d: Diagnostic) => diagnostics.push(d); + + let [data, file] = await loadFile( + (path) => readFile(path, "utf-8"), + filePath, + loadData, + reportDiagnostic + ); + + if (data) { + configValidator.validateConfig(data, file, reportDiagnostic); + } + + if (!data || diagnostics.length > 0) { + // NOTE: Don't trust the data if there are errors and use default + // config. Otherwise, we may return an object that does not conform to + // ADLConfig's typing. + data = deepClone(defaultConfig); + } else { + mergeDefaults(data, defaultConfig); + } + + data.filename = filePath; + data.diagnostics = diagnostics; + return data; +} + +/** + * Recursively add properties from defaults that are not present in target. + */ +function mergeDefaults(target: any, defaults: any) { + for (const prop in defaults) { + const value = target[prop]; + if (value === undefined) { + target[prop] = deepClone(defaults[prop]); + } else if (typeof value === "object" && typeof defaults[prop] === "object") { + mergeDefaults(value, defaults[prop]); + } + } +} diff --git a/packages/adl/config/config-schema.ts b/packages/adl/config/config-schema.ts new file mode 100644 index 000000000..8f79b47bb --- /dev/null +++ b/packages/adl/config/config-schema.ts @@ -0,0 +1,46 @@ +import { JSONSchemaType } from "ajv"; +import { ADLRawConfig } from "./types.js"; + +export const ADLConfigJsonSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + plugins: { + type: "array", + nullable: true, + items: { + type: "string", + }, + }, + lint: { + type: "object", + nullable: true, + additionalProperties: false, + properties: { + extends: { + type: "array", + nullable: true, + items: { + type: "string", + }, + }, + rules: { + type: "object", + nullable: true, + required: [], + additionalProperties: { + oneOf: [{ type: "string", enum: ["on", "off"] }, { type: "object" }], + }, + }, + }, + }, + emitters: { + type: "object", + nullable: true, + required: [], + additionalProperties: { + type: "boolean", + }, + }, + }, +}; diff --git a/packages/adl/config/config-validator.ts b/packages/adl/config/config-validator.ts new file mode 100644 index 000000000..030b3913c --- /dev/null +++ b/packages/adl/config/config-validator.ts @@ -0,0 +1,50 @@ +import Ajv, { ErrorObject } from "ajv"; +import { compilerAssert, DiagnosticHandler } from "../compiler/diagnostics.js"; +import { Diagnostic, SourceFile } from "../compiler/types.js"; +import { ADLConfigJsonSchema } from "./config-schema.js"; +import { ADLRawConfig } from "./types.js"; + +export class ConfigValidator { + private ajv = new Ajv({ + strict: true, + }); + + /** + * Validate the config is valid + * @param config Configuration + * @param file @optional file for errors tracing. + * @returns Validation + */ + public validateConfig( + config: ADLRawConfig, + file: SourceFile, + reportDiagnostic: DiagnosticHandler + ): void { + const validate = this.ajv.compile(ADLConfigJsonSchema); + const valid = validate(config); + compilerAssert( + !valid || !validate.errors, + "There should be errors reported if the config file is not valid." + ); + + for (const error of validate.errors ?? []) { + const diagnostic = ajvErrorToDiagnostic(error, file); + reportDiagnostic(diagnostic); + } + } +} + +const IGNORED_AJV_PARAMS = new Set(["type", "errors"]); + +function ajvErrorToDiagnostic(error: ErrorObject, file: SourceFile): Diagnostic { + const messageLines = [`Schema violation: ${error.message} (${error.instancePath || "/"})`]; + for (const [name, value] of Object.entries(error.params).filter( + ([name]) => !IGNORED_AJV_PARAMS.has(name) + )) { + const formattedValue = Array.isArray(value) ? [...new Set(value)].join(", ") : value; + messageLines.push(` ${name}: ${formattedValue}`); + } + + const message = messageLines.join("\n"); + return { message, severity: "error", file }; +} diff --git a/packages/adl/config/index.ts b/packages/adl/config/index.ts new file mode 100644 index 000000000..f66db80ca --- /dev/null +++ b/packages/adl/config/index.ts @@ -0,0 +1,2 @@ +export * from "./config-loader.js"; +export * from "./types.js"; diff --git a/packages/adl/config/types.ts b/packages/adl/config/types.ts new file mode 100644 index 000000000..16b5cf9fd --- /dev/null +++ b/packages/adl/config/types.ts @@ -0,0 +1,36 @@ +import { Diagnostic } from "../compiler"; + +/** + * Represent the normalized user configuration. + */ +export interface ADLConfig { + /** + * Path to the config file used to create this configuration. + */ + filename?: string; + + /** + * Diagnostics reported while loading the configuration + */ + diagnostics: Diagnostic[]; + + plugins: string[]; + lint: ADLLintConfig; + emitters: Record; +} + +export type RuleValue = "on" | "off" | {}; + +export interface ADLLintConfig { + extends: string[]; + rules: Record; +} + +/** + * Represent the configuration that can be provided in a config file. + */ +export interface ADLRawConfig { + plugins?: string[]; + lint?: Partial; + emitters?: Record; +} diff --git a/packages/adl/formatter/index.ts b/packages/adl/formatter/index.ts new file mode 100644 index 000000000..5e0e941b5 --- /dev/null +++ b/packages/adl/formatter/index.ts @@ -0,0 +1,33 @@ +import { Parser, SupportLanguage } from "prettier"; +import { Node } from "../compiler/types.js"; +import { parse } from "./parser.js"; +import { ADLPrinter } from "./print/index.js"; + +export const defaultOptions = {}; + +export const languages: SupportLanguage[] = [ + { + name: "ADL", + parsers: ["adl"], + extensions: [".adl"], + vscodeLanguageIds: ["adl"], + }, +]; + +const ADLParser: Parser = { + parse, + astFormat: "adl-format", + locStart(node: Node) { + return node.pos; + }, + locEnd(node: Node) { + return node.end; + }, +}; +export const parsers = { + adl: ADLParser, +}; + +export const printers = { + "adl-format": ADLPrinter, +}; diff --git a/packages/adl/formatter/parser.ts b/packages/adl/formatter/parser.ts new file mode 100644 index 000000000..9ed80591a --- /dev/null +++ b/packages/adl/formatter/parser.ts @@ -0,0 +1,27 @@ +import { Parser, ParserOptions } from "prettier"; +import { parse as adlParse } from "../compiler/parser.js"; +import { ADLScriptNode, Diagnostic } from "../compiler/types.js"; + +export function parse( + text: string, + parsers: { [parserName: string]: Parser }, + opts: ParserOptions & { parentParser?: string } +): ADLScriptNode { + const result = adlParse(text, { comments: true }); + const errors = result.parseDiagnostics.filter((x) => x.severity === "error"); + if (errors.length > 0) { + throw new PrettierParserError(errors[0]); + } + return result; +} + +export class PrettierParserError extends Error { + public loc: { start: number; end: number }; + public constructor(public readonly error: Diagnostic) { + super(error.message); + this.loc = { + start: error.pos ?? 0, + end: error.end ?? 0, + }; + } +} diff --git a/packages/adl/formatter/print/index.ts b/packages/adl/formatter/print/index.ts new file mode 100644 index 000000000..75e501440 --- /dev/null +++ b/packages/adl/formatter/print/index.ts @@ -0,0 +1,2 @@ +export * from "./printer.js"; +export * from "./types.js"; diff --git a/packages/adl/formatter/print/printer.ts b/packages/adl/formatter/print/printer.ts new file mode 100644 index 000000000..c1bbd43b2 --- /dev/null +++ b/packages/adl/formatter/print/printer.ts @@ -0,0 +1,637 @@ +import prettier, { Doc, FastPath, Printer } from "prettier"; +import { + ADLScriptNode, + AliasStatementNode, + BlockComment, + Comment, + DecoratorExpressionNode, + EnumMemberNode, + EnumStatementNode, + IntersectionExpressionNode, + ModelExpressionNode, + ModelPropertyNode, + ModelSpreadPropertyNode, + ModelStatementNode, + NamespaceStatementNode, + Node, + NumericLiteralNode, + OperationStatementNode, + Statement, + StringLiteralNode, + SyntaxKind, + TextRange, + TypeReferenceNode, + UnionExpressionNode, +} from "../../compiler/types.js"; +import { ADLPrettierOptions, DecorableNode, PrettierChildPrint } from "./types.js"; + +const { + align, + breakParent, + concat, + group, + hardline, + ifBreak, + indent, + join, + line, + softline, +} = prettier.doc.builders; + +const { isNextLineEmpty } = prettier.util; +const { replaceNewlinesWithLiterallines } = prettier.doc.utils as any; + +export const ADLPrinter: Printer = { + print: printADL, + canAttachComment: canAttachComment, + printComment: printComment, +}; + +export function printADL( + // Path to the AST node to print + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +): prettier.Doc { + const node: Node = path.getValue(); + + switch (node.kind) { + // Root + case SyntaxKind.ADLScript: + return printStatementSequence(path as FastPath, options, print, "statements"); + + // Statements + case SyntaxKind.ImportStatement: + return concat([`import "${node.path.value}";`]); + case SyntaxKind.UsingStatement: + return concat([`using "`, path.call(print, "name"), `";`]); + case SyntaxKind.OperationStatement: + return printOperationStatement(path as FastPath, options, print); + case SyntaxKind.NamespaceStatement: + return printNamespaceStatement(path as FastPath, options, print); + case SyntaxKind.ModelStatement: + return printModelStatement(path as FastPath, options, print); + case SyntaxKind.AliasStatement: + return printAliasStatement(path as FastPath, options, print); + case SyntaxKind.EnumStatement: + return printEnumStatement(path as FastPath, options, print); + + // Others. + case SyntaxKind.Identifier: + return node.sv; + case SyntaxKind.StringLiteral: + return printStringLiteral(path as FastPath, options); + case SyntaxKind.NumericLiteral: + return printNumberLiteral(path as FastPath, options); + case SyntaxKind.ModelExpression: + return printModelExpression(path as FastPath, options, print); + case SyntaxKind.ModelProperty: + return printModelProperty(path as FastPath, options, print); + case SyntaxKind.DecoratorExpression: + return printDecorator(path as FastPath, options, print); + case SyntaxKind.UnionExpression: + return printUnion(path as FastPath, options, print); + case SyntaxKind.IntersectionExpression: + return printIntersection(path as FastPath, options, print); + case SyntaxKind.EnumMember: + return printEnumMember(path as FastPath, options, print); + case SyntaxKind.TypeReference: + return printTypeReference(path as FastPath, options, print); + default: + return getRawText(node, options); + } +} + +export function printAliasStatement( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +) { + const id = path.call(print, "id"); + const template = printTemplateParameters(path, options, print, "templateParameters"); + return concat(["alias ", id, template, " = ", path.call(print, "value"), ";"]); +} + +function printTemplateParameters( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint, + propertyName: keyof T +) { + const value = path.getValue()[propertyName]; + if ((value as any).length === 0) { + return ""; + } + return concat(["<", join(", ", path.map(print, propertyName)), ">"]); +} + +export function canAttachComment(node: Node): boolean { + const kind = node.kind as SyntaxKind; + return Boolean(kind && kind !== SyntaxKind.LineComment && kind !== SyntaxKind.BlockComment); +} + +export function printComment( + commentPath: FastPath, + options: ADLPrettierOptions +): Doc { + const comment = commentPath.getValue(); + + switch (comment.kind) { + case SyntaxKind.BlockComment: + return printBlockComment(commentPath as FastPath, options); + case SyntaxKind.LineComment: + return `${options.originalText.slice(comment.pos, comment.end).trimRight()}`; + default: + throw new Error(`Not a comment: ${JSON.stringify(comment)}`); + } +} + +function printBlockComment(commentPath: FastPath, options: ADLPrettierOptions) { + const comment = commentPath.getValue(); + const rawComment = options.originalText.slice(comment.pos + 2, comment.end - 2); + + if (isIndentableBlockComment(rawComment)) { + const printed = printIndentableBlockComment(rawComment); + return printed; + } + + return concat(["/*", replaceNewlinesWithLiterallines(rawComment), "*/"]); +} + +function isIndentableBlockComment(rawComment: string): boolean { + // If the comment has multiple lines and every line starts with a star + // we can fix the indentation of each line. The stars in the `/*` and + // `*/` delimiters are not included in the comment value, so add them + // back first. + const lines = `*${rawComment}*`.split("\n"); + return lines.length > 1 && lines.every((line) => line.trim()[0] === "*"); +} + +function printIndentableBlockComment(rawComment: string): Doc { + const lines = rawComment.split("\n"); + + return concat([ + "/*", + join( + hardline, + lines.map((line, index) => + index === 0 + ? line.trimEnd() + : " " + (index < lines.length - 1 ? line.trim() : line.trimStart()) + ) + ), + "*/", + ]); +} + +export function printDecorators( + path: FastPath, + options: object, + print: PrettierChildPrint, + { tryInline }: { tryInline: boolean } +) { + const node = path.getValue(); + if (node.decorators.length === 0) { + return ""; + } + + const decorators = path.map((x) => concat([print(x as any), ifBreak(line, " ")]), "decorators"); + const shouldBreak = tryInline && decorators.length < 3 ? "" : breakParent; + + return group(concat([...decorators, shouldBreak])); +} + +export function printDecorator( + path: FastPath, + options: object, + print: PrettierChildPrint +) { + const args = printDecoratorArgs(path, options, print); + return concat(["@", path.call(print, "target"), args]); +} + +function printDecoratorArgs( + path: FastPath, + options: object, + print: PrettierChildPrint +) { + const node = path.getValue(); + if (node.arguments.length === 0) { + return ""; + } + + // So that decorator with single object arguments have ( and { hugging. + // @deco({ + // value: "foo" + // }) + const shouldHug = + node.arguments.length === 1 && node.arguments[0].kind === SyntaxKind.ModelExpression; + + if (shouldHug) { + return concat([ + "(", + join( + ", ", + path.map((arg) => concat([print(arg)]), "arguments") + ), + ")", + ]); + } + + return concat([ + "(", + group( + concat([ + indent( + join( + ", ", + path.map((arg) => concat([softline, print(arg)]), "arguments") + ) + ), + softline, + ]) + ), + ")", + ]); +} + +export function printEnumStatement( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +) { + const id = path.call(print, "id"); + const decorators = printDecorators(path, options, print, { tryInline: false }); + return concat([decorators, "enum ", id, " ", printEnumBlock(path, options, print)]); +} + +function printEnumBlock( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +) { + const node = path.getValue(); + if (node.members.length === 0) { + return "{}"; + } + + return group( + concat([ + "{", + indent( + concat([ + hardline, + join( + hardline, + path.map((x) => concat([print(x as any), ","]), "members") + ), + ]) + ), + hardline, + "}", + ]) + ); +} + +export function printEnumMember( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +) { + const node = path.getValue(); + const id = path.call(print, "id"); + const value = node.value ? concat([": ", path.call(print, "value")]) : ""; + const decorators = printDecorators(path, options, print, { tryInline: true }); + return concat([decorators, id, value]); +} + +/** + * Handle printing an intersection node. + * @example `Foo & Bar` or `{foo: string} & {bar: string}` + * + * @param path Prettier AST Path. + * @param options Prettier options + * @param print Prettier child print callback. + * @returns Prettier document. + */ +export function printIntersection( + path: FastPath, + options: object, + print: PrettierChildPrint +) { + const node = path.getValue(); + const types = path.map(print, "options"); + const result: (prettier.Doc | string)[] = []; + let wasIndented = false; + for (let i = 0; i < types.length; ++i) { + if (i === 0) { + result.push(types[i]); + } else if (isModelNode(node.options[i - 1]) && isModelNode(node.options[i])) { + // If both are objects, don't indent + result.push(concat([" & ", wasIndented ? indent(types[i]) : types[i]])); + } else if (!isModelNode(node.options[i - 1]) && !isModelNode(node.options[i])) { + // If no object is involved, go to the next line if it breaks + result.push(indent(concat([" &", line, types[i]]))); + } else { + // If you go from object to non-object or vis-versa, then inline it + if (i > 1) { + wasIndented = true; + } + result.push(" & ", i > 1 ? indent(types[i]) : types[i]); + } + } + return group(concat(result)); +} + +function isModelNode(node: Node) { + return node.kind === SyntaxKind.ModelExpression; +} + +export function printModelExpression( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +) { + const inBlock = isModelExpressionInBlock(path); + + if (inBlock) { + return group(printModelPropertiesBlock(path, options, print)); + } else { + return group( + concat([ + indent( + join( + ", ", + path.map((arg) => concat([softline, print(arg)]), "properties") + ) + ), + softline, + ]) + ); + } +} + +export function printModelStatement( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +) { + const node = path.getValue(); + const id = path.call(print, "id"); + const heritage = + node.heritage.length > 0 ? concat(["extends ", path.map(print, "heritage")[0], " "]) : ""; + + const generic = printTemplateParameters(path, options, print, "templateParameters"); + return concat([ + printDecorators(path, options, print, { tryInline: false }), + "model ", + id, + generic, + " ", + heritage, + printModelPropertiesBlock(path, options, print), + ]); +} + +function printModelPropertiesBlock( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +) { + const node = path.getNode(); + if (!node?.properties || node.properties.length === 0) { + return "{}"; + } + + const seperator = isModelAValue(path) ? "," : ";"; + + return concat([ + "{", + indent( + concat([ + hardline, + join( + hardline, + path.map((x) => concat([print(x as any), seperator]), "properties") + ), + ]) + ), + hardline, + "}", + ]); +} + +/** + * Figure out if this model is being used as a definition or value. + * @returns true if the model is used as a value(e.g. decorator value), false if it is used as a model definition. + */ +function isModelAValue(path: FastPath): boolean { + let count = 0; + let node: Node | null = path.getValue(); + do { + switch (node.kind) { + case SyntaxKind.ModelStatement: + case SyntaxKind.AliasStatement: + return false; + case SyntaxKind.DecoratorExpression: + return true; + } + } while ((node = path.getParentNode(count++))); + return true; +} + +export function printModelProperty( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +) { + const node = path.getValue(); + return concat([ + printDecorators(path as FastPath, options, print, { tryInline: true }), + path.call(print, "id"), + node.optional ? "?: " : ": ", + path.call(print, "value"), + ]); +} + +function isModelExpressionInBlock(path: FastPath) { + const parent: Node | null = path.getParentNode() as any; + + switch (parent?.kind) { + case SyntaxKind.OperationStatement: + return false; + default: + return true; + } +} + +export function printNamespaceStatement( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +) { + const printNamespace = ( + path: FastPath, + names: Doc[], + suffix: Doc | string + ) => {}; + + const printNested = (currentPath: FastPath, parentNames: Doc[]): Doc => { + const names = [...parentNames, currentPath.call(print, "name")]; + const currentNode = currentPath.getNode(); + + if ( + !Array.isArray(currentNode?.statements) && + currentNode?.statements?.kind === SyntaxKind.NamespaceStatement + ) { + return path.call((x) => printNested(x, names), "statements"); + } + + const suffix = + currentNode?.statements === undefined + ? ";" + : concat([ + " {", + indent(concat([hardline, printStatementSequence(path, options, print, "statements")])), + hardline, + "}", + ]); + return concat([ + printDecorators(path, options, print, { tryInline: false }), + `namespace `, + join(".", names), + suffix, + ]); + }; + + return printNested(path, []); +} + +export function printOperationStatement( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +) { + return concat([ + printDecorators(path as FastPath, options, print, { tryInline: true }), + `op `, + path.call(print, "id"), + "(", + path.call(print, "parameters"), + "): ", + path.call(print, "returnType"), + `;`, + ]); +} + +export function printStatementSequence( + path: FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint, + property: keyof T +) { + const node = path.getValue(); + const parts: Doc[] = []; + const lastStatement = getLastStatement((node[property] as any) as Statement[]); + + path.each((statementPath) => { + const node = path.getValue(); + + if (node.kind === SyntaxKind.EmptyStatement) { + return; + } + + const printed = print(statementPath); + parts.push(printed); + + if (node !== lastStatement) { + parts.push(hardline); + + if (isNextLineEmpty(options.originalText, node, options.locEnd)) { + parts.push(hardline); + } + } + }, property); + + return concat(parts); +} + +function getLastStatement(statements: Statement[]): Statement | undefined { + for (let i = statements.length - 1; i >= 0; i--) { + const statement = statements[i]; + if (statement.kind !== SyntaxKind.EmptyStatement) { + return statement; + } + } + return undefined; +} + +export function printUnion( + path: FastPath, + options: object, + print: PrettierChildPrint +) { + const node = path.getValue(); + const shouldHug = shouldHugType(node); + + const types = path.map((typePath) => { + let printedType: string | prettier.Doc = print(typePath); + if (!shouldHug) { + printedType = align(2, printedType); + } + return printedType; + }, "options"); + + if (shouldHug) { + return join(" | ", types); + } + + const shouldAddStartLine = true; + const code = [ + ifBreak(concat([shouldAddStartLine ? line : "", "| "]), ""), + join(concat([line, "| "]), types), + ]; + return group(indent(concat(code))); +} + +function shouldHugType(node: Node) { + if (node.kind === SyntaxKind.UnionExpression || node.kind === SyntaxKind.IntersectionExpression) { + return node.options.length < 4; + } + return false; +} + +export function printTypeReference( + path: prettier.FastPath, + options: ADLPrettierOptions, + print: PrettierChildPrint +): prettier.doc.builders.Doc { + const type = path.call(print, "target"); + const template = printTemplateParameters(path, options, print, "arguments"); + return concat([type, template]); +} + +export function printStringLiteral( + path: prettier.FastPath, + options: ADLPrettierOptions +): prettier.doc.builders.Doc { + const node = path.getValue(); + return getRawText(node, options); +} + +export function printNumberLiteral( + path: prettier.FastPath, + options: ADLPrettierOptions +): prettier.doc.builders.Doc { + const node = path.getValue(); + return getRawText(node, options); +} + +/** + * @param node Node that has postition information. + * @param options Prettier options + * @returns Raw text in the file for the given node. + */ +function getRawText(node: TextRange, options: ADLPrettierOptions) { + return options.originalText.slice(node.pos, node.end); +} diff --git a/packages/adl/formatter/print/types.ts b/packages/adl/formatter/print/types.ts new file mode 100644 index 000000000..114b7fc68 --- /dev/null +++ b/packages/adl/formatter/print/types.ts @@ -0,0 +1,10 @@ +import { Doc, FastPath, ParserOptions } from "prettier"; +import { DecoratorExpressionNode, Node } from "../../compiler/types.js"; + +export interface ADLPrettierOptions extends ParserOptions {} + +export type PrettierChildPrint = (path: FastPath, index?: number) => Doc; + +export interface DecorableNode { + decorators: DecoratorExpressionNode[]; +} diff --git a/packages/adl/lib/decorators.ts b/packages/adl/lib/decorators.ts index c8de12ba9..b58f359f2 100644 --- a/packages/adl/lib/decorators.ts +++ b/packages/adl/lib/decorators.ts @@ -1,15 +1,13 @@ -import { throwDiagnostic } from "../compiler/diagnostics.js"; import { Program } from "../compiler/program.js"; import { ModelTypeProperty, NamespaceType, Type } from "../compiler/types.js"; -const docs = new Map(); - +const docsKey = Symbol(); export function doc(program: Program, target: Type, text: string) { - docs.set(target, text); + program.stateMap(docsKey).set(target, text); } -export function getDoc(target: Type) { - return docs.get(target); +export function getDoc(program: Program, target: Type): string { + return program.stateMap(docsKey).get(target); } export function inspectType(program: Program, target: Type, text: string) { @@ -22,26 +20,33 @@ export function inspectTypeName(program: Program, target: Type, text: string) { console.log(program.checker!.getTypeName(target)); } -const intrinsics = new Set(); +const intrinsicsKey = Symbol(); export function intrinsic(program: Program, target: Type) { - intrinsics.add(target); + program.stateSet(intrinsicsKey).add(target); } -export function isIntrinsic(target: Type) { - return intrinsics.has(target); +export function isIntrinsic(program: Program, target: Type | undefined) { + if (!target) { + return false; + } + return program.stateSet(intrinsicsKey).has(target); } // Walks the assignmentType chain to find the core intrinsic type, if any -export function getIntrinsicType(target: Type | undefined): string | undefined { +export function getIntrinsicType(program: Program, target: Type | undefined): string | undefined { while (target) { if (target.kind === "Model") { - if (isIntrinsic(target)) { + if (isIntrinsic(program, target)) { return target.name; } - target = (target.assignmentType?.kind === "Model" && target.assignmentType) || undefined; + if (target.baseModels.length === 1) { + target = target.baseModels[0]; + } else { + target = undefined; + } } else if (target.kind === "ModelProperty") { - return getIntrinsicType(target.type); + return getIntrinsicType(program, target.type); } else { break; } @@ -50,185 +55,194 @@ export function getIntrinsicType(target: Type | undefined): string | undefined { return undefined; } -const numericTypes = new Set(); - +const numericTypesKey = Symbol(); export function numeric(program: Program, target: Type) { - if (!isIntrinsic(target)) { - throwDiagnostic("Cannot apply @numeric decorator to non-intrinsic type.", target); + if (!isIntrinsic(program, target)) { + program.reportDiagnostic("Cannot apply @numeric decorator to non-intrinsic type.", target); + return; } - if (target.kind === "Model") { - numericTypes.add(target.name); - } else { - throwDiagnostic("Cannot apply @numeric decorator to non-model type.", target); + if (target.kind !== "Model") { + program.reportDiagnostic("Cannot apply @numeric decorator to non-model type.", target); + return; } + program.stateSet(numericTypesKey).add(target.name); } -export function isNumericType(target: Type): boolean { - const intrinsicType = getIntrinsicType(target); - return intrinsicType !== undefined && numericTypes.has(intrinsicType); +export function isNumericType(program: Program, target: Type): boolean { + const intrinsicType = getIntrinsicType(program, target); + return intrinsicType !== undefined && program.stateSet(numericTypesKey).has(intrinsicType); } // -- @format decorator --------------------- -const formatValues = new Map(); +const formatValuesKey = Symbol(); export function format(program: Program, target: Type, format: string) { - if (target.kind === "Model" || target.kind === "ModelProperty") { - // Is it a model type that ultimately derives from 'string'? - if (getIntrinsicType(target) === "string") { - formatValues.set(target, format); - } else { - throwDiagnostic("Cannot apply @format to a non-string type", target); - } - } else { - throwDiagnostic("Cannot apply @format to anything that isn't a Model or ModelProperty", target); + if (target.kind !== "Model" && target.kind !== "ModelProperty") { + program.reportDiagnostic( + "Cannot apply @format to anything that isn't a Model or ModelProperty", + target + ); + return; } + + if (getIntrinsicType(program, target) !== "string") { + program.reportDiagnostic("Cannot apply @format to a non-string type", target); + return; + } + + program.stateMap(formatValuesKey).set(target, format); } -export function getFormat(target: Type): string | undefined { - return formatValues.get(target); +export function getFormat(program: Program, target: Type): string | undefined { + return program.stateMap(formatValuesKey).get(target); } // -- @minLength decorator --------------------- -const minLengthValues = new Map(); +const minLengthValuesKey = Symbol(); export function minLength(program: Program, target: Type, minLength: number) { - if (target.kind === "Model" || target.kind === "ModelProperty") { - // Is it a model type that ultimately derives from 'string'? - if (getIntrinsicType(target) === "string") { - minLengthValues.set(target, minLength); - } else { - throwDiagnostic("Cannot apply @minLength to a non-string type", target); - } - } else { - throwDiagnostic( + if (target.kind !== "Model" && target.kind !== "ModelProperty") { + program.reportDiagnostic( "Cannot apply @minLength to anything that isn't a Model or ModelProperty", target ); + return; } + + if (getIntrinsicType(program, target) !== "string") { + program.reportDiagnostic("Cannot apply @minLength to a non-string type", target); + return; + } + + program.stateMap(minLengthValuesKey).set(target, minLength); } -export function getMinLength(target: Type): number | undefined { - return minLengthValues.get(target); +export function getMinLength(program: Program, target: Type): number | undefined { + return program.stateMap(minLengthValuesKey).get(target); } // -- @maxLength decorator --------------------- -const maxLengthValues = new Map(); +const maxLengthValuesKey = Symbol(); export function maxLength(program: Program, target: Type, maxLength: number) { - if (target.kind === "Model" || target.kind === "ModelProperty") { - // Is it a model type that ultimately derives from 'string'? - if (getIntrinsicType(target) === "string") { - maxLengthValues.set(target, maxLength); - } else { - throwDiagnostic("Cannot apply @maxLength to a non-string type", target); - } - } else { - throwDiagnostic( + if (target.kind !== "Model" && target.kind !== "ModelProperty") { + program.reportDiagnostic( "Cannot apply @maxLength to anything that isn't a Model or ModelProperty", target ); + return; } + + if (getIntrinsicType(program, target) !== "string") { + program.reportDiagnostic("Cannot apply @maxLength to a non-string type", target); + return; + } + program.stateMap(maxLengthValuesKey).set(target, maxLength); } -export function getMaxLength(target: Type): number | undefined { - return maxLengthValues.get(target); +export function getMaxLength(program: Program, target: Type): number | undefined { + return program.stateMap(maxLengthValuesKey).get(target); } // -- @minValue decorator --------------------- -const minValues = new Map(); +const minValuesKey = Symbol(); export function minValue(program: Program, target: Type, minValue: number) { - if (target.kind === "Model" || target.kind === "ModelProperty") { - // Is it ultimately a numeric type? - if (isNumericType(target)) { - minValues.set(target, minValue); - } else { - throwDiagnostic("Cannot apply @minValue to a non-numeric type", target); - } - } else { - throwDiagnostic( + if (target.kind !== "Model" && target.kind !== "ModelProperty") { + program.reportDiagnostic( "Cannot apply @minValue to anything that isn't a Model or ModelProperty", target ); } + if (!isNumericType(program, target)) { + program.reportDiagnostic("Cannot apply @minValue to a non-numeric type", target); + return; + } + program.stateMap(minValuesKey).set(target, minValue); } -export function getMinValue(target: Type): number | undefined { - return minValues.get(target); +export function getMinValue(program: Program, target: Type): number | undefined { + return program.stateMap(minValuesKey).get(target); } // -- @maxValue decorator --------------------- -const maxValues = new Map(); +const maxValuesKey = Symbol(); export function maxValue(program: Program, target: Type, maxValue: number) { - if (target.kind === "Model" || target.kind === "ModelProperty") { - // Is it ultimately a numeric type? - if (isNumericType(target)) { - maxValues.set(target, maxValue); - } else { - throwDiagnostic("Cannot apply @maxValue to a non-numeric type", target); - } - } else { - throwDiagnostic( + if (target.kind !== "Model" && target.kind !== "ModelProperty") { + program.reportDiagnostic( "Cannot apply @maxValue to anything that isn't a Model or ModelProperty", target ); + return; } + if (!isNumericType(program, target)) { + program.reportDiagnostic("Cannot apply @maxValue to a non-numeric type", target); + return; + } + program.stateMap(maxValuesKey).set(target, maxValue); } -export function getMaxValue(target: Type): number | undefined { - return maxValues.get(target); +export function getMaxValue(program: Program, target: Type): number | undefined { + return program.stateMap(maxValuesKey).get(target); } // -- @secret decorator --------------------- -const secretTypes = new Map(); +const secretTypesKey = Symbol(); export function secret(program: Program, target: Type) { - if (target.kind === "Model") { - // Is it a model type that ultimately derives from 'string'? - if (getIntrinsicType(target) === "string") { - secretTypes.set(target, true); - } else { - throwDiagnostic("Cannot apply @secret to a non-string type", target); - } - } else { - throwDiagnostic("Cannot apply @secret to anything that isn't a Model", target); + if (target.kind !== "Model") { + program.reportDiagnostic("Cannot apply @secret to anything that isn't a Model", target); + return; } + + if (getIntrinsicType(program, target) !== "string") { + program.reportDiagnostic("Cannot apply @secret to a non-string type", target); + return; + } + program.stateMap(secretTypesKey).set(target, true); } -export function isSecret(target: Type): boolean | undefined { - return secretTypes.get(target); +export function isSecret(program: Program, target: Type): boolean | undefined { + return program.stateMap(secretTypesKey).get(target); } // -- @visibility decorator --------------------- -const visibilitySettings = new Map(); +const visibilitySettingsKey = Symbol(); export function visibility(program: Program, target: Type, ...visibilities: string[]) { - if (target.kind === "ModelProperty") { - visibilitySettings.set(target, visibilities); - } else { - throwDiagnostic("The @visibility decorator can only be applied to model properties.", target); + if (target.kind !== "ModelProperty") { + program.reportDiagnostic( + "The @visibility decorator can only be applied to model properties.", + target + ); + return; } + program.stateMap(visibilitySettingsKey).set(target, visibilities); } -export function getVisibility(target: Type): string[] | undefined { - return visibilitySettings.get(target); +export function getVisibility(program: Program, target: Type): string[] | undefined { + return program.stateMap(visibilitySettingsKey).get(target); } export function withVisibility(program: Program, target: Type, ...visibilities: string[]) { if (target.kind !== "Model") { - throwDiagnostic("The @withVisibility decorator can only be applied to models.", target); + program.reportDiagnostic( + "The @withVisibility decorator can only be applied to models.", + target + ); + return; } const filter = (_: any, prop: ModelTypeProperty) => { - const vis = getVisibility(prop); + const vis = getVisibility(program, prop); return vis !== undefined && visibilities.filter((v) => !vis.includes(v)).length > 0; }; @@ -248,56 +262,63 @@ function mapFilterOut( // -- @list decorator --------------------- -const listProperties = new Set(); +const listPropertiesKey = Symbol(); export function list(program: Program, target: Type) { - if (target.kind === "Operation" || target.kind === "ModelProperty") { - listProperties.add(target); - } else { - throwDiagnostic( - "The @list decorator can only be applied to interface or model properties.", + if (target.kind !== "Operation" && target.kind !== "ModelProperty") { + program.reportDiagnostic( + "The @list decorator can only be applied to operations or model properties.", target ); + return; } + program.stateSet(listPropertiesKey).add(target); } -export function isList(target: Type): boolean { - return listProperties.has(target); +export function isList(program: Program, target: Type): boolean { + return program.stateSet(listPropertiesKey).has(target); } // -- @tag decorator --------------------- -const tagProperties = new Map(); +const tagPropertiesKey = Symbol(); // Set a tag on an operation or namespace. There can be multiple tags on either an // operation or namespace. export function tag(program: Program, target: Type, tag: string) { - if (target.kind === "Operation" || target.kind === "Namespace") { - const tags = tagProperties.get(target); - if (tags) { - tags.push(tag); - } else { - tagProperties.set(target, [tag]); - } + if (target.kind !== "Operation" && target.kind !== "Namespace") { + program.reportDiagnostic( + "The @tag decorator can only be applied to namespaces or operations.", + target + ); + return; + } + const tags = program.stateMap(tagPropertiesKey).get(target); + if (tags) { + tags.push(tag); } else { - throwDiagnostic("The @tag decorator can only be applied to namespace or operation.", target); + program.stateMap(tagPropertiesKey).set(target, [tag]); } } // Return the tags set on an operation or namespace -export function getTags(target: Type): string[] { - return tagProperties.get(target) || []; +export function getTags(program: Program, target: Type): string[] { + return program.stateMap(tagPropertiesKey).get(target) || []; } // Merge the tags for a operation with the tags that are on the namespace it resides within. // // TODO: (JC) We'll need to update this for nested namespaces -export function getAllTags(namespace: NamespaceType, target: Type): string[] | undefined { +export function getAllTags( + program: Program, + namespace: NamespaceType, + target: Type +): string[] | undefined { const tags = new Set(); - for (const t of getTags(namespace)) { + for (const t of getTags(program, namespace)) { tags.add(t); } - for (const t of getTags(target)) { + for (const t of getTags(program, target)) { tags.add(t); } return tags.size > 0 ? Array.from(tags) : undefined; diff --git a/packages/adl/lib/main.adl b/packages/adl/lib/main.adl new file mode 100644 index 000000000..09d7c9b9c --- /dev/null +++ b/packages/adl/lib/main.adl @@ -0,0 +1,2 @@ +import "../dist/lib/decorators.js"; +import "./lib.adl"; diff --git a/packages/adl/package.json b/packages/adl/package.json index 3c32280d5..f07d8a55f 100644 --- a/packages/adl/package.json +++ b/packages/adl/package.json @@ -1,6 +1,6 @@ { "name": "@azure-tools/adl", - "version": "0.9.0", + "version": "0.11.0", "description": "ADL Compiler Preview", "author": "Microsoft Corporation", "license": "MIT", @@ -38,29 +38,36 @@ "watch": "tsc -p . --watch", "dogfood": "node scripts/dogfood.js", "test": "mocha --timeout 5000 --require source-map-support/register --ignore 'dist/test/manual/**/*.js' 'dist/test/**/*.js'", + "test-official": "mocha --forbid-only --timeout 5000 --require source-map-support/register --ignore 'dist/test/manual/**/*.js' 'dist/test/**/*.js'", "regen-samples": "node scripts/regen-samples.js", - "regen-nonascii-maps": "node scripts/regen-nonascii-maps.js", - "fuzz": "node dist/test/manual/fuzz.js" + "regen-nonascii": "node scripts/regen-nonascii.js", + "fuzz": "node dist/test/manual/fuzz.js run" }, "dependencies": { "autorest": "~3.0.6335", + "ajv": "~8.4.0", + "glob": "~7.1.6", + "js-yaml": "~4.1.0", "mkdirp": "~1.0.4", + "prettier": "~2.2.1", "resolve": "~1.20.0", - "vscode-languageserver": "~7.0.0", "vscode-languageserver-textdocument": "~1.0.1", + "vscode-languageserver": "~7.0.0", "yargs": "~16.2.0" }, "devDependencies": { + "@types/glob": "~7.1.3", + "@types/js-yaml": "~4.0.1", "@types/mkdirp": "~1.0.1", "@types/mocha": "~7.0.2", "@types/node": "~14.0.27", + "@types/prettier": "^2.0.2", "@types/resolve": "~1.20.0", "@types/yargs": "~15.0.12", "grammarkdown": "~3.1.2", "mocha": "~8.3.2", - "prettier": "~2.2.1", "prettier-plugin-organize-imports": "~1.1.1", "source-map-support": "~0.5.19", - "typescript": "~4.2.4" + "typescript": "~4.3.2" } } diff --git a/packages/adl/scripts/regen-nonascii-maps.js b/packages/adl/scripts/regen-nonascii.js similarity index 77% rename from packages/adl/scripts/regen-nonascii-maps.js rename to packages/adl/scripts/regen-nonascii.js index 94a70e630..65ba2d62c 100644 --- a/packages/adl/scripts/regen-nonascii-maps.js +++ b/packages/adl/scripts/regen-nonascii.js @@ -9,8 +9,11 @@ import { fileURLToPath } from "url"; const MIN_NONASCII_CODEPOINT = 0x80; const MAX_UNICODE_CODEPOINT = 0x10ffff; -const isStartRegex = /[\p{ID_Start}\u{2118}\u{212E}\u{309B}\u{309C}]/u; -const isContinueRegex = /[\p{ID_Continue}\u{00B7}\u{0387}\u{19DA}\u{1369}\u{136A}\u{136B}\u{136C}\u{136D}\u{136E}\u{136F}\u{1370}\u{1371}]/u; +// Includes Other_ID_Start +const isStartRegex = /[\p{ID_Start}]/u; + +// Includes Other_ID_Start and Other_ID_Continue +const isContinueRegex = /[\p{ID_Continue}\u{200c}\u{200d}]/u; function isStart(c) { return isStartRegex.test(c); @@ -50,15 +53,19 @@ const src = `// // // Based on: // - http://www.unicode.org/reports/tr31/ -// - https://www.ecma-international.org/ecma-262/6.0/#sec-names-and-keywords +// - https://www.ecma-international.org/ecma-262/11.0/#sec-names-and-keywords // // ADL's identifier naming rules are currently the same as JavaScript's. +// /** * @internal * * Map of non-ascii characters that are valid at the start of an identifier. * Each pair of numbers represents an inclusive range of code points. + * + * Corresponds to code points outside the ASCII range with property ID_Start or + * Other_ID_Start. */ // prettier-ignore export const nonAsciiIdentifierStartMap: readonly number[] = [ @@ -68,8 +75,12 @@ ${formatPairs(startMap)} /** * @internal * - * Map of non-ascii chacters that are valid after the first character in and identifier. - * Each pair of numbers represents an inclusive range of code points. + * Map of non-ascii chacters that are valid after the first character in and + * identifier. Each pair of numbers represents an inclusive range of code + * points. + * + * Corresponds to code points outside the ASCII range with property ID_Continue, + * Other_ID_Start, or Other_ID_Continue, plus ZWNJ and ZWJ. */ //prettier-ignore export const nonAsciiIdentifierContinueMap: readonly number[] = [ @@ -77,5 +88,5 @@ ${formatPairs(continueMap)} ]; `; -const file = resolve(fileURLToPath(import.meta.url), "../../compiler/non-ascii-maps.ts"); +const file = resolve(fileURLToPath(import.meta.url), "../../compiler/nonascii.ts"); writeFileSync(file, src); diff --git a/packages/adl/server/server.ts b/packages/adl/server/server.ts index 3d56f8481..121bec3b8 100644 --- a/packages/adl/server/server.ts +++ b/packages/adl/server/server.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "url"; import { TextDocument } from "vscode-languageserver-textdocument"; import { Connection, @@ -21,7 +22,8 @@ let documents: TextDocuments; main(); function main() { - log(`** ADL Language Server v${adlVersion} **`); + log(`ADL language server v${adlVersion}\n`); + log(`Module: ${fileURLToPath(import.meta.url)}`); log(`Command Line: ${JSON.stringify(process.argv)}`); connection = createConnection(ProposedFeatures.all); @@ -48,11 +50,11 @@ function checkChange(change: TextDocumentChangeEvent) { const diagnostics: Diagnostic[] = []; for (const each of parseDiagnostics) { - const start = document.positionAt(each.pos); - const end = document.positionAt(each.end); + const start = document.positionAt(each.pos ?? 0); + const end = document.positionAt(each.end ?? 0); const range = Range.create(start, end); const severity = convertSeverity(each.severity); - const diagnostic = Diagnostic.create(range, each.message, severity, "ADL"); + const diagnostic = Diagnostic.create(range, each.message, severity, each.code, "ADL"); diagnostics.push(diagnostic); } diff --git a/packages/adl/test/checker/alias.ts b/packages/adl/test/checker/alias.ts new file mode 100644 index 000000000..d68a3d65a --- /dev/null +++ b/packages/adl/test/checker/alias.ts @@ -0,0 +1,165 @@ +import { ok, strictEqual } from "assert"; +import { ModelType, UnionType } from "../../compiler/types.js"; +import { createTestHost, TestHost } from "../test-host.js"; + +describe("adl: aliases", () => { + let testHost: TestHost; + + beforeEach(async () => { + testHost = await createTestHost(); + }); + + it("can alias a union expression", async () => { + testHost.addAdlFile( + "main.adl", + ` + alias Foo = int32 | string; + alias Bar = "hi" | 10; + alias FooBar = Foo | Bar; + + @test model A { + prop: FooBar + } + ` + ); + const { A } = (await testHost.compile("./")) as { + A: ModelType; + }; + + const propType: UnionType = A.properties.get("prop")!.type as UnionType; + strictEqual(propType.kind, "Union"); + strictEqual(propType.options.length, 4); + strictEqual(propType.options[0].kind, "Model"); + strictEqual(propType.options[1].kind, "Model"); + strictEqual(propType.options[2].kind, "String"); + strictEqual(propType.options[3].kind, "Number"); + }); + + it("can alias a deep union expression", async () => { + testHost.addAdlFile( + "main.adl", + ` + alias Foo = int32 | string; + alias Bar = "hi" | 10; + alias Baz = Foo | Bar; + alias FooBar = Baz | "bye"; + + @test model A { + prop: FooBar + } + ` + ); + const { A } = (await testHost.compile("./")) as { + A: ModelType; + }; + + const propType: UnionType = A.properties.get("prop")!.type as UnionType; + strictEqual(propType.kind, "Union"); + strictEqual(propType.options.length, 5); + strictEqual(propType.options[0].kind, "Model"); + strictEqual(propType.options[1].kind, "Model"); + strictEqual(propType.options[2].kind, "String"); + strictEqual(propType.options[3].kind, "Number"); + strictEqual(propType.options[4].kind, "String"); + }); + + it("can alias a union expression with parameters", async () => { + testHost.addAdlFile( + "main.adl", + ` + alias Foo = int32 | T; + + @test model A { + prop: Foo<"hi"> + } + ` + ); + + const { A } = (await testHost.compile("./")) as { + A: ModelType; + }; + + const propType: UnionType = A.properties.get("prop")!.type as UnionType; + strictEqual(propType.kind, "Union"); + strictEqual(propType.options.length, 2); + strictEqual(propType.options[0].kind, "Model"); + strictEqual(propType.options[1].kind, "String"); + }); + + it("can alias a deep union expression with parameters", async () => { + testHost.addAdlFile( + "main.adl", + ` + alias Foo = int32 | T; + alias Bar = Foo | Foo; + + @test model A { + prop: Bar<"hi", 42> + } + ` + ); + + const { A } = (await testHost.compile("./")) as { + A: ModelType; + }; + + const propType: UnionType = A.properties.get("prop")!.type as UnionType; + strictEqual(propType.kind, "Union"); + strictEqual(propType.options.length, 4); + strictEqual(propType.options[0].kind, "Model"); + strictEqual(propType.options[1].kind, "String"); + strictEqual(propType.options[2].kind, "Model"); + strictEqual(propType.options[3].kind, "Number"); + }); + + it("can alias an intersection expression", async () => { + testHost.addAdlFile( + "main.adl", + ` + alias Foo = {a: string} & {b: string}; + alias Bar = {c: string} & {d: string}; + alias FooBar = Foo & Bar; + + @test model A { + prop: FooBar + } + ` + ); + const { A } = (await testHost.compile("./")) as { + A: ModelType; + }; + + const propType: ModelType = A.properties.get("prop")!.type as ModelType; + strictEqual(propType.kind, "Model"); + strictEqual(propType.properties.size, 4); + ok(propType.properties.has("a")); + ok(propType.properties.has("b")); + ok(propType.properties.has("c")); + ok(propType.properties.has("d")); + }); + + it("can be used like any model", async () => { + testHost.addAdlFile( + "main.adl", + ` + @test model Test { a: string }; + + alias Alias = Test; + + @test model A extends Alias { }; + @test model B { ... Alias }; + @test model C { c: Alias }; + ` + ); + const { Test, A, B, C } = (await testHost.compile("./")) as { + Test: ModelType; + A: ModelType; + B: ModelType; + C: ModelType; + }; + + strictEqual(A.baseModels[0], Test); + ok(B.properties.has("a")); + strictEqual(C.properties.get("c")!.type, Test); + }); +}); diff --git a/packages/adl/test/checker/check-parse-errors.ts b/packages/adl/test/checker/check-parse-errors.ts new file mode 100644 index 000000000..4c3403147 --- /dev/null +++ b/packages/adl/test/checker/check-parse-errors.ts @@ -0,0 +1,27 @@ +import { match, strictEqual } from "assert"; +import { createTestHost, TestHost } from "../test-host.js"; + +describe("adl: semantic checks on source with parse errors", () => { + let testHost: TestHost; + + beforeEach(async () => { + testHost = await createTestHost(); + }); + + it("reports semantic errors in addition to parse errors", async () => { + testHost.addAdlFile( + "main.adl", + `model M extends Q { + a: B; + a: C; + ` + ); + + const diagnostics = await testHost.diagnose("/"); + strictEqual(diagnostics.length, 4); + match(diagnostics[0].message, /Property expected/); + match(diagnostics[1].message, /Unknown identifier Q/); + match(diagnostics[2].message, /Unknown identifier B/); + match(diagnostics[3].message, /Unknown identifier C/); + }); +}); diff --git a/packages/adl/test/checker/duplicate-ids.ts b/packages/adl/test/checker/duplicate-ids.ts index 33ae8f0ed..393dcc6cb 100644 --- a/packages/adl/test/checker/duplicate-ids.ts +++ b/packages/adl/test/checker/duplicate-ids.ts @@ -1,39 +1,42 @@ -import { rejects } from "assert"; +import { match, strictEqual } from "assert"; +import { Diagnostic } from "../../compiler/types.js"; import { createTestHost, TestHost } from "../test-host.js"; -describe("duplicate declarations", () => { +describe("adl: duplicate declarations", () => { let testHost: TestHost; beforeEach(async () => { testHost = await createTestHost(); }); - it("throws for duplicate template parameters", async () => { + it("reports duplicate template parameters", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` model A { } ` ); - await rejects(testHost.compile("/")); + const diagnostics = await testHost.diagnose("/"); + assertDuplicates(diagnostics); }); - it("throws for duplicate model declarations in global scope", async () => { + it("reports duplicate model declarations in global scope", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` model A { } model A { } ` ); - await rejects(testHost.compile("/")); + const diagnostics = await testHost.diagnose("/"); + assertDuplicates(diagnostics); }); - it("throws for duplicate model declarations in a single namespace", async () => { + it("reports duplicate model declarations in a single namespace", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` namespace Foo; model A { } @@ -41,12 +44,13 @@ describe("duplicate declarations", () => { ` ); - await rejects(testHost.compile("/")); + const diagnostics = await testHost.diagnose("/"); + assertDuplicates(diagnostics); }); - it("throws for duplicate model declarations in across multiple namespaces", async () => { + it("reports duplicate model declarations across multiple namespaces", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` namespace N { model A { }; @@ -58,10 +62,18 @@ describe("duplicate declarations", () => { ` ); - await rejects(testHost.compile("/")); + const diagnostics = await testHost.diagnose("/"); + assertDuplicates(diagnostics); }); - it("throws for duplicate model declarations in across multiple files and namespaces", async () => { + it("reports duplicate model declarations across multiple files and namespaces", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -79,6 +91,14 @@ describe("duplicate declarations", () => { ` ); - await rejects(testHost.compile("/")); + const diagnostics = await testHost.diagnose("/"); + assertDuplicates(diagnostics); }); }); + +function assertDuplicates(diagnostics: readonly Diagnostic[]) { + strictEqual(diagnostics.length, 2); + for (const diagnostic of diagnostics) { + match(diagnostic.message, /Duplicate name/); + } +} diff --git a/packages/adl/test/checker/enum.ts b/packages/adl/test/checker/enum.ts new file mode 100644 index 000000000..243a9d9b3 --- /dev/null +++ b/packages/adl/test/checker/enum.ts @@ -0,0 +1,76 @@ +import { ok, strictEqual } from "assert"; +import { EnumMemberType, EnumType, ModelType } from "../../compiler/types.js"; +import { createTestHost, TestHost } from "../test-host.js"; + +describe("adl: enums", () => { + let testHost: TestHost; + + beforeEach(async () => { + testHost = await createTestHost(); + }); + + it("can be valueless", async () => { + testHost.addAdlFile( + "main.adl", + ` + @test enum E { + A, B, C + } + ` + ); + + const { E } = (await testHost.compile("./")) as { + E: EnumType; + }; + + ok(E); + ok(!E.members[0].value); + ok(!E.members[1].value); + ok(!E.members[2].value); + }); + + it("can have values", async () => { + testHost.addAdlFile( + "main.adl", + ` + @test enum E { + @test("A") A: "a"; + @test("B") B: "b"; + @test("C") C: "c"; + } + ` + ); + + const { E, A, B, C } = (await testHost.compile("./")) as { + E: EnumType; + A: EnumMemberType; + B: EnumMemberType; + C: EnumMemberType; + }; + + ok(E); + strictEqual(A.value, "a"); + strictEqual(B.value, "b"); + strictEqual(C.value, "c"); + }); + + it("can be a model property", async () => { + testHost.addAdlFile( + "main.adl", + ` + namespace Foo; + enum E { A, B, C } + @test model Foo { + prop: E; + } + ` + ); + + const { Foo } = (await testHost.compile("./")) as { + Foo: ModelType; + }; + + ok(Foo); + strictEqual(Foo.properties.get("prop")!.type.kind, "Enum"); + }); +}); diff --git a/packages/adl/test/checker/global-namespace.ts b/packages/adl/test/checker/global-namespace.ts new file mode 100644 index 000000000..f8a0aed08 --- /dev/null +++ b/packages/adl/test/checker/global-namespace.ts @@ -0,0 +1,101 @@ +import { assert } from "console"; +import { createTestHost, TestHost } from "../test-host.js"; + +describe("adl: global namespace", () => { + let testHost: TestHost; + + beforeEach(async () => { + testHost = await createTestHost(); + }); + + describe("it adds top level entities to the global namespace", () => { + it("adds top-level namespaces", async () => { + testHost.addAdlFile("main.adl", `namespace Foo {}`); + + await testHost.compile("./"); + + const globalNamespaceType = testHost.program.checker?.getGlobalNamespaceType(); + assert( + globalNamespaceType?.namespaces.get("Foo"), + "Namespace Foo was added to global namespace type" + ); + }); + + it("adds top-level models", async () => { + testHost.addAdlFile("main.adl", `model MyModel {}`); + + await testHost.compile("./"); + + const globalNamespaceType = testHost.program.checker?.getGlobalNamespaceType(); + assert( + globalNamespaceType?.models.get("MyModel"), + "model MyModel was added to global namespace type" + ); + }); + + it("adds top-level oeprations", async () => { + testHost.addAdlFile("main.adl", `op myOperation(): string;`); + + await testHost.compile("./"); + + const globalNamespaceType = testHost.program.checker?.getGlobalNamespaceType(); + assert( + globalNamespaceType?.operations.get("myOperation"), + "operation myOperation was added to global namespace type" + ); + }); + }); + + describe("it adds top level entities used in other files to the global namespace", () => { + beforeEach(() => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + + model Base {} + ` + ); + }); + + it("adds top-level namespaces", async () => { + testHost.addAdlFile("a.adl", `namespace Foo {}`); + + await testHost.compile("./"); + + const globalNamespaceType = testHost.program.checker?.getGlobalNamespaceType(); + assert( + globalNamespaceType?.namespaces.get("Foo"), + "Namespace Foo was added to global namespace type" + ); + assert( + globalNamespaceType?.namespaces.get("Base"), + "Should still reference main file top-level entities" + ); + }); + + it("adds top-level models", async () => { + testHost.addAdlFile("a.adl", `model MyModel {}`); + + await testHost.compile("./"); + + const globalNamespaceType = testHost.program.checker?.getGlobalNamespaceType(); + assert( + globalNamespaceType?.models.get("MyModel"), + "model MyModel was added to global namespace type" + ); + }); + + it("adds top-level oeprations", async () => { + testHost.addAdlFile("a.adl", `op myOperation(): string;`); + + await testHost.compile("./"); + + const globalNamespaceType = testHost.program.checker?.getGlobalNamespaceType(); + assert( + globalNamespaceType?.operations.get("myOperation"), + "operation myOperation was added to global namespace type" + ); + }); + }); +}); diff --git a/packages/adl/test/checker/loader.ts b/packages/adl/test/checker/loader.ts index 3606dc6c8..4104649e9 100644 --- a/packages/adl/test/checker/loader.ts +++ b/packages/adl/test/checker/loader.ts @@ -1,6 +1,6 @@ import { createTestHost, TestHost } from "../test-host.js"; -describe("loader", () => { +describe("adl: loader", () => { let testHost: TestHost; beforeEach(async () => { @@ -10,7 +10,7 @@ describe("loader", () => { it("loads ADL and JS files", async () => { testHost.addJsFile("blue.js", { blue() {} }); testHost.addAdlFile( - "a.adl", + "main.adl", ` import "./b.adl"; import "./blue.js"; @@ -39,6 +39,6 @@ describe("loader", () => { ` ); - await testHost.compile("a.adl"); + await testHost.compile("main.adl"); }); }); diff --git a/packages/adl/test/checker/namespaces.ts b/packages/adl/test/checker/namespaces.ts index 5ddad4a07..008430631 100644 --- a/packages/adl/test/checker/namespaces.ts +++ b/packages/adl/test/checker/namespaces.ts @@ -2,7 +2,7 @@ import { ok, strictEqual } from "assert"; import { ModelType, NamespaceType, Type } from "../../compiler/types.js"; import { createTestHost, TestHost } from "../test-host.js"; -describe("namespaces with blocks", () => { +describe("adl: namespaces with blocks", () => { const blues = new WeakSet(); function blue(_: any, target: Type) { blues.add(target); @@ -17,8 +17,9 @@ describe("namespaces with blocks", () => { it("can be decorated", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` + import "./blue.js"; @blue @test namespace Z.Q; @blue @test namespace N { } @blue @test namespace X.Y { } @@ -37,7 +38,7 @@ describe("namespaces with blocks", () => { it("merges like namespaces", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` @test namespace N { @test model X { x: string } } @@ -58,6 +59,14 @@ describe("namespaces with blocks", () => { }); it("merges like namespaces across files", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + import "./c.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -90,6 +99,14 @@ describe("namespaces with blocks", () => { }); it("merges sub-namespaces across files", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + import "./c.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -117,7 +134,7 @@ describe("namespaces with blocks", () => { it("can see things in outer scope same file", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` model A { } namespace N { model B extends A { } } @@ -127,6 +144,14 @@ describe("namespaces with blocks", () => { }); it("can see things in outer scope cross file", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + import "./c.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -150,9 +175,30 @@ describe("namespaces with blocks", () => { ); await testHost.compile("./"); }); + + it("accumulates declarations inside of it", async () => { + testHost.addAdlFile( + "main.adl", + ` + @test namespace Foo { + namespace Bar { }; + op Baz(): {}; + model Qux { }; + } + ` + ); + + const { Foo } = (await testHost.compile("./")) as { + Foo: NamespaceType; + }; + + strictEqual(Foo.operations.size, 1); + strictEqual(Foo.models.size, 1); + strictEqual(Foo.namespaces.size, 1); + }); }); -describe("blockless namespaces", () => { +describe("adl: blockless namespaces", () => { const blues = new WeakSet(); function blue(_: any, target: Type) { blues.add(target); @@ -166,6 +212,14 @@ describe("blockless namespaces", () => { }); it("merges properly with other namespaces", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + import "./c.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -194,7 +248,7 @@ describe("blockless namespaces", () => { it("does lookup correctly", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` namespace Repro; model Yo { @@ -214,7 +268,7 @@ describe("blockless namespaces", () => { it("does lookup correctly with nested namespaces", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` namespace Repro; model Yo { @@ -244,7 +298,7 @@ describe("blockless namespaces", () => { it("binds correctly", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` namespace N.M; model A { } @@ -266,7 +320,7 @@ describe("blockless namespaces", () => { it("works with blockful namespaces", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` @test namespace N; @@ -293,6 +347,13 @@ describe("blockless namespaces", () => { }); it("works with nested blockless and blockfull namespaces", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -326,7 +387,7 @@ describe("blockless namespaces", () => { "a.adl", ` import "./b.adl"; - model M = N.X; + model M {x: N.X } ` ); testHost.addAdlFile( @@ -339,4 +400,53 @@ describe("blockless namespaces", () => { await testHost.compile("/a.adl"); }); + + it("accumulates declarations inside of it", async () => { + testHost.addAdlFile( + "a.adl", + ` + @test namespace Foo; + namespace Bar { }; + op Baz(): {}; + model Qux { }; + ` + ); + + const { Foo } = (await testHost.compile("/a.adl")) as { + Foo: NamespaceType; + }; + + strictEqual(Foo.operations.size, 1); + strictEqual(Foo.models.size, 1); + strictEqual(Foo.namespaces.size, 1); + }); +}); + +describe("adl: namespace type name", () => { + let testHost: TestHost; + + beforeEach(async () => { + testHost = await createTestHost(); + }); + + it("prefix with the namespace of the entity", async () => { + testHost.addAdlFile( + "a.adl", + ` + namespace Foo; + + @test() + model Model1 {} + + namespace Other.Bar { + @test() + model Model2 {} + } + ` + ); + + const { Model1, Model2 } = await testHost.compile("/a.adl"); + strictEqual(testHost.program.checker?.getTypeName(Model1), "Foo.Model1"); + strictEqual(testHost.program.checker?.getTypeName(Model2), "Foo.Other.Bar.Model2"); + }); }); diff --git a/packages/adl/test/checker/spread.ts b/packages/adl/test/checker/spread.ts index 83991a1ef..f0e4a86a1 100644 --- a/packages/adl/test/checker/spread.ts +++ b/packages/adl/test/checker/spread.ts @@ -2,7 +2,7 @@ import { ok, strictEqual } from "assert"; import { ModelType, Type } from "../../compiler/types.js"; import { createTestHost, TestHost } from "../test-host.js"; -describe("spread", () => { +describe("adl: spread", () => { const blues = new WeakSet(); function blue(_: any, target: Type) { blues.add(target); @@ -17,8 +17,9 @@ describe("spread", () => { it("clones decorated properties", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` + import "./blue.js"; model A { @blue foo: string } model B { @blue bar: string } @test model C { ... A, ... B } diff --git a/packages/adl/test/checker/using.ts b/packages/adl/test/checker/using.ts index 050e23a85..9d6b69944 100644 --- a/packages/adl/test/checker/using.ts +++ b/packages/adl/test/checker/using.ts @@ -2,7 +2,7 @@ import { rejects, strictEqual } from "assert"; import { ModelType } from "../../compiler/types"; import { createTestHost, TestHost } from "../test-host.js"; -describe("using statements", () => { +describe("adl: using statements", () => { let testHost: TestHost; beforeEach(async () => { @@ -10,6 +10,13 @@ describe("using statements", () => { }); it("works in global scope", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -33,6 +40,13 @@ describe("using statements", () => { }); it("works in namespaces", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -57,6 +71,13 @@ describe("using statements", () => { }); it("works with dotted namespaces", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -80,6 +101,13 @@ describe("using statements", () => { }); it("throws errors for duplicate imported usings", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -101,6 +129,13 @@ describe("using statements", () => { }); it("throws errors for different usings with the same bindings", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -126,6 +161,13 @@ describe("using statements", () => { }); it("resolves 'local' decls over usings", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -151,6 +193,13 @@ describe("using statements", () => { }); it("usings are local to a file", async () => { + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + ` + ); testHost.addAdlFile( "a.adl", ` @@ -167,7 +216,7 @@ describe("using statements", () => { } namespace M { - model X = A; + model X { a: A }; } ` ); diff --git a/packages/adl/test/config/config.ts b/packages/adl/test/config/config.ts new file mode 100644 index 000000000..d90548b29 --- /dev/null +++ b/packages/adl/test/config/config.ts @@ -0,0 +1,145 @@ +import { deepStrictEqual } from "assert"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; +import { createSourceFile } from "../../compiler/diagnostics.js"; +import { Diagnostic } from "../../compiler/types.js"; +import { ConfigValidator } from "../../config/config-validator.js"; +import { ADLRawConfig, loadADLConfigInDir } from "../../config/index.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe("adl: config file loading", () => { + describe("file discovery", async () => { + const scenarioRoot = resolve(__dirname, "../../../test/config/scenarios"); + const loadTestConfig = async (folderName: string) => { + const folderPath = join(scenarioRoot, folderName); + const { filename, ...config } = await loadADLConfigInDir(folderPath); + return config; + }; + + const assertLoadFromFolder = async (folderName: string) => { + const config = await loadTestConfig(folderName); + deepStrictEqual(config, { + plugins: ["foo"], + diagnostics: [], + emitters: { + "foo:openapi": true, + }, + lint: { + extends: [], + rules: { + "some-rule": "on", + }, + }, + }); + }; + + it("loads yaml config file", async () => { + await assertLoadFromFolder("yaml"); + }); + + it("loads json config file", async () => { + await assertLoadFromFolder("json"); + }); + + it("loads from adl section in package.json config file", async () => { + await assertLoadFromFolder("package-json"); + }); + + it("loads empty config if it can't find any config files", async () => { + const config = await loadTestConfig("empty"); + deepStrictEqual(config, { + plugins: [], + diagnostics: [], + emitters: {}, + lint: { + extends: [], + rules: {}, + }, + }); + }); + + it("only loads first config file found", async () => { + // Should load .adlrc.yaml and MOT load .adlrc.json here + await assertLoadFromFolder("yaml-json"); + }); + + it("deep clones defaults when not found", async () => { + let config = await loadTestConfig("empty"); + config.plugins.push("x"); + config.emitters["x"] = true; + config.lint.extends.push("x"); + config.lint.rules["x"] = "off"; + + config = await loadTestConfig("empty"); + deepStrictEqual(config, { + plugins: [], + diagnostics: [], + emitters: {}, + lint: { + extends: [], + rules: {}, + }, + }); + }); + + it("deep clones defaults when found", async () => { + let config = await loadTestConfig("yaml"); + config.plugins.push("x"); + config.emitters["x"] = true; + config.lint.extends.push("x"); + config.lint.rules["x"] = "off"; + + config = await loadTestConfig("yaml"); + deepStrictEqual(config, { + plugins: ["foo"], + diagnostics: [], + emitters: { + "foo:openapi": true, + }, + lint: { + extends: [], + rules: { + "some-rule": "on", + }, + }, + }); + }); + }); + + describe("validation", () => { + const validator = new ConfigValidator(); + const file = createSourceFile("", ""); + + function validate(data: ADLRawConfig) { + const diagnostics: Diagnostic[] = []; + validator.validateConfig(data, file, (d) => diagnostics.push(d)); + return diagnostics; + } + + it("does not allow additional properties", () => { + deepStrictEqual(validate({ someCustomProp: true } as any), [ + { + file, + severity: "error", + message: + "Schema violation: must NOT have additional properties (/)\n additionalProperty: someCustomProp", + }, + ]); + }); + + it("fails if passing the wrong type", () => { + deepStrictEqual(validate({ emitters: true } as any), [ + { + file, + severity: "error", + message: "Schema violation: must be object (/emitters)", + }, + ]); + }); + + it("succeeds if config is valid", () => { + deepStrictEqual(validate({ lint: { rules: { foo: "on" } } }), []); + }); + }); +}); diff --git a/packages/adl/test/config/scenarios/empty/.keep b/packages/adl/test/config/scenarios/empty/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/adl/test/config/scenarios/json/.adlrc.json b/packages/adl/test/config/scenarios/json/.adlrc.json new file mode 100644 index 000000000..1dc207a32 --- /dev/null +++ b/packages/adl/test/config/scenarios/json/.adlrc.json @@ -0,0 +1,11 @@ +{ + "plugins": ["foo"], + "emitters": { + "foo:openapi": true + }, + "lint": { + "rules": { + "some-rule": "on" + } + } +} diff --git a/packages/adl/test/config/scenarios/package-json/package.json b/packages/adl/test/config/scenarios/package-json/package.json new file mode 100644 index 000000000..a569bcab5 --- /dev/null +++ b/packages/adl/test/config/scenarios/package-json/package.json @@ -0,0 +1,15 @@ +{ + "adl": { + "plugins": [ + "foo" + ], + "emitters": { + "foo:openapi": true + }, + "lint": { + "rules": { + "some-rule": "on" + } + } + } +} diff --git a/packages/adl/test/config/scenarios/yaml-json/.adlrc.json b/packages/adl/test/config/scenarios/yaml-json/.adlrc.json new file mode 100644 index 000000000..e93e1ffe4 --- /dev/null +++ b/packages/adl/test/config/scenarios/yaml-json/.adlrc.json @@ -0,0 +1,6 @@ +{ + "plugins": ["otherconfig"], + "emitters": { + "foo:other": true + } +} diff --git a/packages/adl/test/config/scenarios/yaml-json/.adlrc.yaml b/packages/adl/test/config/scenarios/yaml-json/.adlrc.yaml new file mode 100644 index 000000000..6a0d62b9a --- /dev/null +++ b/packages/adl/test/config/scenarios/yaml-json/.adlrc.yaml @@ -0,0 +1,10 @@ +plugins: + - foo + +# This has comments +emitters: + foo:openapi: true + +lint: + rules: + "some-rule": "on" diff --git a/packages/adl/test/config/scenarios/yaml/.adlrc.yaml b/packages/adl/test/config/scenarios/yaml/.adlrc.yaml new file mode 100644 index 000000000..cf462ad4d --- /dev/null +++ b/packages/adl/test/config/scenarios/yaml/.adlrc.yaml @@ -0,0 +1,10 @@ +plugins: + - foo + +# This has comments +emitters: + foo:openapi: true + +lint: + rules: + some-rule: on diff --git a/packages/adl/test/decorators/range-limits.ts b/packages/adl/test/decorators/range-limits.ts index 6ada04b53..034fbc824 100644 --- a/packages/adl/test/decorators/range-limits.ts +++ b/packages/adl/test/decorators/range-limits.ts @@ -3,7 +3,7 @@ import { ModelType } from "../../compiler/types.js"; import { getMaxValue, getMinValue } from "../../lib/decorators.js"; import { createTestHost, TestHost } from "../test-host.js"; -describe("range limiting decorators", () => { +describe("adl: range limiting decorators", () => { let testHost: TestHost; beforeEach(async () => { @@ -12,7 +12,7 @@ describe("range limiting decorators", () => { it("applies @minimum and @maximum decorators", async () => { testHost.addAdlFile( - "a.adl", + "main.adl", ` @test model A { @minValue(15) foo: int32; @maxValue(55) boo: float32; } @test model B { @maxValue(20) bar: int64; @minValue(23) car: float64; } @@ -21,9 +21,9 @@ describe("range limiting decorators", () => { const { A, B } = (await testHost.compile("./")) as { A: ModelType; B: ModelType }; - strictEqual(getMinValue(A.properties.get("foo")!), 15); - strictEqual(getMaxValue(A.properties.get("boo")!), 55); - strictEqual(getMaxValue(B.properties.get("bar")!), 20); - strictEqual(getMinValue(B.properties.get("car")!), 23); + strictEqual(getMinValue(testHost.program, A.properties.get("foo")!), 15); + strictEqual(getMaxValue(testHost.program, A.properties.get("boo")!), 55); + strictEqual(getMaxValue(testHost.program, B.properties.get("bar")!), 20); + strictEqual(getMinValue(testHost.program, B.properties.get("car")!), 23); }); }); diff --git a/packages/adl/test/formatter/formatter.ts b/packages/adl/test/formatter/formatter.ts new file mode 100644 index 000000000..7dfafe1b2 --- /dev/null +++ b/packages/adl/test/formatter/formatter.ts @@ -0,0 +1,508 @@ +import { strictEqual, throws } from "assert"; +import prettier from "prettier"; +import * as plugin from "../../formatter/index.js"; + +function format(code: string): string { + const output = prettier.format(code, { + parser: "adl", + plugins: [plugin], + }); + return output; +} + +function assertFormat({ code, expected }: { code: string; expected: string }) { + const result = format(code); + strictEqual(result, expected.trim()); +} + +describe("adl: prettier formatter", () => { + it("throws error if there is a parsing issue", () => { + const code = `namespace this is invalid`; + + throws(() => format(code)); + }); + + it("format imports", () => { + assertFormat({ + code: ` + import "@azure-tools/adl-rest"; +import "@azure-tools/adl-openapi"; +import "@azure-tools/adl-rpaas" ; +`, + expected: ` +import "@azure-tools/adl-rest"; +import "@azure-tools/adl-openapi"; +import "@azure-tools/adl-rpaas"; +`, + }); + }); + + describe("model", () => { + it("format simple models", () => { + assertFormat({ + code: ` +model Foo { + id: number; + type: Bar; + + name?: string; + isArray: string[] ; +} +`, + expected: ` +model Foo { + id: number; + type: Bar; + name?: string; + isArray: string[]; +} +`, + }); + }); + + it("format nested models", () => { + assertFormat({ + code: ` +model Foo { + id: number; + address: { street: string, country: string} +} +`, + expected: ` +model Foo { + id: number; + address: { + street: string; + country: string; + }; +} +`, + }); + }); + + it("format models with spread", () => { + assertFormat({ + code: ` +model Foo { + id: number; + ...Bar; + name: string; +} +`, + expected: ` +model Foo { + id: number; + ...Bar; + name: string; +} +`, + }); + }); + + it("format model with decorator", () => { + assertFormat({ + code: ` + @some @decorator +model Foo {} +`, + expected: ` +@some +@decorator +model Foo {} +`, + }); + }); + + it("format model with heritage", () => { + assertFormat({ + code: ` +model Foo extends Base { +} + +model Bar extends Base< + string > { +} +`, + expected: ` +model Foo extends Base {} + +model Bar extends Base {} +`, + }); + }); + + it("format model with generic", () => { + assertFormat({ + code: ` +model Foo < T >{ +} +`, + expected: ` +model Foo {} +`, + }); + }); + }); + + describe("comments", () => { + it("format single line comments", () => { + assertFormat({ + code: ` + // This is a comment. +model Foo {} +`, + expected: ` +// This is a comment. +model Foo {} +`, + }); + }); + + it("format multi line comments", () => { + assertFormat({ + code: ` + /** + * This is a multiline comment + * that has bad formatting. + */ +model Foo {} +`, + expected: ` +/** + * This is a multiline comment + * that has bad formatting. + */ +model Foo {} +`, + }); + }); + }); + + describe("alias union", () => { + it("format simple alias", () => { + assertFormat({ + code: ` +alias Foo = "one" | "two"; +alias Bar + = "one" + | "two"; +`, + expected: ` +alias Foo = "one" | "two"; +alias Bar = "one" | "two"; +`, + }); + }); + + it("format generic alias", () => { + assertFormat({ + code: ` +alias Foo< A, B> = A | B; +alias Bar< + A, B> = + A | + B; +`, + expected: ` +alias Foo = A | B; +alias Bar = A | B; +`, + }); + }); + + it("format long alias", () => { + assertFormat({ + code: ` +alias VeryLong = "one" | "two" | "three" | "four" | "five" | "six" | "seven" | "height" | "nine" | "ten"; +`, + expected: ` +alias VeryLong = + | "one" + | "two" + | "three" + | "four" + | "five" + | "six" + | "seven" + | "height" + | "nine" + | "ten"; +`, + }); + }); + }); + + describe("alias intersection", () => { + it("format intersection of types", () => { + assertFormat({ + code: ` +alias Foo = One & Two; +alias Bar + = One & + Two; +`, + expected: ` +alias Foo = One & Two; +alias Bar = One & Two; +`, + }); + }); + + it("format intersection of anoymous models", () => { + assertFormat({ + code: ` +alias Foo = { foo: string } & {bar: string}; +alias Bar + = { foo: string } & + { + bar: string}; +`, + expected: ` +alias Foo = { + foo: string; +} & { + bar: string; +}; +alias Bar = { + foo: string; +} & { + bar: string; +}; +`, + }); + }); + }); + + describe("enum", () => { + it("format simple enum", () => { + assertFormat({ + code: ` +enum Foo { A, B} +enum Bar + { A, + B} +`, + expected: ` +enum Foo { + A, + B, +} +enum Bar { + A, + B, +} +`, + }); + }); + + it("format named enum", () => { + assertFormat({ + code: ` +enum Foo { A: "a", B : "b"} +enum Bar + { A: "a", + B: + "b"} +`, + expected: ` +enum Foo { + A: "a", + B: "b", +} +enum Bar { + A: "a", + B: "b", +} +`, + }); + }); + }); + + describe("namespaces", () => { + it("format global namespace", () => { + assertFormat({ + code: ` +namespace Foo; +`, + expected: ` +namespace Foo; +`, + }); + }); + + it("format global nested namespace", () => { + assertFormat({ + code: ` +namespace Foo . Bar; +`, + expected: ` +namespace Foo.Bar; +`, + }); + }); + + it("format global namespace with decorators", () => { + assertFormat({ + code: ` + @service + @other +namespace Foo . Bar; +`, + expected: ` +@service +@other +namespace Foo.Bar; +`, + }); + }); + + it("format namespace with body", () => { + assertFormat({ + code: ` +namespace Foo { + op some(): string; +} + + +namespace Foo . Bar { + op some(): string; +} +`, + expected: ` +namespace Foo { + op some(): string; +} + +namespace Foo.Bar { + op some(): string; +} +`, + }); + }); + + it("format nested namespaces", () => { + assertFormat({ + code: ` +namespace Foo { +namespace Bar { +op some(): string; +} +} + + +`, + expected: ` +namespace Foo { + namespace Bar { + op some(): string; + } +} +`, + }); + }); + }); + + describe("string literals", () => { + it("format single line string literal", () => { + assertFormat({ + code: ` +@doc( "this is a doc. " + ) +model Foo {} +`, + expected: ` +@doc("this is a doc. ") +model Foo {} +`, + }); + }); + + it("format single line with newline characters", () => { + assertFormat({ + code: ` +@doc( "foo\\nbar" + ) +model Foo {} +`, + expected: ` +@doc("foo\\nbar") +model Foo {} +`, + }); + }); + + it("format multi line string literal", () => { + assertFormat({ + code: ` +@doc( """ + +this is a doc. + that + span + multiple lines. +""" + ) +model Foo {} +`, + expected: ` +@doc(""" + +this is a doc. + that + span + multiple lines. +""") +model Foo {} +`, + }); + }); + }); + + describe("number literals", () => { + it("format integer", () => { + assertFormat({ + code: ` +alias MyNum = 123 ; +`, + expected: ` +alias MyNum = 123; +`, + }); + }); + + it("format float", () => { + assertFormat({ + code: ` +alias MyFloat1 = 1.234 ; +alias MyFloat2 = 0.123 ; +`, + expected: ` +alias MyFloat1 = 1.234; +alias MyFloat2 = 0.123; +`, + }); + }); + + it("format e notation numbers", () => { + assertFormat({ + code: ` +alias MyBigNumber = 1.0e8 ; +`, + expected: ` +alias MyBigNumber = 1.0e8; +`, + }); + }); + + it("format big numbers", () => { + assertFormat({ + code: ` +alias MyBigNumber = 1.0e999999999 ; +`, + expected: ` +alias MyBigNumber = 1.0e999999999; +`, + }); + }); + }); +}); diff --git a/packages/adl/test/formatter/scenarios/inputs/alias.adl b/packages/adl/test/formatter/scenarios/inputs/alias.adl new file mode 100644 index 000000000..d06d9452e --- /dev/null +++ b/packages/adl/test/formatter/scenarios/inputs/alias.adl @@ -0,0 +1,20 @@ +alias UnionOfType = Foo | Bar; + +alias UnionOfManyType = "one" | "two" | "three" | "four" | "five" | "six" | "seven" | "height" | "nine" | "ten"; + +alias UnionOfObject = {foo: string} | {bar: string}; + +alias UnionOfManyObject = {one: string} | {two: string} | {three: string} | {four: string} | {five: string}; + +alias UnionOfMix = Foo | { bar: string}; + +alias UnionOfManyMix = Foo | { bar: string} | Bar | Other | {other: string}; + +alias InterOfObject = {foo: string} & {bar: string}; + +alias InterOfManyObject = {one: string} & {two: string} & {three: string} & {four: string} & {five: string}; + +alias InterOfMix = Foo & { bar: string}; + +alias InterOfManyMix = Foo & { bar: string} & Bar & Other & {other: string}; + diff --git a/packages/adl/test/formatter/scenarios/inputs/misc.adl b/packages/adl/test/formatter/scenarios/inputs/misc.adl new file mode 100644 index 000000000..50ccb47f4 --- /dev/null +++ b/packages/adl/test/formatter/scenarios/inputs/misc.adl @@ -0,0 +1,58 @@ +import "@azure-tools/adl-rest"; +import "@azure-tools/adl-openapi"; +import "@azure-tools/adl-rpaas"; + +alias Bar = "that" | "this"; + +// This is a comment +alias VeryLong = "one" | "two" | "three" | "four" | "five" | "six" | "seven" | "height" | "nine" | "ten"; + +alias Inter = {foo: string} & {bar: string} ; + + +alias Inter2 = Foo & +Inter; + +alias Generic< A, B> = A | B; + +/** + * Model comment + */ +model Foo { + id: number; + type: Bar; + + /* + * Name is special and comment is unaligned. + */ + name?: string; + isArray: string[] ; + + address: { street: string, country: string}; + + ...Bar; +} + + +@bar +enum SomeEnum { A, B, C} + +enum + SomeNamedEnum { A: "a", + B: "b", @val() C: "c" } + + + +/** + * Multi line comment still works, + * yeahh.... + */ +@resource("/operations") +namespace Operations { + // Comment here too + @get @doc("Abc") op get(@path id: string): ArmResponse; + + + + @get op list(): ArmResponse | ErrorResponse; +} diff --git a/packages/adl/test/formatter/scenarios/inputs/model.adl b/packages/adl/test/formatter/scenarios/inputs/model.adl new file mode 100644 index 000000000..600e89740 --- /dev/null +++ b/packages/adl/test/formatter/scenarios/inputs/model.adl @@ -0,0 +1,11 @@ +model Empty { +} + +model WithProps { + foo: string; + bar: string +} + +model GenericExtension extends Parent { + +} diff --git a/packages/adl/test/formatter/scenarios/outputs/alias.adl b/packages/adl/test/formatter/scenarios/outputs/alias.adl new file mode 100644 index 000000000..fc3a61eda --- /dev/null +++ b/packages/adl/test/formatter/scenarios/outputs/alias.adl @@ -0,0 +1,80 @@ +alias UnionOfType = Foo | Bar; + +alias UnionOfManyType = + | "one" + | "two" + | "three" + | "four" + | "five" + | "six" + | "seven" + | "height" + | "nine" + | "ten"; + +alias UnionOfObject = { + foo: string; +} | { + bar: string; +}; + +alias UnionOfManyObject = + | { + one: string; + } + | { + two: string; + } + | { + three: string; + } + | { + four: string; + } + | { + five: string; + }; + +alias UnionOfMix = Foo | { + bar: string; +}; + +alias UnionOfManyMix = + | Foo + | { + bar: string; + } + | Bar + | Other + | { + other: string; + }; + +alias InterOfObject = { + foo: string; +} & { + bar: string; +}; + +alias InterOfManyObject = { + one: string; +} & { + two: string; +} & { + three: string; +} & { + four: string; +} & { + five: string; +}; + +alias InterOfMix = Foo & { + bar: string; +}; + +alias InterOfManyMix = Foo & { + bar: string; +} & Bar & + Other & { + other: string; + }; \ No newline at end of file diff --git a/packages/adl/test/formatter/scenarios/outputs/misc.adl b/packages/adl/test/formatter/scenarios/outputs/misc.adl new file mode 100644 index 000000000..16cd6fcbc --- /dev/null +++ b/packages/adl/test/formatter/scenarios/outputs/misc.adl @@ -0,0 +1,71 @@ +import "@azure-tools/adl-rest"; +import "@azure-tools/adl-openapi"; +import "@azure-tools/adl-rpaas"; + +alias Bar = "that" | "this"; + +// This is a comment +alias VeryLong = + | "one" + | "two" + | "three" + | "four" + | "five" + | "six" + | "seven" + | "height" + | "nine" + | "ten"; + +alias Inter = { + foo: string; +} & { + bar: string; +}; + +alias Inter2 = Foo & Inter; + +alias Generic = A | B; + +/** + * Model comment + */ +model Foo { + id: number; + type: Bar; + /* + * Name is special and comment is unaligned. + */ + name?: string; + isArray: string[]; + address: { + street: string; + country: string; + }; + ...Bar; +} + +@bar +enum SomeEnum { + A, + B, + C, +} + +enum SomeNamedEnum { + A: "a", + B: "b", + @val C: "c", +} + +/** + * Multi line comment still works, + * yeahh.... + */ +@resource("/operations") +namespace Operations { + // Comment here too + @get @doc("Abc") op get(@path id: string): ArmResponse; + + @get op list(): ArmResponse | ErrorResponse; +} \ No newline at end of file diff --git a/packages/adl/test/formatter/scenarios/outputs/model.adl b/packages/adl/test/formatter/scenarios/outputs/model.adl new file mode 100644 index 000000000..bec1f667b --- /dev/null +++ b/packages/adl/test/formatter/scenarios/outputs/model.adl @@ -0,0 +1,8 @@ +model Empty {} + +model WithProps { + foo: string; + bar: string; +} + +model GenericExtension extends Parent {} \ No newline at end of file diff --git a/packages/adl/test/formatter/scenarios/scenarios.ts b/packages/adl/test/formatter/scenarios/scenarios.ts new file mode 100644 index 000000000..3d193a024 --- /dev/null +++ b/packages/adl/test/formatter/scenarios/scenarios.ts @@ -0,0 +1,67 @@ +import { strictEqual } from "assert"; +import { mkdir, readFile, writeFile } from "fs/promises"; +import { dirname, join, resolve } from "path"; +import prettier from "prettier"; +import { fileURLToPath } from "url"; +import * as plugin from "../../../formatter/index.js"; + +function format(code: string): string { + const output = prettier.format(code, { + parser: "adl", + plugins: [plugin], + }); + return output; +} + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const scenarioRoot = resolve(__dirname, "../../../../test/formatter/scenarios"); +const shouldUpdate = process.argv.indexOf("--update-snapshots") !== -1; + +async function getOutput(name: string): Promise { + try { + const output = await readFile(join(scenarioRoot, "outputs", name)); + return output.toString(); + } catch { + return undefined; + } +} + +async function saveOutput(name: string, content: string) { + const outputDir = join(scenarioRoot, "outputs"); + await mkdir(outputDir, { recursive: true }); + await writeFile(join(outputDir, name), content); +} + +async function testScenario(name: string) { + const content = await readFile(join(scenarioRoot, "inputs", name)); + const output = await getOutput(name); + const formatted = format(content.toString()); + if (!output) { + return await saveOutput(name, formatted); + } + if (output !== formatted) { + if (shouldUpdate) { + return await saveOutput(name, formatted); + } + + strictEqual( + formatted, + output, + `Scenario ${name} does not match expected snapshot. Run with tests '--update-snapshots' option to update.` + ); + } +} + +describe("adl: prettier formatter scenarios", () => { + it("misc", async () => { + await testScenario("misc.adl"); + }); + + it("alias", async () => { + await testScenario("alias.adl"); + }); + + it("model", async () => { + await testScenario("model.adl"); + }); +}); diff --git a/packages/adl/test/libraries/simple/main.adl b/packages/adl/test/libraries/simple/main.adl index 6c0e564db..2ab41a9b3 100644 --- a/packages/adl/test/libraries/simple/main.adl +++ b/packages/adl/test/libraries/simple/main.adl @@ -2,5 +2,5 @@ import "MyLib"; import "CustomAdlMain"; @myLibDec -model A = MyLib.Model; -model B = CustomAdlMain.Model; +model A { x: MyLib.Model }; +model B { x: CustomAdlMain.Model }; diff --git a/packages/adl/test/libraries/test-libraries.ts b/packages/adl/test/libraries/test-libraries.ts index 09ea91dae..9777bacc6 100644 --- a/packages/adl/test/libraries/test-libraries.ts +++ b/packages/adl/test/libraries/test-libraries.ts @@ -4,7 +4,7 @@ import { NodeHost } from "../../compiler/util.js"; const libs = ["simple"]; -describe("libraries", () => { +describe("adl: libraries", () => { for (const lib of libs) { describe(lib, () => { it("compiles without error", async () => { @@ -12,10 +12,7 @@ describe("libraries", () => { const mainFile = fileURLToPath( new URL(`../../../test/libraries/${lib}/main.adl`, import.meta.url) ); - await createProgram(NodeHost, { - mainFile, - noEmit: true, - }); + await createProgram(NodeHost, mainFile, { noEmit: true }); } catch (e) { console.error(e.diagnostics); throw e; diff --git a/packages/adl/test/manual/fuzz.ts b/packages/adl/test/manual/fuzz.ts index 945f3f7c3..45a31839d 100644 --- a/packages/adl/test/manual/fuzz.ts +++ b/packages/adl/test/manual/fuzz.ts @@ -43,6 +43,11 @@ const weights = { main(); function main() { + if (process.argv[2] !== "run") { + throw new Error( + "Correct usage is `node fuzz.js run`. Is there a missing/incorrect mocha exclude pattern causing this to load?" + ); + } const iterations = 10000; console.log("Running parser fuzz test with 1000 iterations..."); fuzzTest(iterations); diff --git a/packages/adl/test/test-host.ts b/packages/adl/test/test-host.ts index 9e073cb2c..f4596bc73 100644 --- a/packages/adl/test/test-host.ts +++ b/packages/adl/test/test-host.ts @@ -1,34 +1,54 @@ import { readdir, readFile } from "fs/promises"; -import { basename, isAbsolute, join, normalize, relative, resolve, sep } from "path"; +import { basename, extname, isAbsolute, relative, resolve, sep } from "path"; import { fileURLToPath, pathToFileURL } from "url"; -import { createProgram } from "../compiler/program.js"; -import { CompilerHost, Type } from "../compiler/types"; +import { formatDiagnostic, logDiagnostics, logVerboseTestOutput } from "../compiler/diagnostics.js"; +import { CompilerOptions } from "../compiler/options.js"; +import { createProgram, Program } from "../compiler/program.js"; +import { CompilerHost, Diagnostic, Type } from "../compiler/types.js"; export interface TestHost { addAdlFile(path: string, contents: string): void; addJsFile(path: string, contents: any): void; - compile(main: string): Promise>; + addRealAdlFile(path: string, realPath: string): Promise; + addRealJsFile(path: string, realPath: string): Promise; + compile(main: string, options?: CompilerOptions): Promise>; + diagnose(main: string, options?: CompilerOptions): Promise; + compileAndDiagnose( + main: string, + options?: CompilerOptions + ): Promise<[Record, readonly Diagnostic[]]>; testTypes: Record; + program: Program; /** * Virtual filesystem used in the tests. */ - fs: { [name: string]: string }; + fs: Map; +} + +class TestHostError extends Error { + constructor(message: string, public code: "ENOENT" | "ERR_MODULE_NOT_FOUND") { + super(message); + } } export async function createTestHost(): Promise { const testTypes: Record = {}; - - const virtualFs: { [name: string]: string } = {}; - const jsImports: { [path: string]: Promise } = {}; + let program: Program = undefined as any; // in practice it will always be initialized + const virtualFs = new Map(); + const jsImports = new Map>(); const compilerHost: CompilerHost = { async readFile(path: string) { - return virtualFs[path]; + const contents = virtualFs.get(path); + if (contents === undefined) { + throw new TestHostError(`File ${path} not found.`, "ENOENT"); + } + return contents; }, async readDir(path: string) { const contents = []; - for (const fsPath of Object.keys(virtualFs)) { + for (const fsPath of virtualFs.keys()) { if (isContainedIn(path, fsPath)) { contents.push({ isFile() { @@ -46,7 +66,7 @@ export async function createTestHost(): Promise { }, async writeFile(path: string, content: string) { - virtualFs[path] = content; + virtualFs.set(path, content); }, getLibDirs() { @@ -58,7 +78,11 @@ export async function createTestHost(): Promise { }, getJsImport(path) { - return jsImports[path]; + const module = jsImports.get(path); + if (module === undefined) { + throw new TestHostError(`Module ${path} not found`, "ERR_MODULE_NOT_FOUND"); + } + return module; }, getCwd() { @@ -66,7 +90,18 @@ export async function createTestHost(): Promise { }, async stat(path: string) { - for (const fsPath of Object.keys(virtualFs)) { + if (virtualFs.has(path)) { + return { + isDirectory() { + return false; + }, + isFile() { + return true; + }, + }; + } + + for (const fsPath of virtualFs.keys()) { if (fsPath.startsWith(path) && fsPath !== path) { return { isDirectory() { @@ -79,14 +114,7 @@ export async function createTestHost(): Promise { } } - return { - isDirectory() { - return false; - }, - isFile() { - return true; - }, - }; + throw new TestHostError(`File ${path} not found`, "ENOENT"); }, // symlinks not supported in test-host @@ -96,31 +124,42 @@ export async function createTestHost(): Promise { }; // load standard library into the vfs - for (const relDir of ["../../lib", "../../../lib"]) { + for (const [relDir, virtualDir] of [ + ["../../lib", "/.adl/dist/lib"], + ["../../../lib", "/.adl/lib"], + ]) { const dir = resolve(fileURLToPath(import.meta.url), relDir); - const contents = await readdir(dir, { withFileTypes: true }); - for (const entry of contents) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const realPath = resolve(dir, entry.name); + const virtualPath = resolve(virtualDir, entry.name); if (entry.isFile()) { - const path = join(dir, entry.name); - const virtualDir = compilerHost.getLibDirs()[0]; - const key = normalize(join(virtualDir, entry.name)); - - if (entry.name.endsWith(".js")) { - jsImports[key] = import(pathToFileURL(path).href); - virtualFs[key] = ""; // don't need contents. - } else { - const contents = await readFile(path, "utf-8"); - virtualFs[key] = contents; + switch (extname(entry.name)) { + case ".adl": + const contents = await readFile(realPath, "utf-8"); + virtualFs.set(virtualPath, contents); + break; + case ".js": + case ".mjs": + jsImports.set(virtualPath, import(pathToFileURL(realPath).href)); + virtualFs.set(virtualPath, ""); // don't need contents. + break; } } } } // add test decorators + addAdlFile("/.adl/test-lib/main.adl", 'import "./test.js";'); addJsFile("/.adl/test-lib/test.js", { test(_: any, target: Type, name?: string) { if (!name) { - if (target.kind === "Model" || target.kind === "Namespace") { + if ( + target.kind === "Model" || + target.kind === "Namespace" || + target.kind === "Enum" || + target.kind === "Operation" + ) { name = target.name; } else { throw new Error("Need to specify a name for test type"); @@ -134,33 +173,66 @@ export async function createTestHost(): Promise { return { addAdlFile, addJsFile, + addRealAdlFile, + addRealJsFile, compile, + diagnose, + compileAndDiagnose, testTypes, + get program() { + return program; + }, fs: virtualFs, }; function addAdlFile(path: string, contents: string) { - virtualFs[resolve(compilerHost.getCwd(), path)] = contents; + virtualFs.set(resolve(compilerHost.getCwd(), path), contents); } function addJsFile(path: string, contents: any) { const key = resolve(compilerHost.getCwd(), path); - // don't need file contents; - virtualFs[key] = ""; - jsImports[key] = new Promise((r) => r(contents)); + virtualFs.set(key, ""); // don't need contents + jsImports.set(key, new Promise((r) => r(contents))); } - async function compile(main: string) { - try { - const program = await createProgram(compilerHost, { - mainFile: main, - noEmit: true, - }); + async function addRealAdlFile(path: string, existingPath: string) { + virtualFs.set(resolve(compilerHost.getCwd(), path), await readFile(existingPath, "utf8")); + } - return testTypes; - } catch (e) { - throw e; + async function addRealJsFile(path: string, existingPath: string) { + const key = resolve(compilerHost.getCwd(), path); + const exports = await import(pathToFileURL(existingPath).href); + + virtualFs.set(key, ""); + jsImports.set(key, exports); + } + + async function compile(main: string, options: CompilerOptions = {}) { + const [testTypes, diagnostics] = await compileAndDiagnose(main, options); + if (diagnostics.length > 0) { + let message = "Unexpected diagnostics:\n" + diagnostics.map(formatDiagnostic).join("\n"); + throw new Error(message); } + return testTypes; + } + + async function diagnose(main: string, options: CompilerOptions = {}) { + const [, diagnostics] = await compileAndDiagnose(main, options); + return diagnostics; + } + + async function compileAndDiagnose( + mainFile: string, + options: CompilerOptions = {} + ): Promise<[Record, readonly Diagnostic[]]> { + if (options.noEmit === undefined) { + // default for tests is noEmit + options = { ...options, noEmit: true }; + } + + program = await createProgram(compilerHost, mainFile, options); + logVerboseTestOutput((log) => logDiagnostics(program.diagnostics, log)); + return [testTypes, program.diagnostics]; } function isContainedIn(a: string, b: string) { diff --git a/packages/adl/test/test-mutators.ts b/packages/adl/test/test-mutators.ts new file mode 100644 index 000000000..572b32249 --- /dev/null +++ b/packages/adl/test/test-mutators.ts @@ -0,0 +1,120 @@ +import { strictEqual } from "assert"; +import { + addModelProperty, + addOperationParameter, + addOperationResponseType, +} from "../compiler/mutators.js"; +import { Program } from "../compiler/program.js"; +import { ModelType, OperationType, UnionType } from "../compiler/types.js"; +import { createTestHost, TestHost } from "./test-host.js"; + +describe("adl: mutators", () => { + let testHost: TestHost; + + beforeEach(async () => { + testHost = await createTestHost(); + }); + + function addBarProperty(program: Program, model: ModelType) { + addModelProperty(program, model, "bar", "string"); + } + + it("addModelProperty adds a property to a model type", async () => { + testHost.addJsFile("a.js", { addBarProperty }); + testHost.addAdlFile( + "main.adl", + ` + import "./a.js"; + + @test + @addBarProperty + model A { foo: int32; } + ` + ); + + const { A } = (await testHost.compile("./")) as { A: ModelType }; + + strictEqual(A.properties.size, 2); + strictEqual(A.properties.get("bar")!.name, "bar"); + strictEqual((A.properties.get("bar")!.type as ModelType).name, "string"); + }); + + function addParameters(program: Program, operation: OperationType) { + addOperationParameter(program, operation, "omega", "string"); + addOperationParameter(program, operation, "alpha", "int64", { insertIndex: 0 }); + addOperationParameter(program, operation, "beta", "B.Excellent", { insertIndex: 1 }); + } + + it("addOperationParameter inserts operation parameters", async () => { + testHost.addJsFile("a.js", { addParameters }); + testHost.addAdlFile( + "main.adl", + ` + import "./a.adl"; + import "./b.adl"; + ` + ); + testHost.addAdlFile( + "a.adl", + ` + import "./a.js"; + import "./b.adl"; + + @test + @addParameters + op TestOp(foo: int32): string; + ` + ); + testHost.addAdlFile( + "b.adl", + ` + namespace B; + model Excellent {} + ` + ); + + const { TestOp } = (await testHost.compile("./")) as { TestOp: OperationType }; + + const params = Array.from(TestOp.parameters.properties.entries()); + strictEqual(TestOp.parameters.properties.size, 4); + strictEqual(params[0][0], "alpha"); + strictEqual((params[0][1].type as ModelType).name, "int64"); + strictEqual(params[1][0], "beta"); + strictEqual((params[1][1].type as ModelType).name, "Excellent"); + strictEqual(params[2][0], "foo"); + strictEqual((params[2][1].type as ModelType).name, "int32"); + strictEqual(params[3][0], "omega"); + strictEqual((params[3][1].type as ModelType).name, "string"); + }); + + function addResponseTypes(program: Program, operation: OperationType) { + addOperationResponseType(program, operation, "int64"); + addOperationResponseType(program, operation, "A.Response"); + } + + it("addModelProperty adds a property to a model type", async () => { + testHost.addJsFile("a.js", { addResponseTypes }); + testHost.addAdlFile( + "main.adl", + ` + import "./a.js"; + + @test + @addResponseTypes + op TestOp(foo: int32): string; + + namespace A { + model Response {} + } + ` + ); + + const { TestOp } = (await testHost.compile("./")) as { TestOp: OperationType }; + + const unionType = TestOp.returnType as UnionType; + strictEqual(unionType.options.length, 3); + strictEqual((unionType.options[0] as ModelType).name, "string"); + strictEqual((unionType.options[1] as ModelType).name, "int64"); + strictEqual((unionType.options[2] as ModelType).name, "Response"); + }); +}); diff --git a/packages/adl/test/test-parser.ts b/packages/adl/test/test-parser.ts index e0878f57c..9553c1d06 100644 --- a/packages/adl/test/test-parser.ts +++ b/packages/adl/test/test-parser.ts @@ -1,9 +1,10 @@ import assert from "assert"; -import { logDiagnostics, logVerboseTestOutput } from "../compiler/diagnostics.js"; +import { CharCode } from "../compiler/charcode.js"; +import { formatDiagnostic, logDiagnostics, logVerboseTestOutput } from "../compiler/diagnostics.js"; import { hasParseError, NodeFlags, parse } from "../compiler/parser.js"; import { ADLScriptNode, SyntaxKind } from "../compiler/types.js"; -describe("syntax", () => { +describe("adl: syntax", () => { describe("import statements", () => { parseEach(['import "x";']); @@ -101,7 +102,11 @@ describe("syntax", () => { }); describe("model = statements", () => { - parseEach(["model x = y;", "model foo = bar | baz;", "model bar = a | b;"]); + parseErrorEach([ + ["model x = y;", [/'{' expected/]], + ["model foo = bar | baz;", [/'{' expected/]], + ["model bar = a | b;", [/'{' expected/]], + ]); }); describe("model expressions", () => { @@ -121,7 +126,7 @@ describe("syntax", () => { }); describe("template instantiations", () => { - parseEach(["model A = Foo;", "model B = Foo[];"]); + parseEach(["model A { x: Foo; }", "model B { x: Foo[]; }"]); }); describe("intersection expressions", () => { @@ -129,7 +134,7 @@ describe("syntax", () => { }); describe("parenthesized expressions", () => { - parseEach(["model A = ((B | C) & D)[];"]); + parseEach(["model A { x: ((B | C) & D)[]; }"]); }); describe("namespace statements", () => { @@ -165,7 +170,6 @@ describe("syntax", () => { ` model A { }; model B { } - model C = A; ; namespace I { op foo(): number; @@ -219,53 +223,248 @@ describe("syntax", () => { ]); }); - describe("non-ascii identifiers", () => { - parseEach(["model IncomprĆ©hensible {}", "model šŒ°šŒ² {}", "model BananašŒ°šŒ²Banana {}"]); - parseErrorEach([["model šŸ˜¢ {}", [/Invalid character/]]]); + describe("unterminated tokens", () => { + parseErrorEach([["/* Yada yada yada", [/Unterminated multi-line comment/]]]); + + const strings = [ + '"banana', + '"banana\\', + '"banana\r"', + '"banana\n"', + '"banana\r\n"', + '"banana\u{2028}"', + '"banana\u{2029}"', + '"""\nbanana', + '"""\nbanana\\', + ]; + parseErrorEach( + Array.from(strings.entries()).map((e) => [ + `alias ${String.fromCharCode(CharCode.A + e[0])} = ${e[1]}`, + [/Unterminated string literal/], + (node) => { + const statement = node.statements[0]; + assert(statement.kind === SyntaxKind.AliasStatement, "alias statement expected"); + const value = statement.value; + assert(value.kind === SyntaxKind.StringLiteral, "string literal expected"); + assert.strictEqual(value.value, "banana"); + }, + ]) + ); + }); + + describe("terminated tokens at EOF", () => { + parseErrorEach([ + ["alias X = 0x10101", [/';' expected/]], + ["alias X = 0xBEEF", [/';' expected/]], + ["alias X = 123", [/';' expected/]], + ["alias X = 123e45", [/';' expected/]], + ["alias X = 123.45", [/';' expected/]], + ["alias X = 123.45e2", [/';' expected/]], + ["alias X = Banana", [/';' expected/]], + ['alias X = "Banana"', [/';' expected/]], + ['alias X = """\nBanana\n"""', [/';' expected/]], + ]); + }); + + describe("numeric literals", () => { + const good: [string, number][] = [ + // Some questions remain here: https://github.com/Azure/adl/issues/506 + ["-0", -0], + ["1e9999", Infinity], + ["1e-9999", 0], + ["-1e-9999", -0], + ["-1e9999", -Infinity], + + // NOTE: No octal in ADL + ["077", 77], + ["+077", 77], + ["-077", -77], + + ["0xABCD", 0xabcd], + ["0xabcd", 0xabcd], + ["0x1010", 0x1010], + ["0b1010", 0b1010], + ["0", 0], + ["+0", 0], + ["0.0", 0.0], + ["+0.0", 0], + ["-0.0", -0.0], + ["123", 123], + ["+123", 123], + ["-123", -123], + ["123.123", 123.123], + ["+123.123", 123.123], + ["-123.123", -123.123], + ["789e42", 789e42], + ["+789e42", 789e42], + ["-789e42", -789e42], + ["654.321e9", 654.321e9], + ["+654.321e9", 654.321e9], + ["-654.321e9", -654.321e9], + ]; + + const bad: [string, RegExp][] = [ + ["123.", /Digit expected/], + ["123.0e", /Digit expected/], + ["123e", /Digit expected/], + ["0b", /Binary digit expected/], + ["0b2", /Binary digit expected/], + ["0x", /Hexadecimal digit expected/], + ["0xG", /Hexadecimal digit expected/], + ]; + + parseEach(good.map((c) => [`alias M = ${c[0]};`, (node) => isNumericLiteral(node, c[1])])); + parseErrorEach(bad.map((c) => [`alias M = ${c[0]};`, [c[1]]])); + + function isNumericLiteral(node: ADLScriptNode, value: number) { + const statement = node.statements[0]; + assert(statement.kind === SyntaxKind.AliasStatement, "alias statement expected"); + const assignment = statement.value; + assert(assignment?.kind === SyntaxKind.NumericLiteral, "numeric literal expected"); + assert.strictEqual(assignment.value, value); + } + }); + + describe("identifiers", () => { + const good = [ + "short", + "short42", + "lowercaseandlong", + "lowercaseandlong42", + "camelCase", + "camelCase42", + "PascalCase", + "PascalCase42", + "has_underscore", + "has_$dollar", + "_startsWithUnderscore", + "$startsWithDollar", + "IncomprĆ©hensible", + "incomprĆ©hensible", + "IncomprƉhensible", + "incomprƉhensible", + // leading astral character + "šŒ°šŒ²", + // continuing astral character + "BananašŒ°šŒ²42Banana", + "bananašŒ°šŒ²42banana", + // ZWNJ + "deaf\u{200c}ly", + // ZWJ + "ą¤•ą„ā€ą¤·", + ]; + + const bad: [string, RegExp][] = [ + ["šŸ˜¢", /Invalid character/], + ["42", /Identifier expected/], + ["true", /Keyword cannot be used as identifier/], + ]; + + parseEach( + good.map((s) => [ + `model ${s} {}`, + (node) => { + const statement = node.statements[0]; + assert(statement.kind === SyntaxKind.ModelStatement, "Model statement expected."); + assert.strictEqual(statement.id.sv, s); + }, + ]) + ); + + parseErrorEach(bad.map((e) => [`model ${e[0]} {}`, [e[1]]])); + }); + + // smaller repro of previous regen-samples baseline failures + describe("sample regressions", () => { + parseEach([ + [ + `/* \\n <-- before string! */ @format("\\\\w") model M {}`, + (node) => { + assert(node.statements[0].kind === SyntaxKind.ModelStatement); + assert(node.statements[0].decorators[0].arguments[0].kind === SyntaxKind.StringLiteral); + assert.strictEqual(node.statements[0].decorators[0].arguments[0].value, "\\w"); + }, + ], + ]); + }); + + describe("enum statements", () => { + parseEach([ + "enum Foo { }", + "enum Foo { a, b }", + 'enum Foo { a: "hi", c: 10 }', + "@foo enum Foo { @bar a, @baz b: 10 }", + ]); + + parseErrorEach([ + ["enum Foo { a: number }", [/Expected numeric or string literal/]], + ["enum Foo { a: ; b: ; }", [/Expression expected/, /Expression expected/]], + ["enum Foo { ;+", [/Enum member expected/]], + ["enum { }", [/Identifier expected/]], + ]); + }); + + describe("alias statements", () => { + parseEach(["alias X = 1;", "alias X = A | B;", "alias MaybeUndefined = T | undefined;"]); + parseErrorEach([ + ["@foo alias Bar = 1;", [/Cannot decorate alias statement/]], + ["alias Foo =", [/Expression expected/]], + ["alias Foo<> =", [/Identifier expected/, /Expression expected/]], + ["alias Foo = X |", [/Expression expected/]], + ["alias =", [/Identifier expected/]], + ]); }); }); -function parseEach(cases: string[]) { - for (const code of cases) { +type Callback = (node: ADLScriptNode) => void; + +function parseEach(cases: (string | [string, Callback])[]) { + for (const each of cases) { + const code = typeof each === "string" ? each : each[0]; + const callback = typeof each === "string" ? undefined : each[1]; it("parses `" + shorten(code) + "`", () => { logVerboseTestOutput("=== Source ==="); logVerboseTestOutput(code); logVerboseTestOutput("\n=== Parse Result ==="); const astNode = parse(code); + if (callback) { + callback(astNode); + } dumpAST(astNode); logVerboseTestOutput("\n=== Diagnostics ==="); if (astNode.parseDiagnostics.length > 0) { - const diagnostics: string[] = []; - logDiagnostics(astNode.parseDiagnostics, (e) => diagnostics.push(e!)); + const diagnostics = astNode.parseDiagnostics.map(formatDiagnostic).join("\n"); assert.strictEqual( hasParseError(astNode), astNode.parseDiagnostics.some((e) => e.severity === "error"), - "root node claims to have no parse errors, but these were reported:\n" + - diagnostics.join("\n") + "root node claims to have no parse errors, but these were reported:\n" + diagnostics ); - assert.fail("Unexpected parse errors in test:\n" + diagnostics.join("\n")); + assert.fail("Unexpected parse errors in test:\n" + diagnostics); } }); } } -function parseErrorEach(cases: [string, RegExp[]][]) { - for (const [code, matches] of cases) { +function parseErrorEach(cases: [string, RegExp[], Callback?][], significantWhitespace = false) { + for (const [code, matches, callback] of cases) { it(`doesn't parse ${shorten(code)}`, () => { logVerboseTestOutput("=== Source ==="); logVerboseTestOutput(code); const astNode = parse(code); + if (callback) { + callback(astNode); + } logVerboseTestOutput("\n=== Parse Result ==="); dumpAST(astNode); logVerboseTestOutput("\n=== Diagnostics ==="); logVerboseTestOutput((log) => logDiagnostics(astNode.parseDiagnostics, log)); - assert.notStrictEqual(astNode.parseDiagnostics.length, 0); + assert.notStrictEqual(astNode.parseDiagnostics.length, 0, "parse diagnostics length"); let i = 0; for (const match of matches) { assert.match(astNode.parseDiagnostics[i++].message, match); @@ -280,7 +479,7 @@ function parseErrorEach(cases: [string, RegExp[]][]) { function dumpAST(astNode: ADLScriptNode) { logVerboseTestOutput((log) => { - const hasErrors = hasParseError(astNode); // force flags to initialize + hasParseError(astNode); // force flags to initialize const json = JSON.stringify(astNode, replacer, 2); log(json); }); diff --git a/packages/adl/test/test-scanner.ts b/packages/adl/test/test-scanner.ts index f81c4a415..ec127fea9 100644 --- a/packages/adl/test/test-scanner.ts +++ b/packages/adl/test/test-scanner.ts @@ -1,23 +1,37 @@ import assert from "assert"; import { readFile } from "fs/promises"; import { URL } from "url"; -import { throwOnError } from "../compiler/diagnostics.js"; +import { isIdentifierContinue, isIdentifierStart } from "../compiler/charcode.js"; +import { DiagnosticHandler, formatDiagnostic } from "../compiler/diagnostics.js"; import { createScanner, isKeyword, isPunctuation, isStatementKeyword, + KeywordLimit, Keywords, - maxKeywordLength, Token, TokenDisplay, } from "../compiler/scanner.js"; -import { LineAndCharacter } from "../compiler/types.js"; -type TokenEntry = [Token, string?, number?, LineAndCharacter?]; +type TokenEntry = [ + Token, + string?, + { + pos?: number; + line?: number; + character?: number; + value?: string; + }? +]; -function tokens(text: string, onError = throwOnError): TokenEntry[] { - const scanner = createScanner(text, onError); +function tokens(text: string, diagnosticHandler?: DiagnosticHandler): TokenEntry[] { + if (!diagnosticHandler) { + diagnosticHandler = (diagnostic) => + assert.fail("Unexpected diagnostic: " + formatDiagnostic(diagnostic)); + } + + const scanner = createScanner(text, diagnosticHandler); const result: TokenEntry[] = []; do { const token = scanner.scan(); @@ -25,8 +39,11 @@ function tokens(text: string, onError = throwOnError): TokenEntry[] { result.push([ scanner.token, scanner.getTokenText(), - scanner.tokenPosition, - scanner.file.getLineAndCharacterOfPosition(scanner.tokenPosition), + { + pos: scanner.tokenPosition, + value: scanner.getTokenValue(), + ...scanner.file.getLineAndCharacterOfPosition(scanner.tokenPosition), + }, ]); } while (!scanner.eof()); @@ -38,44 +55,61 @@ function tokens(text: string, onError = throwOnError): TokenEntry[] { } function verify(tokens: TokenEntry[], expecting: TokenEntry[]) { - for (const [ - index, - [expectedToken, expectedText, expectedPosition, expectedLineAndCharacter], - ] of expecting.entries()) { - const [token, text, position, lineAndCharacter] = tokens[index]; + for (const [index, [expectedToken, expectedText, expectedAdditional]] of expecting.entries()) { + const [token, text, additional] = tokens[index]; assert.strictEqual(Token[token], Token[expectedToken], `Token ${index} must match`); if (expectedText) { assert.strictEqual(text, expectedText, `Token ${index} test must match`); } - if (expectedPosition) { - assert.strictEqual(position, expectedPosition, `Token ${index} position must match`); + if (expectedAdditional?.pos) { + assert.strictEqual( + additional!.pos, + expectedAdditional.pos, + `Token ${index} position must match` + ); } - if (expectedLineAndCharacter) { - assert.deepStrictEqual( - lineAndCharacter, - expectedLineAndCharacter, - `Token ${index} line and character must match` + if (expectedAdditional?.line) { + assert.strictEqual( + additional!.line, + expectedAdditional.line, + `Token ${index} line must match` + ); + } + + if (expectedAdditional?.character) { + assert.strictEqual( + additional!.character, + expectedAdditional?.character, + `Token ${index} character must match` + ); + } + + if (expectedAdditional?.value) { + assert.strictEqual( + additional!.value, + expectedAdditional.value, + `Token ${index} value must match` ); } } } -describe("scanner", () => { +describe("adl: scanner", () => { /** verifies that we can scan tokens and get back some output. */ it("smoketest", () => { - const all = tokens("\tthis is a test"); + const all = tokens('\tthis is "a" test'); verify(all, [ [Token.Whitespace], - [Token.Identifier, "this"], + [Token.Identifier, "this", { value: "this" }], [Token.Whitespace], - [Token.Identifier, "is"], + [Token.Identifier, "is", { value: "is" }], [Token.Whitespace], - [Token.Identifier, "a"], + [Token.StringLiteral, '"a"', { value: "a" }], [Token.Whitespace], - [Token.Identifier, "test"], + [Token.Identifier, "test", { value: "test" }], ]); }); @@ -94,6 +128,11 @@ describe("scanner", () => { ]); }); + it("scans intersections", () => { + const all = tokens("A&B"); + verify(all, [[Token.Identifier, "A"], [Token.Ampersand], [Token.Identifier, "B"]]); + }); + it("scans decorator expressions", () => { const all = tokens('@foo(1,"hello",foo)'); @@ -146,11 +185,20 @@ describe("scanner", () => { ]); }); - function scanString(text: string, expectedValue: string) { - const scanner = createScanner(text); + function scanString(text: string, expectedValue: string, expectedDiagnostic?: RegExp) { + const scanner = createScanner(text, (diagnostic) => { + if (expectedDiagnostic) { + assert.match(diagnostic.message, expectedDiagnostic); + } else { + assert.fail("No diagnostic expected, but got " + formatDiagnostic(diagnostic)); + } + }); + assert.strictEqual(scanner.scan(), Token.StringLiteral); assert.strictEqual(scanner.token, Token.StringLiteral); - assert.strictEqual(scanner.getTokenText(), text); + if (!expectedDiagnostic) { + assert.strictEqual(scanner.getTokenText(), text); + } assert.strictEqual(scanner.getTokenValue(), expectedValue); } @@ -162,56 +210,64 @@ describe("scanner", () => { scanString('"Hello world \\r\\n \\t \\" \\\\ !"', 'Hello world \r\n \t " \\ !'); }); - it("scans multi-line strings", () => { - scanString('"More\r\nthan\r\none\r\nline"', "More\nthan\none\nline"); + it("does not allow multi-line, non-triple-quoted strings", () => { + scanString('"More\r\nthan\r\none\r\nline"', "More", /Unterminated string/); + scanString('"More\nthan\none\nline"', "More", /Unterminated string/); + scanString('"Fancy\u{2028}line separator"', "Fancy", /Unterminated string/); + scanString('"Fancy\u{2029}paragraph separator', "Fancy", /Unterminated string/); }); it("scans triple-quoted strings", () => { scanString( + // NOTE: sloppy blank line formatting and trailing whitespace after open + // quotes above is deliberate here and deliberately tolerated by + // the scanner. `""" This is a triple-quoted string - - And this is another line + "You do not need to escape lone quotes" + You can use escape sequences: \\r \\n \\t \\\\ \\" """`, - // NOTE: sloppy blank line formatting and trailing whitespace after open - // quotes above is deliberately tolerated. - "This is a triple-quoted string\n\n\n\nAnd this is another line" + 'This is a triple-quoted string\n\n\n"You do not need to escape lone quotes"\nYou can use escape sequences: \r \n \t \\ "' ); }); + it("normalizes CRLF to LF in multi-line string", () => { + scanString('"""\r\nThis\r\nis\r\na\r\ntest\r\n"""', "This\nis\na\ntest"); + }); + it("provides token position", () => { const all = tokens("a x\raa x\r\naaa x\naaaa x\u{2028}aaaaa x\u{2029}aaaaaa x"); verify(all, [ - [Token.Identifier, "a", 0, { line: 0, character: 0 }], - [Token.Whitespace, " ", 1, { line: 0, character: 1 }], - [Token.Identifier, "x", 2, { line: 0, character: 2 }], - [Token.NewLine, "\r", 3, { line: 0, character: 3 }], + [Token.Identifier, "a", { pos: 0, line: 0, character: 0 }], + [Token.Whitespace, " ", { pos: 1, line: 0, character: 1 }], + [Token.Identifier, "x", { pos: 2, line: 0, character: 2 }], + [Token.NewLine, "\r", { pos: 3, line: 0, character: 3 }], - [Token.Identifier, "aa", 4, { line: 1, character: 0 }], - [Token.Whitespace, " ", 6, { line: 1, character: 2 }], - [Token.Identifier, "x", 7, { line: 1, character: 3 }], - [Token.NewLine, "\r\n", 8, { line: 1, character: 4 }], + [Token.Identifier, "aa", { pos: 4, line: 1, character: 0 }], + [Token.Whitespace, " ", { pos: 6, line: 1, character: 2 }], + [Token.Identifier, "x", { pos: 7, line: 1, character: 3 }], + [Token.NewLine, "\r\n", { pos: 8, line: 1, character: 4 }], - [Token.Identifier, "aaa", 10, { line: 2, character: 0 }], - [Token.Whitespace, " ", 13, { line: 2, character: 3 }], - [Token.Identifier, "x", 14, { line: 2, character: 4 }], - [Token.NewLine, "\n", 15, { line: 2, character: 5 }], + [Token.Identifier, "aaa", { pos: 10, line: 2, character: 0 }], + [Token.Whitespace, " ", { pos: 13, line: 2, character: 3 }], + [Token.Identifier, "x", { pos: 14, line: 2, character: 4 }], + [Token.NewLine, "\n", { pos: 15, line: 2, character: 5 }], - [Token.Identifier, "aaaa", 16, { line: 3, character: 0 }], - [Token.Whitespace, " ", 20, { line: 3, character: 4 }], - [Token.Identifier, "x", 21, { line: 3, character: 5 }], - [Token.NewLine, "\u{2028}", 22, { line: 3, character: 6 }], + [Token.Identifier, "aaaa", { pos: 16, line: 3, character: 0 }], + [Token.Whitespace, " ", { pos: 20, line: 3, character: 4 }], + [Token.Identifier, "x", { pos: 21, line: 3, character: 5 }], + [Token.NewLine, "\u{2028}", { pos: 22, line: 3, character: 6 }], - [Token.Identifier, "aaaaa", 23, { line: 4, character: 0 }], - [Token.Whitespace, " ", 28, { line: 4, character: 5 }], - [Token.Identifier, "x", 29, { line: 4, character: 6 }], - [Token.NewLine, "\u{2029}", 30, { line: 4, character: 7 }], + [Token.Identifier, "aaaaa", { pos: 23, line: 4, character: 0 }], + [Token.Whitespace, " ", { pos: 28, line: 4, character: 5 }], + [Token.Identifier, "x", { pos: 29, line: 4, character: 6 }], + [Token.NewLine, "\u{2029}", { pos: 30, line: 4, character: 7 }], - [Token.Identifier, "aaaaaa", 31, { line: 5, character: 0 }], - [Token.Whitespace, " ", 37, { line: 5, character: 6 }], - [Token.Identifier, "x", 38, { line: 5, character: 7 }], + [Token.Identifier, "aaaaaa", { pos: 31, line: 5, character: 0 }], + [Token.Whitespace, " ", { pos: 37, line: 5, character: 6 }], + [Token.Identifier, "x", { pos: 38, line: 5, character: 7 }], ]); }); @@ -225,11 +281,20 @@ describe("scanner", () => { `Token enum has ${tokenCount} elements but TokenDisplay array has ${tokenDisplayCount}.` ); - // check that keywords have appropriate display + // check that keywords have appropriate display and limits const nonStatementKeywords = [Token.ExtendsKeyword, Token.TrueKeyword, Token.FalseKeyword]; - let maxKeywordLengthFound = -1; - for (const [name, token] of Keywords.entries()) { + let minKeywordLengthFound = Number.MAX_SAFE_INTEGER; + let maxKeywordLengthFound = Number.MIN_SAFE_INTEGER; + + for (const [name, token] of Keywords) { + assert.match( + name, + /^[a-z]+$/, + "We need to change the keyword lookup algorithm in the scanner if we ever add a keyword that is not all lowercase ascii letters." + ); + minKeywordLengthFound = Math.min(minKeywordLengthFound, name.length); maxKeywordLengthFound = Math.max(maxKeywordLengthFound, name.length); + assert.strictEqual(TokenDisplay[token], `'${name}'`); assert(isKeyword(token), `${name} should be classified as a keyword`); if (!nonStatementKeywords.includes(token)) { @@ -237,7 +302,21 @@ describe("scanner", () => { } } - assert.strictEqual(maxKeywordLengthFound, maxKeywordLength); + assert.strictEqual( + minKeywordLengthFound, + KeywordLimit.MinLength, + `min keyword length is incorrect, set KeywordLimit.MinLength to ${minKeywordLengthFound}` + ); + assert.strictEqual( + maxKeywordLengthFound, + KeywordLimit.MaxLength, + `max keyword length is incorrect, set KeywordLimit.MaxLength to ${maxKeywordLengthFound}` + ); + + assert( + maxKeywordLengthFound < 11, + "We need to change the keyword lookup algorithm in the scanner if we ever add a keyword with 11 characters or more." + ); // check single character punctuation for (let i = 33; i <= 126; i++) { @@ -256,15 +335,63 @@ describe("scanner", () => { // check the rest assert.strictEqual(TokenDisplay[Token.Elipsis], "'...'"); - assert.strictEqual(TokenDisplay[Token.None], ""); - assert.strictEqual(TokenDisplay[Token.Invalid], ""); - assert.strictEqual(TokenDisplay[Token.EndOfFile], ""); - assert.strictEqual(TokenDisplay[Token.SingleLineComment], ""); - assert.strictEqual(TokenDisplay[Token.MultiLineComment], ""); - assert.strictEqual(TokenDisplay[Token.NewLine], ""); - assert.strictEqual(TokenDisplay[Token.Whitespace], ""); - assert.strictEqual(TokenDisplay[Token.ConflictMarker], ""); - assert.strictEqual(TokenDisplay[Token.Identifier], ""); + assert.strictEqual(TokenDisplay[Token.None], "none"); + assert.strictEqual(TokenDisplay[Token.Invalid], "invalid"); + assert.strictEqual(TokenDisplay[Token.EndOfFile], "end of file"); + assert.strictEqual(TokenDisplay[Token.SingleLineComment], "single-line comment"); + assert.strictEqual(TokenDisplay[Token.MultiLineComment], "multi-line comment"); + assert.strictEqual(TokenDisplay[Token.NewLine], "newline"); + assert.strictEqual(TokenDisplay[Token.Whitespace], "whitespace"); + assert.strictEqual(TokenDisplay[Token.ConflictMarker], "conflict marker"); + assert.strictEqual(TokenDisplay[Token.Identifier], "identifier"); + }); + + // Search for Other_ID_Start in https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt + const otherIDStart = [0x1885, 0x1886, 0x2118, 0x212e, 0x309b, 0x309c]; + + // Search for Other_ID_Continue in https://www.unicode.org/Public/UCD/latest/ucd/PropList.txt + const otherIdContinue = [ + 0x00b7, + 0x0387, + 0x1369, + 0x136a, + 0x136b, + 0x136c, + 0x136d, + 0x136e, + 0x136f, + 0x1370, + 0x1371, + 0x19da, + ]; + + it("allows additional identifier start characters", () => { + assert(isIdentifierStart("$".codePointAt(0)!), "'$' should be allowed to start identifier."); + assert(isIdentifierStart("_".codePointAt(0)!), "'_' should be allowed to start identifier."); + + for (const codePoint of otherIDStart) { + assert( + isIdentifierStart(codePoint), + `U+${codePoint.toString(16)} should be allowed to start identifier.` + ); + } + }); + + it("allows additional identifier continuation characters", () => { + //prettier-ignore + assert(isIdentifierContinue("$".codePointAt(0)!), "'$' should be allowed to continue identifier."); + //prettier-ignore + assert(isIdentifierContinue("_".codePointAt(0)!), "'_' should be allowed to continue identifier."); + + for (const codePoint of [...otherIDStart, ...otherIdContinue]) { + assert( + isIdentifierContinue(codePoint), + `U+${codePoint.toString(16)} should be allowed to continue identifier.` + ); + } + + assert(isIdentifierContinue(0x200c), "U+200C (ZWNJ) should be allowed to continue identifier."); + assert(isIdentifierContinue(0x200d), "U+200D (ZWJ) should be allowed to continue identifier."); }); it("scans this file", async () => { diff --git a/packages/adl/tsconfig.json b/packages/adl/tsconfig.json index bf9521076..32ff9167e 100644 --- a/packages/adl/tsconfig.json +++ b/packages/adl/tsconfig.json @@ -6,5 +6,5 @@ "types": ["node", "mocha"] }, "include": ["./**/*.ts"], - "exclude": ["dist", "node_modules", "temp"] + "exclude": ["dist", "node_modules", "temp", "adl-output"] } diff --git a/packages/prettier-plugin-adl/CHANGELOG.json b/packages/prettier-plugin-adl/CHANGELOG.json new file mode 100644 index 000000000..24558c1f3 --- /dev/null +++ b/packages/prettier-plugin-adl/CHANGELOG.json @@ -0,0 +1,17 @@ +{ + "name": "@azure-tools/prettier-plugin-adl", + "entries": [ + { + "version": "0.1.1", + "tag": "@azure-tools/prettier-plugin-adl_v0.1.1", + "date": "Tue, 18 May 2021 23:43:31 GMT", + "comments": { + "dependency": [ + { + "comment": "Updating dependency \"@azure-tools/adl\" from `0.10.0` to `0.11.0`" + } + ] + } + } + ] +} diff --git a/packages/prettier-plugin-adl/CHANGELOG.md b/packages/prettier-plugin-adl/CHANGELOG.md new file mode 100644 index 000000000..2abda2bf7 --- /dev/null +++ b/packages/prettier-plugin-adl/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log - @azure-tools/prettier-plugin-adl + +This log was last generated on Tue, 18 May 2021 23:43:31 GMT and should not be manually modified. + +## 0.1.1 +Tue, 18 May 2021 23:43:31 GMT + +_Initial release_ + diff --git a/packages/prettier-plugin-adl/LICENSE b/packages/prettier-plugin-adl/LICENSE new file mode 100644 index 000000000..21071075c --- /dev/null +++ b/packages/prettier-plugin-adl/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/packages/prettier-plugin-adl/README.md b/packages/prettier-plugin-adl/README.md new file mode 100644 index 000000000..5e12ccec1 --- /dev/null +++ b/packages/prettier-plugin-adl/README.md @@ -0,0 +1,32 @@ +# Prettier Plugin for ADL + +## Requirements + +- **Using node 14 and above** +- For use in **VSCode**, use version `VSCode 1.56` and above. + +## Getting Started + +```bash +npm install --save-dev prettier @azure-tools/prettier-plugin-adl +``` + +You can now call prettier + +```bash +./node_modules/.bin/prettier --write '**/*.adl' +``` + +# Contributing + +This project welcomes contributions and suggestions. Most contributions require you to agree to a +Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us +the rights to use your contribution. For details, visit https://cla.microsoft.com. + +When you submit a pull request, a CLA-bot will automatically determine whether you need to provide +a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions +provided by the bot. You will only need to do this once across all repos using our CLA. + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). +For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/packages/prettier-plugin-adl/ThirdPartyNotices.txt b/packages/prettier-plugin-adl/ThirdPartyNotices.txt new file mode 100644 index 000000000..a2c885cf9 --- /dev/null +++ b/packages/prettier-plugin-adl/ThirdPartyNotices.txt @@ -0,0 +1,153 @@ +prettier-plugin-adl + +THIRD-PARTY SOFTWARE NOTICES AND INFORMATION +Do Not Translate or Localize + +This project incorporates components from the projects listed below. The +original copyright notices and the licenses under which Microsoft received such +components are set forth below. Microsoft reserves all rights not expressly +granted herein, whether by implication, estoppel or otherwise. + +1. function-bind version 1.1.1 (https://github.com/Raynos/function-bind) +2. has version 1.0.3 (https://github.com/tarruda/has) +3. is-core-module version 2.2.0 (https://github.com/inspect-js/is-core-module) +4. path-parse version 1.0.6 (https://github.com/jbgutierrez/path-parse) +5. resolve version 1.20.0 (https://github.com/browserify/resolve) + + +%% function-bind NOTICES AND INFORMATION BEGIN HERE +===================================================== +Copyright (c) 2013 Raynos. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +====================================================="); +END OF function-bind NOTICES AND INFORMATION + + +%% has NOTICES AND INFORMATION BEGIN HERE +===================================================== +Copyright (c) 2013 Thiago de Arruda + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +====================================================="); +END OF has NOTICES AND INFORMATION + + +%% is-core-module NOTICES AND INFORMATION BEGIN HERE +===================================================== +The MIT License (MIT) + +Copyright (c) 2014 Dave Justice + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +====================================================="); +END OF is-core-module NOTICES AND INFORMATION + + +%% path-parse NOTICES AND INFORMATION BEGIN HERE +===================================================== +The MIT License (MIT) + +Copyright (c) 2015 Javier Blanco + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +====================================================="); +END OF path-parse NOTICES AND INFORMATION + + +%% resolve NOTICES AND INFORMATION BEGIN HERE +===================================================== +MIT License + +Copyright (c) 2012 James Halliday + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +====================================================="); +END OF resolve NOTICES AND INFORMATION \ No newline at end of file diff --git a/packages/prettier-plugin-adl/package.json b/packages/prettier-plugin-adl/package.json new file mode 100644 index 000000000..788d4c186 --- /dev/null +++ b/packages/prettier-plugin-adl/package.json @@ -0,0 +1,30 @@ +{ + "name": "@azure-tools/prettier-plugin-adl", + "version": "0.1.1", + "description": "", + "main": "dist/index.js", + "scripts": { + "build": "rollup --config 2>&1 && npm run generate-third-party-notices", + "test": "mocha --timeout 5000 'test/**/*.js'", + "test-official": "mocha --timeout 5000 --forbid-only 'test/**/*.js'", + "generate-third-party-notices": "node ../../eng/scripts/generate-third-party-notices" + }, + "author": "Microsoft Corporation", + "license": "MIT", + "dependencies": { + "prettier": "~2.2.1" + }, + "devDependencies": { + "@azure-tools/adl": "0.11.0", + "@rollup/plugin-commonjs": "~17.1.0", + "@rollup/plugin-json": "~4.1.0", + "@rollup/plugin-node-resolve": "~11.2.0", + "@rollup/plugin-replace": "~2.4.2", + "mocha": "~8.3.2", + "rollup": "~2.41.4" + }, + "files": [ + "dist/**/*", + "ThirdPartyNotices.txt" + ] +} diff --git a/packages/prettier-plugin-adl/rollup.config.js b/packages/prettier-plugin-adl/rollup.config.js new file mode 100644 index 000000000..80e4166fd --- /dev/null +++ b/packages/prettier-plugin-adl/rollup.config.js @@ -0,0 +1,29 @@ +// @ts-check +import resolve from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; +import json from "@rollup/plugin-json"; +import replace from "@rollup/plugin-replace"; + +export default { + input: "src/index.js", + output: { + file: "dist/index.js", + format: "commonjs", + sourcemap: true, + exports: "default", + }, + context: "this", + external: ["fs/promises", "prettier"], + plugins: [ + resolve({ preferBuiltins: true }), + commonjs(), + json(), + replace({ + values: { + "export const adlVersion = getVersion();": `export const adlVersion = "";`, + }, + delimiters: ["", ""], + preventAssignment: true, + }), + ], +}; diff --git a/packages/prettier-plugin-adl/src/index.js b/packages/prettier-plugin-adl/src/index.js new file mode 100644 index 000000000..873b53ec8 --- /dev/null +++ b/packages/prettier-plugin-adl/src/index.js @@ -0,0 +1,2 @@ +const { ADLPrettierPlugin } = require("@azure-tools/adl"); +module.exports = ADLPrettierPlugin; diff --git a/packages/prettier-plugin-adl/test/smoke.js b/packages/prettier-plugin-adl/test/smoke.js new file mode 100644 index 000000000..46f806117 --- /dev/null +++ b/packages/prettier-plugin-adl/test/smoke.js @@ -0,0 +1,14 @@ +// Simple smoke test verifying the plugin is able to be loaded via prettier. +const prettier = require("prettier"); +const { strictEqual } = require("assert"); +const { resolve } = require("path"); + +describe("prettier-plugin: smoke test", () => { + it("loads and formats", () => { + const result = prettier.format("alias Foo = string;", { + parser: "adl", + plugins: [resolve(__dirname, "..")], + }); + strictEqual(result, "alias Foo = string;"); + }); +});