diff --git a/.chronus/changes/typespecApiview-2026-0-8-15-7-55.md b/.chronus/changes/typespecApiview-2026-0-8-15-7-55.md new file mode 100644 index 0000000000..d1372f9f3c --- /dev/null +++ b/.chronus/changes/typespecApiview-2026-0-8-15-7-55.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@azure-tools/typespec-apiview" +--- + +Migrate typespec-apiview from azure-sdk-tools repo. \ No newline at end of file diff --git a/.chronus/config.yaml b/.chronus/config.yaml index a310689384..6dc0c12e28 100644 --- a/.chronus/config.yaml +++ b/.chronus/config.yaml @@ -36,6 +36,7 @@ versionPolicies: type: lockstep step: minor packages: + - "@azure-tools/typespec-apiview" - "@azure-tools/typespec-autorest" - "@azure-tools/typespec-azure-core" - "@azure-tools/typespec-azure-portal-core" diff --git a/packages/typespec-apiview/.vscode/launch.json b/packages/typespec-apiview/.vscode/launch.json new file mode 100644 index 0000000000..c4a36890cc --- /dev/null +++ b/packages/typespec-apiview/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug TypeSpec APIView Emitter", + "program": "${workspaceFolder}/node_modules/@typespec/compiler/entrypoints/cli.js", + "args": [ + "compile", + "C:/repos/azure-rest-api-specs/specification/orbital/Microsoft.PlanetaryComputer/main.tsp", + "--emit=C:/repos/azure-sdk-tools/tools/apiview/emitters/typespec-apiview/dist/src/index.js" + ], + "smartStep": true, + "sourceMaps": true, + "skipFiles": ["/**/*.js"], + "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/node_modules/**/*.js"], + "cwd": "${workspaceFolder}", + "presentation": { + "order": 1 + } + } + ] +} diff --git a/packages/typespec-apiview/CHANGELOG.md b/packages/typespec-apiview/CHANGELOG.md new file mode 100644 index 0000000000..371e983694 --- /dev/null +++ b/packages/typespec-apiview/CHANGELOG.md @@ -0,0 +1,104 @@ +# Release History + +## Version 0.7.2 (05-14-2025) +Fixed issue where `cannot deindent with an indent` could cause the generator to crash. + +## Version 0.7.1 (05-14-2025) +Support new TypeSpec compiler. +Fixed issue where `HasSuffixSpace` could cause the generator to crash. + +## Version 0.7.0 (04-03-2025) +Support new TypeSpec compiler. + +## Version 0.6.0 (03-20-2025) +Support new TypeSpec compiler. +**BREAKING CHANGE**: Removed support for `--version` parameter. Multi-versioned specs will be emitter as-is. + +## Version 0.5.1 (02-27-2025) +Support new TypeSpec syntax. + +## Version 0.5.0 (01-08-2025) +Support new internal APIView tree-structure. + +## Version 0.4.9 (07-09-2024) +Fix issue where "unknown" was rendered as "any". +Support value syntax for objects and arrays. +Support const statements in namespaces. + +## Version 0.4.8 (04-18-2024) +Display suppressions in APIView. +Resolve visual anomalies. + +## Version 0.4.7 (03-22-2024) +Support TypeSpec string templates. +Fix display issue with templated aliases. +Ensure alias statements end with semicolon. + +## Version 0.4.6 (03-08-2024) +Support CrossLanguagePackageId. + +## Version 0.4.5 (01-29-2024) +Support named template arguments. +Support unnamed unions. +Support cross-language definition IDs. + +## Version 0.4.4 (04-18-2023) +Support future beta releases of TypeSpec. + +## Version 0.4.3 (04-17-2023) +Support latest release of TypeSpec. + +## Version 0.4.2 (03-16-2023) +Support latest release of TypeSpec. + +## Version 0.4.1 (03-13-2023) +Fixed issue where enums with spread members would cause the generator to crash. + +## Version 0.4.0 (03-06-2023) +Update for rename of Cadl to TypeSpec. + +## Version 0.3.5 (02-10-2023) +Support latest release of Cadl compiler. +**BREAKING CHANGE**: Removed the `--namespace` emitter option. +Added the `--service` emitter option to support filtering output for multi-service specs. +Emitter options `--output-file` and `--version` cannot be used with multi-service specs unless the + `--service` option is provided. +Added the `--include-global-namespace` option to permit including the global namespace in the token file. +Fixed issue where namespaces that are not proper subnamespaces may be included in the token file. + +## Version 0.3.4 (01-13-2023) +Support latest release of Cadl compiler. + +## Version 0.3.3 (01-03-2023) +Fixed issue where some type references were not navigable. + +## Version 0.3.2 (12-20-2022) +Changed structure of APIView navigation so that aliases appear under a separate "Alias" section, instead of + within the existing "Models" section. Will likely result in a non-API-related diff with prior APIView versions. + +## Version 0.3.1 (12-9-2022) +Support Cadl scalars. + +## Version 0.3.0 (11-15-2022) +Add support for aliases and augment decorators. + +## Version 0.2.1 (10-27-2022) +Change behavior of `version` emitter option so that if it is not supplied, APIView will be generated for the + un-projected Cadl, rendering all versioning decorators. Supplying `version` allows the user to project a + specific version. + +## Version 0.2.0 (10-26-2022) +Support `namespace` emitter option to filter the appropriate namespace when it cannot be automatically resolved. + This is primarily intended for creating APIViews for libraries. +Support `version` emitter option to choose which version of a multi-versioned spec to emit. Specs with a single + version can omit this. Multi-version specs can omit this if emitting the latest version. +No longer suppress `@doc`, `@summary`, and `@example` decorators. These can be toggled using the APIView UI. +Support rendering multi-line strings. +Change default path for generating artifacts. + +## Version 0.1.1 (10-13-2022) +Support compiler-level noEmit option. +Support `output-dir` emitter option. + +## Version 0.1.0 (10-5-2022) +Initial release. \ No newline at end of file diff --git a/packages/typespec-apiview/README.md b/packages/typespec-apiview/README.md new file mode 100644 index 0000000000..cf5839de2f --- /dev/null +++ b/packages/typespec-apiview/README.md @@ -0,0 +1,105 @@ +# TypeSpec APIView Emitter + +This package provides the [TypeSpec](https://github.com/microsoft/typespec) emitter to produce APIView token file output from TypeSpec source. + +## Install + +Add `@azure-tools/typespec-apiview` to your `package.json` and run `npm install`. + +## Emit APIView spec + +1. Via the command line + +```bash +tsp compile {path to typespec project} --emit=@azure-tools/typespec-apiview +``` + +2. Via the config + +Add the following to the `typespec-project.yaml` file. + +```yaml +emitters: + @azure-tools/typespec-apiview: true +``` + +For configuration [see options](#emitter-options) + +## Create API View + +1. Log in to the API View site (apiview.dev)[https://apiview.dev]. +2. Click the blue "Create Review" button in the bottom-right corner of the screen. +3. In the first block, load the token file generated by the `typespec-apiview` emitter. + +## Revise API View + +1. Log in to the API View site (apiview.dev)[https://apiview.dev]. +2. Navigate to your review. +3. Click the "Revisions" tab in the top left. +4. Click the blue "Add Revision" button in the bottom-right corner of the screen. +5. In the first block, load the token file generated by the `typespec-apiview` emitter. + +## Use APIView-specific decorators: + +Currently there are no APIView-specific decorators... + +## Emitter options: + +Emitter options can be configured via the `tspconfig.yaml` configuration: + +```yaml +emitters: + '@azure-tools/typespec-apiview': + : + + +# For example +emitters: + '@azure-tools/typespec-apiview': + output-file: my-custom-apiview.json +``` + +or via the command line with + +```bash +--option "@azure-tools/typespec-apiview.=" + +# For example +--option "@azure-tools/typespec-apiview.output-file=my-custom-apiview.json" +``` + +### `emitter-output-dir` + +Configure the name of the output directory. Default is `tsc-output/@azure-tools/typespec-apiview`. + +### `include-global-namespace` + +Normally, APIView will filter all namespaces and only output those in the service namespace and any +subnamespaces. This is to filter out types that come from the TypeSpec compiler and supporting libraries. +This setting, if `true`, tells APIView to output the contents of the global (empty) namespace, which +would normally be excluded. + +### `service` + +Filter output to a single service definition. If omitted, all service defintions will be +output as separate APIView token files. + +### `output-file` + +Configure the name of the output JSON token file relative to the `output-dir`. For multi-service +specs, this option cannot be supplied unless the `service` option is also set. If outputting +all services in a multi-service spec, the output filename will be the service root namespace with the +`-apiview.json` suffix. Otherwise, the default is `apiview.json`. + +### `version` + +For multi-versioned TypeSpec, this parameter is used to control which version to emit. This +is not required for single-version specs. For multi-versioned specs, the unprojected TypeSpec will +be rendered if this is not supplied. For multi-service specs, this option cannot be supplied +unless the `service` option is also set. + +## See also + +- [TypeSpec Getting Started](https://github.com/microsoft/typespec#getting-started) +- [TypeSpec Tutorial](https://github.com/microsoft/typespec/blob/main/docs/tutorial.md) +- [TypeSpec for the OpenAPI Developer](https://github.com/microsoft/typespec/blob/main/docs/typespec-for-openapi-dev.md) diff --git a/packages/typespec-apiview/cspell.yaml b/packages/typespec-apiview/cspell.yaml new file mode 100644 index 0000000000..8b6295d56b --- /dev/null +++ b/packages/typespec-apiview/cspell.yaml @@ -0,0 +1,69 @@ +version: "0.2" +language: en +allowCompoundWords: true +dictionaries: + - node + - typescript +words: + - autorest + - azsdkengsys + - azurecr + - blockful + - blockless + - typespec + - Contoso + - CRUDL + - devdriven + - dogfood + - eastus + - esbuild + - globby + - Hdvcmxk + - inmemory + - instanceid + - intrinsics + - ints + - jsyaml + - msbuild + - MSRC + - munge + - myconst + - mylib + - nostdlib + - oapi + - oneof + - onig + - onigasm + - onwarn + - openapi + - openapiv + - picocolors + - pnpm + - proto + - protobuf + - protoc + - regen + - rpaas + - rushx + - safeint + - strs + - TSES + - unassignable + - Uncapitalize + - uninstantiated + - unioned + - unprojected + - unsourced + - unversioned + - vsix + - vswhere + - westus + - xplat + - deindent +ignorePaths: + - "**/node_modules/**" + - "**/dist/**" + - "**/dist-dev/**" + - "**/tsp-output/**" +enableFiletypes: + - tsp diff --git a/packages/typespec-apiview/eslint.config.mjs b/packages/typespec-apiview/eslint.config.mjs new file mode 100644 index 0000000000..3202ab459d --- /dev/null +++ b/packages/typespec-apiview/eslint.config.mjs @@ -0,0 +1,18 @@ +// @ts-check +import tsEslint from "typescript-eslint"; +import { TypeSpecCommonEslintConfigs } from "../../core/eslint.config.js"; + +export default tsEslint.config( + { + ignores: ["dist/**"], + }, + ...TypeSpecCommonEslintConfigs, + { + rules: { + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, + }, +); diff --git a/packages/typespec-apiview/package.json b/packages/typespec-apiview/package.json new file mode 100644 index 0000000000..7d0b092005 --- /dev/null +++ b/packages/typespec-apiview/package.json @@ -0,0 +1,74 @@ +{ + "name": "@azure-tools/typespec-apiview", + "version": "0.7.2", + "author": "Microsoft Corporation", + "description": "Library for emitting APIView token files from TypeSpec", + "homepage": "https://azure.github.io/typespec-azure", + "readme": "https://github.com/Azure/typespec-azure/blob/main/packages/typespec-apiview/README.md", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Azure/typespec-azure.git" + }, + "bugs": { + "url": "https://github.com/Azure/typespec-azure/issues" + }, + "keywords": [ + "typespec", + "apiview" + ], + "type": "module", + "main": "dist/src/index.js", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + }, + "./testing": { + "types": "./dist/src/testing/index.d.ts", + "default": "./dist/src/testing/index.js" + } + }, + "tspMain": "dist/src/index.js", + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "clean": "rimraf ./dist ./temp", + "build": "tsc -p . && npm run lint-typespec-library", + "watch": "tsc -p . --watch", + "gen-extern-signature": "tspd --enable-experimental gen-extern-signature .", + "lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit", + "test": "vitest run", + "test:watch": "vitest -w", + "test:ui": "vitest --ui", + "test:ci": "vitest run --coverage --reporter=junit --reporter=default", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix" + }, + "files": [ + "lib/*.tsp", + "dist/**", + "!dist/test/**" + ], + "peerDependencies": { + "@typespec/compiler": "workspace:^", + "@typespec/versioning": "workspace:^" + }, + "devDependencies": { + "@azure-tools/typespec-azure-core": "workspace:^", + "@types/node": "~25.0.2", + "@typespec/compiler": "workspace:^", + "@typespec/http": "workspace:^", + "@typespec/library-linter": "workspace:^", + "@typespec/rest": "workspace:^", + "@typespec/tspd": "workspace:^", + "@typespec/versioning": "workspace:^", + "@vitest/coverage-v8": "^4.0.15", + "@vitest/ui": "^4.0.15", + "c8": "^10.1.3", + "rimraf": "~6.1.2", + "typescript": "~5.9.2", + "vitest": "^4.0.15" + } +} diff --git a/packages/typespec-apiview/src/apiview.ts b/packages/typespec-apiview/src/apiview.ts new file mode 100644 index 0000000000..e1df7827e0 --- /dev/null +++ b/packages/typespec-apiview/src/apiview.ts @@ -0,0 +1,1374 @@ +import { + Expression, + getNamespaceFullName, + getSourceLocation, + Namespace, + navigateProgram, + Program, +} from "@typespec/compiler"; +import { + AliasStatementNode, + ArrayExpressionNode, + ArrayLiteralNode, + AugmentDecoratorStatementNode, + BaseNode, + BooleanLiteralNode, + CallExpressionNode, + ConstStatementNode, + DecoratorExpressionNode, + DirectiveExpressionNode, + EnumMemberNode, + EnumSpreadMemberNode, + EnumStatementNode, + IdentifierNode, + InterfaceStatementNode, + IntersectionExpressionNode, + MemberExpressionNode, + ModelExpressionNode, + ModelPropertyNode, + ModelSpreadPropertyNode, + ModelStatementNode, + NumericLiteralNode, + ObjectLiteralNode, + ObjectLiteralPropertyNode, + ObjectLiteralSpreadPropertyNode, + OperationSignatureDeclarationNode, + OperationSignatureReferenceNode, + OperationStatementNode, + ScalarStatementNode, + StringLiteralNode, + StringTemplateExpressionNode, + StringTemplateHeadNode, + StringTemplateSpanNode, + SyntaxKind, + TemplateArgumentNode, + TemplateParameterDeclarationNode, + TupleExpressionNode, + TypeReferenceNode, + UnionExpressionNode, + UnionStatementNode, + UnionVariantNode, + ValueOfExpressionNode, +} from "@typespec/compiler/ast"; +import { generateId, NamespaceModel } from "./namespace-model.js"; +import { + CodeDiagnostic, + CodeDiagnosticLevel, + CodeFile, + NavigationItem, + ReviewLine, + ReviewToken, + ReviewTokenOptions, + TokenKind, +} from "./schemas.js"; +import { NamespaceStack, reviewLineText } from "./util.js"; +import { LIB_VERSION } from "./version.js"; + +export class ApiView { + name: string; + packageName: string; + crossLanguagePackageId: string | undefined; + /** Stores the current line. All helper methods append to this. */ + currentLine: ReviewLine; + /** Stores the parent of the current line. */ + currentParent: ReviewLine | undefined; + /** Stores the stack of parent lines. */ + parentStack: ReviewLine[]; + + reviewLines: ReviewLine[] = []; + navigationItems: NavigationItem[] = []; + diagnostics: CodeDiagnostic[] = []; + packageVersion: string; + + namespaceStack = new NamespaceStack(); + typeDeclarations = new Set(); + includeGlobalNamespace: boolean; + + constructor(name: string, packageName: string, includeGlobalNamespace?: boolean) { + this.name = name; + this.packageName = packageName; + this.packageVersion = "ALL"; + this.includeGlobalNamespace = includeGlobalNamespace ?? false; + this.crossLanguagePackageId = packageName; + this.currentLine = { + LineId: "", + CrossLanguageId: "", + Tokens: [], + Children: [], + }; + this.parentStack = []; + this.currentParent = undefined; + this.emitHeader(); + } + + /** Compiles the APIView model for output. */ + compile(program: Program) { + let allNamespaces = new Map(); + + // collect namespaces in program + navigateProgram(program, { + namespace(obj) { + const name = getNamespaceFullName(obj); + allNamespaces.set(name, obj); + }, + }); + allNamespaces = new Map([...allNamespaces].sort()); + + for (const [name, ns] of allNamespaces.entries()) { + if (!this.shouldEmitNamespace(name)) { + continue; + } + // use a fake name to make the global namespace clear + const namespaceName = name === "" ? "::GLOBAL::" : name; + const nsModel = new NamespaceModel(namespaceName, ns, program); + if (nsModel.shouldEmit()) { + this.tokenizeNamespaceModel(nsModel); + this.buildNavigation(nsModel); + } + } + // Enable this if desired to debug the output + //this.appendDebugInfo(); + } + + private appendDebugInfo() { + function processLines(lines: ReviewLine[]) { + for (const line of lines) { + const lineId = line.LineId; + const relatedLineId = line.RelatedToLine; + const isContextEnd = line.IsContextEndLine; + let string = ""; + if (lineId && lineId !== "") { + string += ` LINE_ID: ${lineId} `; + } + if (relatedLineId && relatedLineId !== "") { + string += ` RELATED_TO: ${relatedLineId} `; + } + if (isContextEnd) { + string += " IS_CONTEXT_END TRUE "; + } + if (string !== "") { + line.Tokens.push({ + Kind: TokenKind.Comment, + Value: `// ${string.trim()}`, + }); + } + processLines(line.Children); + } + } + + processLines(this.reviewLines); + } + + /** Attempts to resolve any type references marked as __MISSING__. */ + resolveMissingTypeReferences() { + for (const token of this.currentLine.Tokens) { + if (token.Kind === TokenKind.TypeName && token.NavigateToId === "__MISSING__") { + token.NavigateToId = this.definitionIdFor(token.Value!, this.packageName); + } + } + } + + /** Apply workarounds to the model before output */ + private adjustLines(lines: ReviewLine[]) { + let currentContext: string | undefined = undefined; + let contextMatchFound: boolean = false; + for (const line of lines) { + // run the normal adjust line logic + this.adjustLine(line); + + const lineId = line.LineId; + if (lineId === "Azure.Test.ConstrainedComplex") { + // Debugging hook + } + const relatedTo = line.RelatedToLine; + const isContextEnd = line.IsContextEndLine; + + if (isContextEnd && relatedTo) { + throw new Error("Context end line should not have a relatedTo line."); + } + + if (currentContext) { + if (lineId === currentContext) { + contextMatchFound = true; + line.RelatedToLine = undefined; + line.IsContextEndLine = false; + } + if (relatedTo && currentContext !== relatedTo) { + if (!contextMatchFound) { + // catches the scenario where the relatedTo line is different within what should be the current context + throw new Error("Mismatched contexts. Expected ${currentContext}, got ${lineId}"); + } else { + // covers the instance where there never is an IsContextEndLine set, which happens if there's no closing brace + // on a separate line + currentContext = relatedTo; + } + } else { + // key to this whole method. This copies RelatedToLine to all lines between the start and end of a context + line.RelatedToLine = currentContext; + } + if (isContextEnd) { + currentContext = undefined; + contextMatchFound = false; + line.RelatedToLine = undefined; + } + } else { + // if currentContext isn't set and we encounter a relatedTo line, set the current context + if (relatedTo) { + currentContext = relatedTo; + // if currentContext isn't set but there's a lineId with childrent, set the current context + } else if (lineId && line.Children.length > 0) { + currentContext = lineId; + contextMatchFound = true; + // If a context end is found without a start, then ignore the contextEnd + } else if (isContextEnd) { + line.IsContextEndLine = false; + } + } + } + } + + private adjustLine(line: ReviewLine) { + for (const token of line.Tokens) { + this.adjustToken(token); + } + this.adjustLines(line.Children); + } + + private adjustToken(token: ReviewToken) { + // the server has a bizarre "true" default for HasSuffixSpace that we + // need to account for. Also, we can delete the property if it's true + // since that's the server default. + token.HasSuffixSpace = token.HasSuffixSpace ?? false; + if (token.HasSuffixSpace) { + delete token.HasSuffixSpace; + } + } + + /** Output the APIView model to the CodeFile JSON format. */ + asCodeFile(): CodeFile { + this.adjustLines(this.reviewLines); + return { + Name: this.name, + PackageName: this.packageName, + PackageVersion: this.packageVersion, + ParserVersion: LIB_VERSION, + Language: "TypeSpec", + LanguageVariant: undefined, + CrossLanguagePackageId: this.crossLanguagePackageId, + ReviewLines: this.reviewLines, + Diagnostics: this.diagnostics, + Navigation: this.navigationItems, + }; + } + + /** Outputs the APIView model to a string approximation of what will display on the web. */ + asString(): string { + return this.reviewLines.map((l) => reviewLineText(l, 0)).join("\n"); + } + + private token(kind: TokenKind, value: string, options?: ReviewTokenOptions) { + this.currentLine.Tokens.push({ + Kind: kind, + Value: value, + ...options, + }); + } + + private indent() { + // ensure no trailing space at the end of the line + try { + const lastToken = this.currentLine.Tokens[this.currentLine.Tokens.length - 1]; + lastToken.HasSuffixSpace = false; + } catch { + // no tokens, so nothing to do + return; + } + + if (this.currentParent) { + this.currentParent.Children.push(this.currentLine); + this.parentStack.push(this.currentParent); + } else { + this.reviewLines.push(this.currentLine); + } + this.currentParent = this.currentLine; + this.currentLine = { + LineId: "", + CrossLanguageId: "", + Tokens: [], + Children: [], + }; + } + + private deindent() { + if (!this.currentParent) { + return; + } + // Ensure that the last line before the deindent has no blank lines + const lastChild = this.currentParent.Children.pop(); + if (lastChild && lastChild.Tokens.length > 0) { + this.currentParent.Children.push(lastChild); + } + this.currentParent = this.parentStack.pop(); + this.currentLine = { + LineId: "", + CrossLanguageId: "", + Tokens: [], + Children: [], + }; + } + + /** Set the exact number of desired newlines. */ + private blankLines(count: number = 0) { + this.newline(); + const parentLines = this.currentParent ? this.currentParent.Children : this.reviewLines; + let newlineCount = 0; + if (parentLines.length) { + for (let i = parentLines.length - 1; i >= 0; i--) { + if (parentLines[i].Tokens.length === 0) { + newlineCount++; + } else { + break; + } + } + } + if (newlineCount === count) { + return; + } else if (newlineCount > count) { + const toRemove = newlineCount - count; + for (let i = 0; i < toRemove; i++) { + parentLines.pop(); + } + } else { + for (let i = newlineCount; i < count; i++) { + this.newline(); + } + } + } + + private newline() { + // ensure no trailing space at the end of the line + if (this.currentLine.Tokens.length > 0) { + const lastToken = this.currentLine.Tokens[this.currentLine.Tokens.length - 1]; + lastToken.HasSuffixSpace = false; + const firstToken = this.currentLine.Tokens[0]; + firstToken.HasPrefixSpace = false; + } + + if (this.currentParent) { + this.currentParent.Children.push(this.currentLine); + } else { + this.reviewLines.push(this.currentLine); + } + this.currentLine = { + LineId: "", + CrossLanguageId: "", + Tokens: [], + Children: [], + }; + } + + private getLastLine(): ReviewLine | undefined { + if (!this.currentParent) { + return undefined; + } + const lastChild = this.currentParent.Children[this.currentParent.Children.length - 1]; + const lastGrandchild = lastChild?.Children[lastChild.Children.length - 1]; + if (lastGrandchild?.Children.length > 0) { + throw new Error("Unexpected great-grandchild in getLastLine()!"); + } + return lastGrandchild ?? lastChild; + } + + /** + * Places the provided token in the tree based on the provided characters. + * param token The token to snap. + * param characters The characters to snap to. + */ + private snapToken(punctuationToken: ReviewToken, characters: string) { + const allowed = new Set(characters.split("")); + const lastLine = this.getLastLine() ?? this.currentLine; + + // iterate through tokens in reverse order + for (let i = lastLine.Tokens.length - 1; i >= 0; i--) { + const token = lastLine.Tokens[i]; + if (token.Kind === TokenKind.Text) { + // skip blank whitespace tokens + const value = token.Value.trim(); + if (value.length === 0) { + continue; + } else { + // no snapping, so render in place + this.currentLine.Tokens.push(punctuationToken); + return; + } + } else if (token.Kind === TokenKind.Punctuation) { + // ensure no whitespace after the trim character + if (allowed.has(token.Value)) { + token.HasSuffixSpace = false; + punctuationToken.HasSuffixSpace = false; + lastLine.Tokens.push(punctuationToken); + } else { + // no snapping, so render in place + this.currentLine.Tokens.push(punctuationToken); + return; + } + } else { + // no snapping, so render in place + this.currentLine.Tokens.push(punctuationToken); + return; + } + } + } + + private lineMarker(options?: { + value?: string; + addCrossLanguageId?: boolean; + relatedLineId?: string; + }) { + this.currentLine.LineId = options?.value ?? this.namespaceStack.value(); + this.currentLine.CrossLanguageId = options?.addCrossLanguageId + ? (options?.value ?? this.namespaceStack.value()) + : undefined; + this.currentLine.RelatedToLine = options?.relatedLineId; + } + + private punctuation( + value: string, + options?: ReviewTokenOptions & { snapTo?: string; isContextEndLine?: boolean }, + ) { + const snapTo = options?.snapTo; + delete options?.snapTo; + const isContextEndLine = options?.isContextEndLine; + delete options?.isContextEndLine; + + const token = { + Kind: TokenKind.Punctuation, + Value: value, + ...options, + }; + + if (snapTo) { + this.snapToken(token, snapTo); + } else { + this.currentLine.Tokens.push(token); + } + if (isContextEndLine) { + this.currentLine.IsContextEndLine = true; + } + } + + private text(text: string, options?: ReviewTokenOptions) { + this.token(TokenKind.Text, text, options); + } + + private keyword(keyword: string, options?: ReviewTokenOptions) { + this.token(TokenKind.Keyword, keyword, options); + } + + private typeDeclaration( + typeName: string, + typeId: string | undefined, + addCrossLanguageId: boolean, + options?: ReviewTokenOptions, + ) { + if (typeId) { + if (this.typeDeclarations.has(typeId)) { + throw new Error(`Duplication ID "${typeId}" for declaration will result in bugs.`); + } + this.typeDeclarations.add(typeId); + } + this.lineMarker({ value: typeId, addCrossLanguageId: true }); + this.token(TokenKind.TypeName, typeName, options); + } + + private typeReference(typeName: string, options?: ReviewTokenOptions) { + options = options ?? {}; + options.NavigateToId = options.NavigateToId ?? "__MISSING__"; + this.token(TokenKind.TypeName, typeName, { ...options }); + } + + private member(name: string, options?: ReviewTokenOptions) { + this.token(TokenKind.MemberName, name, options); + } + + private stringLiteral(value: string, options?: ReviewTokenOptions) { + const lines = value.split("\n"); + if (lines.length === 1) { + this.currentLine.Tokens.push({ + Kind: TokenKind.StringLiteral, + Value: `\u0022${value}\u0022`, + ...options, + }); + } else { + this.punctuation(`"""`, options); + this.newline(); + for (const line of lines) { + this.literal(line, options); + this.newline(); + } + this.punctuation(`"""`, options); + } + } + + private literal(value: string, options?: ReviewTokenOptions) { + this.token(TokenKind.StringLiteral, value, options); + } + + private diagnostic(message: string, targetId: string, level: CodeDiagnosticLevel) { + this.diagnostics.push({ + Text: message, + TargetId: targetId, + Level: level, + }); + } + + private shouldEmitNamespace(name: string): boolean { + if (name === "" && this.includeGlobalNamespace) { + return true; + } + if (name === this.packageName) { + return true; + } + if (!name.startsWith(this.packageName)) { + return false; + } + const suffix = name.substring(this.packageName.length); + return suffix.startsWith("."); + } + + private emitHeader() { + const toolVersion = LIB_VERSION; + const headerText = `// Package parsed using @azure-tools/typespec-apiview (version:${toolVersion})`; + this.literal(headerText, { SkipDiff: true }); + this.namespaceStack.push("GLOBAL"); + this.lineMarker(); + this.namespaceStack.pop(); + // TODO: Source URL? + this.blankLines(2); + } + + private tokenize(node: BaseNode) { + let obj; + let last = 0; // track the final index of an array + let parentNamespace: string; + switch (node.kind) { + case SyntaxKind.AliasStatement: + obj = node as AliasStatementNode; + this.namespaceStack.push(obj.id.sv); + this.keyword("alias", { HasSuffixSpace: true }); + this.typeDeclaration(obj.id.sv, this.namespaceStack.value(), true); + this.tokenizeTemplateParameters(obj.templateParameters); + this.punctuation("=", { HasSuffixSpace: true, HasPrefixSpace: true }); + this.tokenize(obj.value); + this.namespaceStack.pop(); + break; + case SyntaxKind.ArrayExpression: + obj = node as ArrayExpressionNode; + this.tokenize(obj.elementType); + this.punctuation("[]"); + break; + case SyntaxKind.ArrayLiteral: + obj = node as ArrayLiteralNode; + this.punctuation("#["); + last = obj.values.length - 1; + obj.values.forEach((val, i) => { + this.tokenize(val); + if (i !== last) { + this.punctuation(",", { HasSuffixSpace: true }); + } + }); + this.punctuation("]"); + break; + case SyntaxKind.AugmentDecoratorStatement: + obj = node as AugmentDecoratorStatementNode; + const decoratorName = this.getNameForNode(obj.target); + this.namespaceStack.push(decoratorName); + this.punctuation("@@"); + this.tokenizeIdentifier(obj.target, "keyword"); + this.lineMarker(); + if (obj.arguments.length) { + const last = obj.arguments.length - 1; + this.punctuation("("); + this.tokenize(obj.targetType); + if (obj.arguments.length) { + this.punctuation(",", { HasSuffixSpace: true }); + } + for (let x = 0; x < obj.arguments.length; x++) { + const arg = obj.arguments[x]; + this.tokenize(arg); + if (x !== last) { + this.punctuation(",", { HasSuffixSpace: true }); + } + } + this.punctuation(")"); + this.namespaceStack.pop(); + } + break; + case SyntaxKind.BooleanLiteral: + obj = node as BooleanLiteralNode; + this.literal(obj.value.toString()); + break; + case SyntaxKind.BlockComment: + throw new Error(`Case "BlockComment" not implemented`); + case SyntaxKind.TypeSpecScript: + throw new Error(`Case "TypeSpecScript" not implemented`); + case SyntaxKind.ConstStatement: + obj = node as ConstStatementNode; + this.namespaceStack.push(obj.id.sv); + this.keyword("const", { HasSuffixSpace: true }); + this.tokenizeIdentifier(obj.id, "declaration"); + this.punctuation("=", { HasSuffixSpace: true, HasPrefixSpace: true }); + this.tokenize(obj.value); + this.namespaceStack.pop(); + break; + case SyntaxKind.DecoratorExpression: + obj = node as DecoratorExpressionNode; + parentNamespace = this.namespaceStack.value(); + this.namespaceStack.push(generateId(obj)!); + this.punctuation("@"); + this.tokenizeIdentifier(obj.target, "keyword"); + this.lineMarker({ relatedLineId: parentNamespace }); + if (obj.arguments.length) { + last = obj.arguments.length - 1; + this.punctuation("("); + for (let x = 0; x < obj.arguments.length; x++) { + const arg = obj.arguments[x]; + this.tokenize(arg); + if (x !== last) { + this.punctuation(",", { HasSuffixSpace: true }); + } + } + this.punctuation(")"); + } + this.namespaceStack.pop(); + break; + case SyntaxKind.DirectiveExpression: + obj = node as DirectiveExpressionNode; + parentNamespace = this.namespaceStack.value(); + this.namespaceStack.push(generateId(node)!); + this.lineMarker({ relatedLineId: parentNamespace }); + this.keyword(`#${obj.target.sv}`, { HasSuffixSpace: true }); + for (const arg of obj.arguments) { + switch (arg.kind) { + case SyntaxKind.StringLiteral: + this.stringLiteral(arg.value, { HasSuffixSpace: true }); + break; + case SyntaxKind.Identifier: + this.stringLiteral(arg.sv, { HasSuffixSpace: true }); + break; + } + } + this.newline(); + this.namespaceStack.pop(); + break; + case SyntaxKind.EmptyStatement: + throw new Error(`Case "EmptyStatement" not implemented`); + case SyntaxKind.EnumMember: + obj = node as EnumMemberNode; + this.tokenizeDecoratorsAndDirectives(obj.decorators, obj.directives, false); + this.tokenizeIdentifier(obj.id, "member"); + this.lineMarker({ addCrossLanguageId: true }); + if (obj.value) { + this.punctuation(":", { HasSuffixSpace: true }); + this.tokenize(obj.value); + } + break; + case SyntaxKind.EnumSpreadMember: + obj = node as EnumSpreadMemberNode; + this.punctuation("..."); + this.tokenize(obj.target); + this.lineMarker(); + break; + case SyntaxKind.EnumStatement: + this.tokenizeEnumStatement(node as EnumStatementNode); + break; + case SyntaxKind.JsNamespaceDeclaration: + throw new Error(`Case "JsNamespaceDeclaration" not implemented`); + case SyntaxKind.JsSourceFile: + throw new Error(`Case "JsSourceFile" not implemented`); + case SyntaxKind.Identifier: + obj = node as IdentifierNode; + const id = this.namespaceStack.value(); + this.typeReference(obj.sv, { NavigateToId: id }); + break; + case SyntaxKind.ImportStatement: + throw new Error(`Case "ImportStatement" not implemented`); + case SyntaxKind.IntersectionExpression: + obj = node as IntersectionExpressionNode; + for (let x = 0; x < obj.options.length; x++) { + const opt = obj.options[x]; + this.tokenize(opt); + if (x !== obj.options.length - 1) { + this.punctuation("&", { HasPrefixSpace: true, HasSuffixSpace: true }); + } + } + break; + case SyntaxKind.InterfaceStatement: + this.tokenizeInterfaceStatement(node as InterfaceStatementNode); + break; + case SyntaxKind.InvalidStatement: + throw new Error(`Case "InvalidStatement" not implemented`); + case SyntaxKind.LineComment: + throw new Error(`Case "LineComment" not implemented`); + case SyntaxKind.MemberExpression: + this.tokenizeIdentifier(node as MemberExpressionNode, "reference"); + break; + case SyntaxKind.ModelExpression: + this.indent(); + this.tokenizeModelExpression(node as ModelExpressionNode, { isOperationSignature: false }); + this.deindent(); + break; + case SyntaxKind.ModelProperty: + this.tokenizeModelProperty(node as ModelPropertyNode, false); + break; + case SyntaxKind.ModelSpreadProperty: + obj = node as ModelSpreadPropertyNode; + this.punctuation("..."); + this.tokenize(obj.target); + this.lineMarker(); + break; + case SyntaxKind.ModelStatement: + obj = node as ModelStatementNode; + this.tokenizeModelStatement(obj); + break; + case SyntaxKind.NamespaceStatement: + throw new Error(`Case "NamespaceStatement" not implemented`); + case SyntaxKind.NeverKeyword: + this.keyword("never"); + break; + case SyntaxKind.NumericLiteral: + obj = node as NumericLiteralNode; + this.literal(obj.value.toString()); + break; + case SyntaxKind.ObjectLiteral: + obj = node as ObjectLiteralNode; + this.punctuation("#{"); + this.indent(); + last = obj.properties.length - 1; + obj.properties.forEach((prop, i) => { + this.tokenize(prop); + if (i !== last) { + this.punctuation(",", { HasSuffixSpace: false }); + } + this.newline(); + }); + this.deindent(); + this.punctuation("}"); + break; + case SyntaxKind.ObjectLiteralProperty: + obj = node as ObjectLiteralPropertyNode; + this.tokenizeIdentifier(obj.id, "member"); + this.punctuation(":", { HasSuffixSpace: true }); + this.tokenize(obj.value); + break; + case SyntaxKind.ObjectLiteralSpreadProperty: + obj = node as ObjectLiteralSpreadPropertyNode; + // TODO: Whenever there is an example? + throw new Error(`Case "ObjectLiteralSpreadProperty" not implemented`); + case SyntaxKind.OperationStatement: + this.tokenizeOperationStatement(node as OperationStatementNode); + break; + case SyntaxKind.OperationSignatureDeclaration: + obj = node as OperationSignatureDeclarationNode; + this.punctuation("("); + if (obj.parameters.properties.length) { + this.indent(); + this.tokenizeModelExpression(obj.parameters, { isOperationSignature: true }); + this.deindent(); + } + this.punctuation("):", { HasSuffixSpace: true }); + this.tokenizeReturnType(obj, { isExpanded: true }); + break; + case SyntaxKind.OperationSignatureReference: + obj = node as OperationSignatureReferenceNode; + this.keyword("is", { HasPrefixSpace: true, HasSuffixSpace: true }); + this.tokenize(obj.baseOperation); + break; + case SyntaxKind.Return: + throw new Error(`Case "Return" not implemented`); + case SyntaxKind.StringLiteral: + obj = node as StringLiteralNode; + this.stringLiteral(obj.value); + break; + case SyntaxKind.ScalarStatement: + this.tokenizeScalarStatement(node as ScalarStatementNode); + break; + case SyntaxKind.TemplateParameterDeclaration: + obj = node as TemplateParameterDeclarationNode; + this.tokenize(obj.id); + if (obj.constraint) { + this.keyword("extends", { HasSuffixSpace: true, HasPrefixSpace: true }); + this.tokenize(obj.constraint); + } + if (obj.default) { + this.punctuation("=", { HasSuffixSpace: true, HasPrefixSpace: true }); + this.tokenize(obj.default); + } + break; + case SyntaxKind.TupleExpression: + obj = node as TupleExpressionNode; + this.punctuation("[", { HasSuffixSpace: true }); + for (let x = 0; x < obj.values.length; x++) { + const val = obj.values[x]; + this.tokenize(val); + if (x !== obj.values.length - 1) { + this.punctuation(",", { HasSuffixSpace: true }); + } + } + this.punctuation("]"); + break; + case SyntaxKind.TypeReference: + obj = node as TypeReferenceNode; + this.tokenizeIdentifier(obj.target, "reference"); + this.tokenizeTemplateInstantiation(obj); + break; + case SyntaxKind.UnionExpression: + obj = node as UnionExpressionNode; + for (let x = 0; x < obj.options.length; x++) { + const opt = obj.options[x]; + this.tokenize(opt); + if (x !== obj.options.length - 1) { + this.punctuation("|", { HasPrefixSpace: true, HasSuffixSpace: true }); + } + } + break; + case SyntaxKind.UnionStatement: + this.tokenizeUnionStatement(node as UnionStatementNode); + break; + case SyntaxKind.UnionVariant: + this.tokenizeUnionVariant(node as UnionVariantNode); + break; + case SyntaxKind.UnknownKeyword: + this.keyword("unknown"); + break; + case SyntaxKind.UsingStatement: + throw new Error(`Case "UsingStatement" not implemented`); + case SyntaxKind.ValueOfExpression: + this.keyword("valueof", { HasSuffixSpace: true }); + this.tokenize((node as ValueOfExpressionNode).target); + break; + case SyntaxKind.VoidKeyword: + this.keyword("void"); + break; + case SyntaxKind.TemplateArgument: + break; + case SyntaxKind.StringTemplateExpression: + obj = node as StringTemplateExpressionNode; + const stringValue = this.buildTemplateString(obj); + const multiLine = stringValue.includes("\n"); + // single line case + if (!multiLine) { + this.stringLiteral(stringValue); + break; + } + // otherwise multiline case + const lines = stringValue.split("\n"); + this.punctuation(`"""`); + this.indent(); + for (const line of lines) { + this.literal(line); + this.newline(); + } + this.deindent(); + this.punctuation(`"""`); + break; + case SyntaxKind.StringTemplateSpan: + obj = node as StringTemplateSpanNode; + this.punctuation("${"); + this.tokenize(obj.expression); + this.punctuation("}"); + this.tokenize(obj.literal); + break; + case SyntaxKind.StringTemplateHead: + case SyntaxKind.StringTemplateMiddle: + case SyntaxKind.StringTemplateTail: + obj = node as StringTemplateHeadNode; + this.literal(obj.value); + break; + case SyntaxKind.CallExpression: + obj = node as CallExpressionNode; + this.tokenize(obj.target); + this.punctuation("(", { HasSuffixSpace: false }); + for (let x = 0; x < obj.arguments.length; x++) { + const arg = obj.arguments[x]; + this.tokenize(arg); + if (x !== obj.arguments.length - 1) { + this.punctuation(",", { HasSuffixSpace: true, snapTo: "}" }); + } + } + this.punctuation(")"); + break; + default: + // All Projection* cases should fail here... + throw new Error(`Case "${SyntaxKind[node.kind].toString()}" not implemented`); + } + } + + private tokenizeTemplateInstantiation(obj: TypeReferenceNode) { + if (!obj.arguments.length) { + return; + } + + // if any argument is a ModelExpression, then we need to expand the template to multiple lines + const isExpanded = obj.arguments.some( + (arg) => arg.argument.kind === SyntaxKind.ModelExpression, + ); + + this.punctuation("<"); + if (isExpanded) { + this.indent(); + } + for (let x = 0; x < obj.arguments.length; x++) { + const arg = obj.arguments[x]; + this.tokenizeTemplateArgument(arg); + if (x !== obj.arguments.length - 1) { + this.punctuation(",", { HasSuffixSpace: true, snapTo: "}" }); + if (isExpanded && arg.argument.kind) { + this.blankLines(0); + } + } + } + if (isExpanded) { + this.newline(); + this.deindent(); + } + this.punctuation(">"); + } + + private buildExpressionString(node: Expression) { + switch (node.kind) { + case SyntaxKind.StringLiteral: + return `"${(node as StringLiteralNode).value}"`; + case SyntaxKind.NumericLiteral: + return (node as NumericLiteralNode).value.toString(); + case SyntaxKind.BooleanLiteral: + return (node as BooleanLiteralNode).value.toString(); + case SyntaxKind.StringTemplateExpression: + return this.buildTemplateString(node as StringTemplateExpressionNode); + case SyntaxKind.VoidKeyword: + return "void"; + case SyntaxKind.NeverKeyword: + return "never"; + case SyntaxKind.TypeReference: + const obj = node as TypeReferenceNode; + switch (obj.target.kind) { + case SyntaxKind.Identifier: + return (obj.target as IdentifierNode).sv; + case SyntaxKind.MemberExpression: + return this.getFullyQualifiedIdentifier(obj.target as MemberExpressionNode); + } + break; + default: + throw new Error(`Unsupported expression kind: ${SyntaxKind[node.kind]}`); + //unsupported ArrayExpressionNode | MemberExpressionNode | ModelExpressionNode | TupleExpressionNode | UnionExpressionNode | IntersectionExpressionNode | TypeReferenceNode | ValueOfExpressionNode | AnyKeywordNode; + } + } + + /** Constructs a single string with template markers. */ + private buildTemplateString(node: StringTemplateExpressionNode): string { + let result = node.head.value; + for (const span of node.spans) { + result += "${" + this.buildExpressionString(span.expression) + "}"; + result += span.literal.value; + } + return result; + } + + private tokenizeModelStatement(node: ModelStatementNode) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecoratorsAndDirectives(node.decorators, node.directives, false); + this.keyword("model", { HasSuffixSpace: true }); + this.tokenizeIdentifier(node.id, "declaration"); + if (node.extends) { + this.keyword("extends", { HasSuffixSpace: true, HasPrefixSpace: true }); + this.tokenize(node.extends); + } + if (node.is) { + this.keyword("is", { HasPrefixSpace: true, HasSuffixSpace: true }); + this.tokenize(node.is); + } + this.tokenizeTemplateParameters(node.templateParameters); + if (node.properties.length) { + this.punctuation("{", { HasPrefixSpace: true }); + this.indent(); + for (const prop of node.properties) { + const propName = this.getNameForNode(prop); + this.namespaceStack.push(propName); + this.tokenize(prop); + this.punctuation(";"); + this.namespaceStack.pop(); + this.newline(); + } + this.deindent(); + this.punctuation("}", { isContextEndLine: true }); + } else { + this.punctuation("{}", { HasPrefixSpace: true }); + } + this.namespaceStack.pop(); + } + + private tokenizeScalarStatement(node: ScalarStatementNode) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecoratorsAndDirectives(node.decorators, node.directives, false); + this.keyword("scalar", { HasSuffixSpace: true }); + this.tokenizeIdentifier(node.id, "declaration"); + if (node.extends) { + this.keyword("extends", { HasSuffixSpace: true, HasPrefixSpace: true }); + this.tokenize(node.extends); + } + this.tokenizeTemplateParameters(node.templateParameters); + this.newline(); + this.namespaceStack.pop(); + } + + private tokenizeInterfaceStatement(node: InterfaceStatementNode) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecoratorsAndDirectives(node.decorators, node.directives, false); + this.keyword("interface", { HasSuffixSpace: true }); + this.tokenizeIdentifier(node.id, "declaration"); + this.tokenizeTemplateParameters(node.templateParameters); + this.punctuation("{", { HasPrefixSpace: true }); + this.indent(); + for (let x = 0; x < node.operations.length; x++) { + const op = node.operations[x]; + this.tokenizeOperationStatement(op, true); + this.blankLines(1); + } + this.deindent(); + this.punctuation("}", { isContextEndLine: true }); + this.namespaceStack.pop(); + } + + private tokenizeEnumStatement(node: EnumStatementNode) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecoratorsAndDirectives(node.decorators, node.directives, false); + this.keyword("enum", { HasSuffixSpace: true }); + this.tokenizeIdentifier(node.id, "declaration"); + this.punctuation("{", { HasPrefixSpace: true }); + this.indent(); + for (const member of node.members) { + const memberName = this.getNameForNode(member); + this.namespaceStack.push(memberName); + this.tokenize(member); + this.punctuation(","); + this.namespaceStack.pop(); + this.newline(); + } + this.deindent(); + this.punctuation("}", { isContextEndLine: true }); + this.namespaceStack.pop(); + } + + private tokenizeUnionStatement(node: UnionStatementNode) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecoratorsAndDirectives(node.decorators, node.directives, false); + this.keyword("union", { HasSuffixSpace: true }); + this.tokenizeIdentifier(node.id, "declaration"); + this.punctuation("{", { HasPrefixSpace: true }); + this.indent(); + for (let x = 0; x < node.options.length; x++) { + const variant = node.options[x]; + const variantName = this.getNameForNode(variant); + this.namespaceStack.push(variantName); + this.tokenize(variant); + this.namespaceStack.pop(); + if (x !== node.options.length - 1) { + this.punctuation(","); + } + this.newline(); + } + this.deindent(); + this.punctuation("}", { isContextEndLine: true }); + this.namespaceStack.pop(); + } + + private tokenizeUnionVariant(node: UnionVariantNode) { + this.tokenizeDecoratorsAndDirectives(node.decorators, node.directives, false); + if (node.id !== undefined) { + this.tokenizeIdentifier(node.id, "member"); + this.punctuation(":", { HasSuffixSpace: true }); + } + this.lineMarker({ addCrossLanguageId: true }); + this.tokenize(node.value); + } + + private tokenizeModelProperty(node: ModelPropertyNode, inline: boolean) { + this.tokenizeDecoratorsAndDirectives(node.decorators, node.directives, inline); + this.tokenizeIdentifier(node.id, "member"); + this.lineMarker(); + this.punctuation(node.optional ? "?:" : ":", { HasSuffixSpace: true }); + this.tokenize(node.value); + if (node.default) { + this.punctuation("=", { HasSuffixSpace: true, HasPrefixSpace: true }); + this.tokenize(node.default); + } + } + + /** Expands and tokenizes a model expression (anonymous model) */ + private tokenizeModelExpression( + node: ModelExpressionNode, + options: { isOperationSignature: boolean }, + ) { + const isOperationSignature = options.isOperationSignature; + + // display {} for empty model or nothing for empty operation signature + if (!node.properties.length) { + if (!isOperationSignature) { + this.punctuation("{}", { HasPrefixSpace: true }); + } + return; + } + + if (!isOperationSignature) { + this.punctuation("{"); + this.indent(); + } + this.namespaceStack.push("anonymous"); + for (let x = 0; x < node.properties.length; x++) { + const prop = node.properties[x]; + const propName = this.getNameForNode(prop); + this.namespaceStack.push(propName); + switch (prop.kind) { + case SyntaxKind.ModelProperty: + this.tokenizeModelProperty(prop, false); + break; + case SyntaxKind.ModelSpreadProperty: + this.tokenize(prop); + } + this.namespaceStack.pop(); + if (isOperationSignature) { + if (x !== node.properties.length - 1) { + this.punctuation(",", { HasSuffixSpace: true, snapTo: "}" }); + } + } else { + this.punctuation(";", { HasSuffixSpace: true, snapTo: "}" }); + } + this.blankLines(0); + } + if (!isOperationSignature) { + this.deindent(); + this.punctuation("}"); + this.newline(); + } + this.namespaceStack.pop(); + } + + private tokenizeOperationStatement( + node: OperationStatementNode, + suppressOpKeyword: boolean = false, + ) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecoratorsAndDirectives(node.decorators, node.directives, false); + if (!suppressOpKeyword) { + this.keyword("op", { HasSuffixSpace: true }); + } + this.tokenizeIdentifier(node.id, "declaration"); + this.tokenizeTemplateParameters(node.templateParameters); + this.tokenize(node.signature); + this.punctuation(";", { isContextEndLine: true }); + this.namespaceStack.pop(); + } + + private tokenizeNamespaceModel(model: NamespaceModel) { + this.namespaceStack.push(model.name); + if (model.node.kind === SyntaxKind.NamespaceStatement) { + this.tokenizeDecoratorsAndDirectives(model.node.decorators, model.node.directives, false); + } + this.keyword("namespace", { HasSuffixSpace: true }); + this.typeDeclaration(model.name, this.namespaceStack.value(), true, { HasSuffixSpace: true }); + this.punctuation("{", { HasPrefixSpace: true }); + this.indent(); + for (const node of model.augmentDecorators) { + this.tokenize(node); + this.blankLines(1); + } + for (const node of model.operations.values()) { + this.tokenize(node); + this.blankLines(1); + } + for (const node of model.resources.values()) { + this.tokenize(node); + this.blankLines(1); + } + for (const node of model.models.values()) { + this.tokenize(node); + this.blankLines(1); + } + for (const node of model.aliases.values()) { + this.tokenize(node); + this.punctuation(";"); + this.blankLines(1); + } + for (const node of model.constants.values()) { + this.tokenize(node); + this.punctuation(";"); + this.blankLines(0); + } + this.deindent(); + this.punctuation("}", { isContextEndLine: true }); + this.blankLines(1); + this.namespaceStack.pop(); + } + + private tokenizeDecoratorsAndDirectives( + decorators: readonly DecoratorExpressionNode[] | undefined, + directives: readonly DirectiveExpressionNode[] | undefined, + inline: boolean, + ) { + const docDecorators = ["doc", "summary", "example"]; + if ((directives || []).length === 0 && (decorators || []).length === 0) { + return; + } + for (const directive of directives ?? []) { + this.tokenize(directive); + } + // render each decorator + for (const node of decorators || []) { + const isDoc = docDecorators.includes((node.target as IdentifierNode).sv); + this.tokenize(node); + if (isDoc) { + // if any token in a line is documentation, then the whole line is + for (const token of this.currentLine.Tokens) { + token.IsDocumentation = true; + } + } + if (!inline) { + this.newline(); + } + } + } + + private getFullyQualifiedIdentifier(node: MemberExpressionNode, suffix?: string): string { + switch (node.base.kind) { + case SyntaxKind.Identifier: + return `${node.base.sv}.${suffix}`; + case SyntaxKind.MemberExpression: + return this.getFullyQualifiedIdentifier(node.base, `${node.base.id.sv}.${suffix}`); + } + } + + private tokenizeIdentifier( + node: IdentifierNode | MemberExpressionNode | StringLiteralNode, + style: "declaration" | "reference" | "member" | "keyword", + ) { + switch (node.kind) { + case SyntaxKind.MemberExpression: + const defId = this.getFullyQualifiedIdentifier(node, node.id.sv); + switch (style) { + case "reference": + this.typeReference(defId); + break; + case "member": + this.member(defId); + break; + case "keyword": + this.keyword(defId); + break; + case "declaration": + throw new Error(`MemberExpression cannot be a "declaration".`); + } + break; + case SyntaxKind.StringLiteral: + if (style !== "member") { + throw new Error(`StringLiteral type can only be a member name. Unexpectedly "${style}"`); + } + this.stringLiteral(node.value); + break; + case SyntaxKind.Identifier: + switch (style) { + case "declaration": + this.typeDeclaration(node.sv, this.namespaceStack.value(), true, { + HasSuffixSpace: false, + }); + break; + case "reference": + const defId = this.definitionIdFor(node.sv, this.packageName); + this.typeReference(node.sv, { NavigateToId: defId }); + break; + case "member": + this.member(this.getRawText(node)); + break; + case "keyword": + this.keyword(node.sv); + break; + } + } + } + + private getRawText(node: IdentifierNode): string { + return getSourceLocation(node).file.text.slice(node.pos, node.end); + } + + private tokenizeTemplateParameters(nodes: readonly TemplateParameterDeclarationNode[]) { + if (nodes.length) { + this.punctuation("<"); + for (let x = 0; x < nodes.length; x++) { + const param = nodes[x]; + this.tokenize(param); + if (x !== nodes.length - 1) { + this.punctuation(",", { HasSuffixSpace: true }); + } + } + this.punctuation(">"); + } + } + + private tokenizeTemplateArgument(obj: TemplateArgumentNode) { + if (obj.name) { + this.text(obj.name.sv); + this.punctuation("=", { HasSuffixSpace: true, HasPrefixSpace: true }); + } + if (obj.argument.kind === SyntaxKind.ModelExpression) { + this.tokenizeModelExpression(obj.argument, { isOperationSignature: false }); + } else { + this.tokenize(obj.argument); + } + } + + private tokenizeReturnType( + node: OperationSignatureDeclarationNode, + options: { isExpanded: boolean }, + ) { + if (options.isExpanded && node.parameters.properties.length) { + const offset = this.currentLine.Tokens.length; + this.tokenize(node.returnType); + const returnTokens = this.currentLine.Tokens.slice(offset); + const returnTypeString = returnTokens + .filter((x) => x.Value) + .flatMap((x) => x.Value) + .join(""); + this.namespaceStack.push(returnTypeString); + this.lineMarker(); + this.namespaceStack.pop(); + } else { + this.tokenize(node.returnType); + } + } + + private buildNavigation(ns: NamespaceModel) { + this.namespaceStack.reset(); + this.navigationItems.push(new NavigationItem(ns, this.namespaceStack)); + } + + private getNameForNode(node: BaseNode | NamespaceModel): string { + const id = generateId(node); + if (id) { + return id.split(".").splice(-1)[0]; + } else { + throw new Error("Unable to get name for node."); + } + } + + private definitionIdFor(value: string, prefix: string): string | undefined { + if (value.includes(".")) { + const fullName = `${prefix}.${value}`; + return this.typeDeclarations.has(fullName) ? fullName : undefined; + } + for (const item of this.typeDeclarations) { + if (item.split(".").splice(-1)[0] === value) { + return item; + } + } + return undefined; + } +} diff --git a/packages/typespec-apiview/src/emitter.ts b/packages/typespec-apiview/src/emitter.ts new file mode 100644 index 0000000000..512f96e04d --- /dev/null +++ b/packages/typespec-apiview/src/emitter.ts @@ -0,0 +1,133 @@ +import { + EmitContext, + emitFile, + getNamespaceFullName, + listServices, + Namespace, + NoTarget, + Program, + resolvePath, + Service, +} from "@typespec/compiler"; +import path from "path"; +import { ApiView } from "./apiview.js"; +import { ApiViewEmitterOptions, reportDiagnostic } from "./lib.js"; + +export interface ResolvedApiViewEmitterOptions { + emitterOutputDir: string; + outputFile?: string; + service?: string; + includeGlobalNamespace: boolean; +} + +export async function $onEmit(context: EmitContext) { + const options = resolveOptions(context); + const emitter = createApiViewEmitter(context.program, options); + await emitter.emitApiView(); +} + +export function resolveOptions( + context: EmitContext, +): ResolvedApiViewEmitterOptions { + const resolvedOptions = { ...context.options }; + + return { + emitterOutputDir: context.emitterOutputDir, + outputFile: resolvedOptions["output-file"], + service: resolvedOptions["service"], + includeGlobalNamespace: resolvedOptions["include-global-namespace"] ?? false, + }; +} + +function resolveNamespaceString(namespace: Namespace): string | undefined { + // FIXME: Fix this wonky workaround when getNamespaceString is fixed. + const value = getNamespaceFullName(namespace); + return value === "" ? undefined : value; +} + +/** + * Ensures that single-value options are not used in multi-service specs unless the + * `--service` option is specified. Single-service specs need not pass this option. + */ +function validateMultiServiceOptions( + program: Program, + services: Service[], + options: ResolvedApiViewEmitterOptions, +) { + for (const [name, val] of [["output-file", options.outputFile]]) { + if (val && !options.service && services.length > 1) { + reportDiagnostic(program, { + code: "invalid-option", + target: NoTarget, + format: { + name: name!, + }, + }); + } + } +} + +/** + * If the `--service` option is provided, ensures the service exists and returns the filtered list. + */ +function applyServiceFilter( + program: Program, + services: Service[], + options: ResolvedApiViewEmitterOptions, +): Service[] { + if (!options.service) { + return services; + } + const filtered = services.filter((x) => x.title === options.service); + if (!filtered.length) { + reportDiagnostic(program, { + code: "invalid-service", + target: NoTarget, + format: { + value: options.service, + }, + }); + } + return filtered; +} + +function createApiViewEmitter(program: Program, options: ResolvedApiViewEmitterOptions) { + return { emitApiView }; + + async function emitApiView() { + let services = listServices(program); + if (!services.length) { + reportDiagnostic(program, { + code: "no-services-found", + target: NoTarget, + }); + return; + } + // applies the default "apiview.json" filename if not provided and there's only a single service + if (services.length === 1) { + options.outputFile = options.outputFile ?? "apiview.json"; + } + validateMultiServiceOptions(program, services, options); + services = applyServiceFilter(program, services, options); + + for (const service of services) { + const namespaceString = resolveNamespaceString(service.type) ?? "Unknown"; + const serviceTitle = service.title ? service.title : namespaceString; + + const apiview = new ApiView(serviceTitle, namespaceString, options.includeGlobalNamespace); + apiview.compile(program); + apiview.resolveMissingTypeReferences(); + + if (!program.compilerOptions.noEmit && !program.hasError()) { + const outputFolder = path.dirname(options.emitterOutputDir); + await program.host.mkdirp(outputFolder); + const outputFile = options.outputFile ?? `${namespaceString}-apiview.json`; + const outputPath = resolvePath(outputFolder, outputFile); + await emitFile(program, { + path: outputPath, + content: JSON.stringify(apiview.asCodeFile()) + "\n", + }); + } + } + } +} diff --git a/packages/typespec-apiview/src/index.ts b/packages/typespec-apiview/src/index.ts new file mode 100644 index 0000000000..e776ee5822 --- /dev/null +++ b/packages/typespec-apiview/src/index.ts @@ -0,0 +1,4 @@ +export const namespace = "ApiView"; + +export * from "./emitter.js"; +export { $lib } from "./lib.js"; diff --git a/packages/typespec-apiview/src/lib.ts b/packages/typespec-apiview/src/lib.ts new file mode 100644 index 0000000000..4cd3f13e03 --- /dev/null +++ b/packages/typespec-apiview/src/lib.ts @@ -0,0 +1,55 @@ +import { createTypeSpecLibrary, JSONSchemaType, paramMessage } from "@typespec/compiler"; + +export interface ApiViewEmitterOptions { + "output-file"?: string; + service?: string; + "include-global-namespace"?: boolean; + "mapping-path"?: string; +} + +const ApiViewEmitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + "output-file": { type: "string", nullable: true }, + service: { type: "string", nullable: true }, + "include-global-namespace": { type: "boolean", nullable: true }, + "mapping-path": { type: "string", nullable: true }, + }, + required: [], +}; + +export const $lib = createTypeSpecLibrary({ + name: "@azure-tools/typespec-apiview", + diagnostics: { + "no-services-found": { + severity: "error", + messages: { + default: + "No services found. Ensure there is a namespace in the spec annotated with the `@service` decorator.", + }, + }, + "invalid-service": { + severity: "error", + messages: { + default: paramMessage`Service "${"value"}" was not found. Please check for typos.`, + }, + }, + "invalid-option": { + severity: "error", + messages: { + default: paramMessage`Option "--${"name"}" cannot be used with multi-service specs unless "--service" is also supplied.`, + }, + }, + "version-not-found": { + severity: "error", + messages: { + default: paramMessage`Version "${"version"}" not found for service "${"serviceName"}". Allowed values: ${"allowed"}.`, + }, + }, + }, + emitter: { + options: ApiViewEmitterOptionsSchema, + }, +}); +export const { reportDiagnostic } = $lib; diff --git a/packages/typespec-apiview/src/namespace-model.ts b/packages/typespec-apiview/src/namespace-model.ts new file mode 100644 index 0000000000..2f9dde2a6f --- /dev/null +++ b/packages/typespec-apiview/src/namespace-model.ts @@ -0,0 +1,316 @@ +import { Namespace, Program } from "@typespec/compiler"; +import { + AliasStatementNode, + AugmentDecoratorStatementNode, + BaseNode, + ConstStatementNode, + DecoratorExpressionNode, + DirectiveExpressionNode, + EnumMemberNode, + EnumSpreadMemberNode, + EnumStatementNode, + IdentifierNode, + InterfaceStatementNode, + IntersectionExpressionNode, + JsNamespaceDeclarationNode, + MemberExpressionNode, + ModelExpressionNode, + ModelPropertyNode, + ModelSpreadPropertyNode, + ModelStatementNode, + NamespaceStatementNode, + Node, + ObjectLiteralNode, + OperationStatementNode, + ScalarStatementNode, + StringLiteralNode, + SyntaxKind, + TypeReferenceNode, + UnionExpressionNode, + UnionStatementNode, + UnionVariantNode, + visitChildren, +} from "@typespec/compiler/ast"; +export class NamespaceModel { + kind = SyntaxKind.NamespaceStatement; + name: string; + node: NamespaceStatementNode | JsNamespaceDeclarationNode; + operations = new Map(); + resources = new Map< + string, + | AliasStatementNode + | ModelStatementNode + | ModelExpressionNode + | IntersectionExpressionNode + | EnumStatementNode + | ScalarStatementNode + | UnionStatementNode + | UnionExpressionNode + | ObjectLiteralNode + >(); + models = new Map< + string, + | ModelStatementNode + | ModelExpressionNode + | IntersectionExpressionNode + | EnumStatementNode + | ScalarStatementNode + | UnionStatementNode + | UnionExpressionNode + | ObjectLiteralNode + >(); + aliases = new Map(); + augmentDecorators = new Array(); + constants = new Array(); + + constructor(name: string, ns: Namespace, program: Program) { + this.name = name; + this.node = ns.node!; // Assuming ns.node is never undefined + + // Gather operations + for (const [opName, op] of ns.operations) { + if (op.node) { + this.operations.set(opName, op.node); + } else { + throw new Error(`Operation node for ${opName} is undefined.`); + } + } + for (const [intName, int] of ns.interfaces) { + if (int.node) { + this.operations.set(intName, int.node); + } else { + throw new Error(`Interface node for ${intName} is undefined.`); + } + } + + // Gather models and resources + for (const [modelName, model] of ns.models) { + if (model.node !== undefined) { + let isResource = false; + for (const dec of model.decorators) { + if (dec.decorator.name === "$resource") { + isResource = true; + break; + } + } + if (isResource) { + this.resources.set(modelName, model.node); + } else { + this.models.set(modelName, model.node); + } + } else { + throw new Error("Unexpectedly found undefined model node."); + } + } + for (const [enumName, en] of ns.enums) { + if (en.node) { + this.models.set(enumName, en.node); + } else { + throw new Error(`Enum node for ${enumName} is undefined.`); + } + } + for (const [unionName, un] of ns.unions) { + if (un.node) { + this.models.set(unionName, un.node); + } else { + throw new Error(`Union node for ${unionName} is undefined.`); + } + } + for (const [scalarName, sc] of ns.scalars) { + if (sc.node) { + this.models.set(scalarName, sc.node); + } else { + throw new Error(`Scalar node for ${scalarName} is undefined.`); + } + } + + // Gather aliases + for (const alias of findNodes(SyntaxKind.AliasStatement, program, ns)) { + this.aliases.set(alias.id.sv, alias); + } + + // collect augment decorators + for (const augment of findNodes(SyntaxKind.AugmentDecoratorStatement, program, ns)) { + this.augmentDecorators.push(augment); + } + + // collect constants + for (const constant of findNodes(SyntaxKind.ConstStatement, program, ns)) { + this.constants.push(constant); + } + + // sort operations and models + this.operations = new Map([...this.operations].sort(caseInsensitiveSort)); + this.resources = new Map([...this.resources].sort(caseInsensitiveSort)); + this.models = new Map([...this.models].sort(caseInsensitiveSort)); + this.aliases = new Map([...this.aliases].sort(caseInsensitiveSort)); + } + + /** + * Don't emit an empty namespace + * @returns true if there are models, resources or operations + */ + shouldEmit(): boolean { + return ( + (this.node as NamespaceStatementNode).decorators !== undefined || + this.models.size > 0 || + this.operations.size > 0 || + this.resources.size > 0 + ); + } +} + +function findNodes( + kind: T, + program: Program, + namespace: Namespace, +): (Node & { kind: T })[] { + const nodes: Node[] = []; + for (const file of program.sourceFiles.values()) { + visitChildren(file, function visit(node) { + if (node.kind === kind && inNamespace(node, program, namespace)) { + nodes.push(node); + } + visitChildren(node, visit); + }); + } + return nodes as any; +} + +function inNamespace(node: Node, program: Program, namespace: Namespace): boolean { + for (let n: Node | undefined = node; n; n = n.parent) { + switch (n.kind) { + case SyntaxKind.NamespaceStatement: + return program.checker.getTypeForNode(n) === namespace; + case SyntaxKind.TypeSpecScript: + if ( + n.inScopeNamespaces.length > 0 && + inNamespace(n.inScopeNamespaces[0], program, namespace) + ) { + return true; + } + return false; + } + } + return false; +} + +export function generateId(obj: BaseNode | NamespaceModel | undefined): string | undefined { + let node; + if (obj === undefined) { + return undefined; + } + if (obj instanceof NamespaceModel) { + return obj.name; + } + let name: string; + let parentId: string | undefined; + switch (obj.kind) { + case SyntaxKind.NamespaceStatement: + node = obj as NamespaceStatementNode; + name = node.id.sv; + parentId = generateId(node.parent); + break; + case SyntaxKind.DecoratorExpression: + node = obj as DecoratorExpressionNode; + switch (node.target.kind) { + case SyntaxKind.Identifier: + return `@${node.target.sv}`; + case SyntaxKind.MemberExpression: + return generateId(node.target); + } + break; + case SyntaxKind.EnumMember: + node = obj as EnumMemberNode; + name = node.id.sv; + parentId = generateId(node.parent); + break; + case SyntaxKind.EnumSpreadMember: + node = obj as EnumSpreadMemberNode; + return generateId(node.target); + case SyntaxKind.EnumStatement: + node = obj as EnumStatementNode; + name = node.id.sv; + parentId = generateId(node.parent); + break; + case SyntaxKind.Identifier: + node = obj as IdentifierNode; + return node.sv; + case SyntaxKind.InterfaceStatement: + node = obj as InterfaceStatementNode; + name = node.id.sv; + parentId = generateId(node.parent); + break; + case SyntaxKind.MemberExpression: + node = obj as MemberExpressionNode; + name = node.id.sv; + parentId = generateId(node.base); + break; + case SyntaxKind.ModelProperty: + node = obj as ModelPropertyNode; + name = node.id.sv; + parentId = generateId(node.parent); + break; + case SyntaxKind.ModelSpreadProperty: + node = obj as ModelSpreadPropertyNode; + name = generateId(node.target)!; + parentId = generateId(node.parent); + break; + case SyntaxKind.ModelStatement: + node = obj as ModelStatementNode; + name = node.id.sv; + parentId = generateId(node.parent); + break; + case SyntaxKind.OperationStatement: + node = obj as OperationStatementNode; + name = node.id.sv; + parentId = generateId(node.parent); + break; + case SyntaxKind.StringLiteral: + node = obj as StringLiteralNode; + name = node.value; + parentId = undefined; + break; + case SyntaxKind.UnionStatement: + node = obj as UnionStatementNode; + name = node.id.sv; + parentId = generateId(node.parent); + break; + case SyntaxKind.UnionVariant: + node = obj as UnionVariantNode; + if (node.id?.sv !== undefined) { + name = node.id.sv; + } else { + // TODO: Should never have default value of _ + name = generateId(node.value) ?? "_"; + } + parentId = generateId(node.parent); + break; + case SyntaxKind.TypeReference: + node = obj as TypeReferenceNode; + name = generateId(node.target)!; + parentId = undefined; + break; + case SyntaxKind.DirectiveExpression: + node = obj as DirectiveExpressionNode; + name = `#${generateId(node.target)!}`; + for (const arg of node.arguments) { + name += `_${generateId(arg)}`; + } + parentId = generateId(node.parent); + break; + default: + return undefined; + } + if (parentId !== undefined) { + return `${parentId}.${name}`; + } else { + return name; + } +} + +function caseInsensitiveSort(a: [string, any], b: [string, any]): number { + const aLower = a[0].toLowerCase(); + const bLower = b[0].toLowerCase(); + return aLower > bLower ? 1 : aLower < bLower ? -1 : 0; +} diff --git a/packages/typespec-apiview/src/schemas.ts b/packages/typespec-apiview/src/schemas.ts new file mode 100644 index 0000000000..95068c62a9 --- /dev/null +++ b/packages/typespec-apiview/src/schemas.ts @@ -0,0 +1,286 @@ +// These schemas are all adapted from the TypeSpec definition here: +// https://github.com/Azure/azure-sdk-tools/blob/main/tools/apiview/parsers/apiview-treestyle-parser-schema/main.tsp + +import { + AliasStatementNode, + EnumStatementNode, + InterfaceStatementNode, + IntersectionExpressionNode, + ModelExpressionNode, + ModelStatementNode, + ObjectLiteralNode, + OperationStatementNode, + ScalarStatementNode, + SyntaxKind, + UnionExpressionNode, + UnionStatementNode, +} from "@typespec/compiler/ast"; +import { NamespaceModel } from "./namespace-model.js"; +import { NamespaceStack } from "./util.js"; + +// CORE API VIEW SCHEMAS + +export enum TokenKind { + Text = 0, + Punctuation = 1, + Keyword = 2, + TypeName = 3, + MemberName = 4, + StringLiteral = 5, + Literal = 6, + Comment = 7, +} + +/** ReviewFile represents entire API review object. This will be processed to render review lines. */ +export interface CodeFile { + Name: string; + PackageName: string; + PackageVersion: string; + /** version of the APIview language parser used to create token file*/ + ParserVersion: string; + Language: string; + /** Language variant is applicable only for java variants*/ + LanguageVariant: string | undefined; + CrossLanguagePackageId: string | undefined; + ReviewLines: ReviewLine[]; + /** Add any system generated comments. Each comment is linked to review line ID */ + Diagnostics: CodeDiagnostic[] | undefined; + /** Navigation items are used to create a tree view in the navigation panel. Each navigation item is linked to a review line ID. This is optional. + * If navigation items are not provided then navigation panel will be automatically generated using the review lines. Navigation items should be provided only if you want to customize the navigation panel. + */ + Navigation: NavigationItem[] | undefined; +} + +export interface ReviewLineOptions { + /** Set current line as hidden code line by default. .NET has hidden APIs and architects don't want to see them by default. */ + IsHidden?: boolean; + /** Set current line as context end line. For e.g. line with token } or empty line after the class to mark end of context. */ + IsContextEndLine?: boolean; + /** Set ID of related line to ensure current line is not visible when a related line is hidden. + * One e.g. is a code line for class attribute should set class line's Line ID as related line ID. + */ + RelatedToLine?: string; +} + +/** ReviewLine object corresponds to each line displayed on API review. If an empty line is required then add a code line object without any token. */ +export interface ReviewLine extends ReviewLineOptions { + /** lineId is only required if we need to support commenting on a line that contains this token. + * Usually code line for documentation or just punctuation is not required to have lineId. lineId should be a unique value within + * the review token file to use it assign to review comments as well as navigation Id within the review page. + * for e.g Azure.Core.HttpHeader.Common, azure.template.template_main + */ + LineId: string | undefined; + CrossLanguageId: string | undefined; + /** list of tokens that constructs a line in API review */ + Tokens: ReviewToken[]; + /** Add any child lines as children. For e.g. all classes and namespace level methods are added as a children of namespace(module) level code line. + * Similarly all method level code lines are added as children of it's class code line.*/ + Children: ReviewLine[]; +} + +export interface ReviewTokenOptions { + /** NavigationDisplayName is used to create a tree node in the navigation panel. Navigation nodes will be created only if token contains navigation display name.*/ + NavigationDisplayName?: string; + /** navigateToId should be set if the underlying token is required to be displayed as HREF to another type within the review. + * For e.g. a param type which is class name in the same package + */ + NavigateToId?: string; + /** set skipDiff to true if underlying token needs to be ignored from diff calculation. For e.g. package metadata or dependency versions + * are usually excluded when comparing two revisions to avoid reporting them as API changes*/ + SkipDiff?: boolean; + /** This is set if API is marked as deprecated */ + IsDeprecated?: boolean; + /** Set this to true if a prefix space is required before the next value. */ + HasPrefixSpace?: boolean; + /** Set this to true if a suffix space required before next token. For e.g, punctuation right after method name */ + HasSuffixSpace?: boolean; + /** Set isDocumentation to true if current token is part of documentation */ + IsDocumentation?: boolean; + /** Language specific style css class names */ + RenderClasses?: Array; +} + +/** Token corresponds to each component within a code line. A separate token is required for keyword, punctuation, type name, text etc. */ +export interface ReviewToken extends ReviewTokenOptions { + Kind: TokenKind; + Value: string; +} + +// CODE DIAGNOSTIC SCHEMAS + +export enum CodeDiagnosticLevel { + Info = 1, + Warning = 2, + Error = 3, + /** Fatal level diagnostic will block API review approval and it will show an error message to the user. Approver will have to + * override fatal level system comments before approving a review.*/ + Fatal = 4, +} + +/** System comment object is to add system generated comment. It can be one of the 4 different types of system comments. */ +export interface CodeDiagnostic { + /** Auto generated system comment to be displayed under targeted line. */ + Text: string; + /** Diagnostic ID is auto generated ID by CSharp analyzer. */ + DiagnosticId?: string; + /** Id of ReviewLine object where this diagnostic needs to be displayed */ + TargetId: string; + Level: CodeDiagnosticLevel; + HelpLinkUri?: string; +} + +// NAVIGATION SCHEMAS + +export class NavigationItem { + Text: string; + NavigationId: string | undefined; + ChildItems: NavigationItem[]; + Tags: ApiViewNavigationTag; + + constructor( + objNode: + | AliasStatementNode + | NamespaceModel + | ModelStatementNode + | OperationStatementNode + | InterfaceStatementNode + | EnumStatementNode + | ModelExpressionNode + | IntersectionExpressionNode + | ScalarStatementNode + | UnionStatementNode + | UnionExpressionNode + | ObjectLiteralNode, + stack: NamespaceStack, + ) { + let obj; + switch (objNode.kind) { + case SyntaxKind.NamespaceStatement: + stack.push(objNode.name); + this.Text = objNode.name; + this.Tags = { TypeKind: ApiViewNavigationKind.Module }; + const operationItems = new Array(); + for (const node of objNode.operations.values()) { + operationItems.push(new NavigationItem(node, stack)); + } + const resourceItems = new Array(); + for (const node of objNode.resources.values()) { + resourceItems.push(new NavigationItem(node, stack)); + } + const modelItems = new Array(); + for (const node of objNode.models.values()) { + modelItems.push(new NavigationItem(node, stack)); + } + const aliasItems = new Array(); + for (const node of objNode.aliases.values()) { + aliasItems.push(new NavigationItem(node, stack)); + } + this.ChildItems = []; + if (operationItems.length) { + this.ChildItems.push({ + Text: "Operations", + ChildItems: operationItems, + Tags: { TypeKind: ApiViewNavigationKind.Method }, + NavigationId: "", + }); + } + if (resourceItems.length) { + this.ChildItems.push({ + Text: "Resources", + ChildItems: resourceItems, + Tags: { TypeKind: ApiViewNavigationKind.Class }, + NavigationId: "", + }); + } + if (modelItems.length) { + this.ChildItems.push({ + Text: "Models", + ChildItems: modelItems, + Tags: { TypeKind: ApiViewNavigationKind.Class }, + NavigationId: "", + }); + } + if (aliasItems.length) { + this.ChildItems.push({ + Text: "Aliases", + ChildItems: aliasItems, + Tags: { TypeKind: ApiViewNavigationKind.Class }, + NavigationId: "", + }); + } + break; + case SyntaxKind.ModelStatement: + obj = objNode as ModelStatementNode; + stack.push(obj.id.sv); + this.Text = obj.id.sv; + this.Tags = { TypeKind: ApiViewNavigationKind.Class }; + this.ChildItems = []; + break; + case SyntaxKind.EnumStatement: + obj = objNode as EnumStatementNode; + stack.push(obj.id.sv); + this.Text = obj.id.sv; + this.Tags = { TypeKind: ApiViewNavigationKind.Enum }; + this.ChildItems = []; + break; + case SyntaxKind.OperationStatement: + obj = objNode as OperationStatementNode; + stack.push(obj.id.sv); + this.Text = obj.id.sv; + this.Tags = { TypeKind: ApiViewNavigationKind.Method }; + this.ChildItems = []; + break; + case SyntaxKind.InterfaceStatement: + obj = objNode as InterfaceStatementNode; + stack.push(obj.id.sv); + this.Text = obj.id.sv; + this.Tags = { TypeKind: ApiViewNavigationKind.Method }; + this.ChildItems = []; + for (const child of obj.operations) { + this.ChildItems.push(new NavigationItem(child, stack)); + } + break; + case SyntaxKind.UnionStatement: + obj = objNode as UnionStatementNode; + stack.push(obj.id.sv); + this.Text = obj.id.sv; + this.Tags = { TypeKind: ApiViewNavigationKind.Enum }; + this.ChildItems = []; + break; + case SyntaxKind.AliasStatement: + obj = objNode as AliasStatementNode; + stack.push(obj.id.sv); + this.Text = obj.id.sv; + this.Tags = { TypeKind: ApiViewNavigationKind.Class }; + this.ChildItems = []; + break; + case SyntaxKind.ModelExpression: + throw new Error(`Navigation unsupported for "ModelExpression".`); + case SyntaxKind.IntersectionExpression: + throw new Error(`Navigation unsupported for "IntersectionExpression".`); + case SyntaxKind.ScalarStatement: + obj = objNode as ScalarStatementNode; + stack.push(obj.id.sv); + this.Text = obj.id.sv; + this.Tags = { TypeKind: ApiViewNavigationKind.Class }; + this.ChildItems = []; + break; + default: + throw new Error(`Navigation unsupported for "${objNode.kind.toString()}".`); + } + this.NavigationId = stack.value(); + stack.pop(); + } +} + +export interface ApiViewNavigationTag { + TypeKind: ApiViewNavigationKind; +} + +export const enum ApiViewNavigationKind { + Class = "class", + Enum = "enum", + Method = "method", + Module = "namespace", + Package = "assembly", +} diff --git a/packages/typespec-apiview/src/testing/index.ts b/packages/typespec-apiview/src/testing/index.ts new file mode 100644 index 0000000000..ebb8713904 --- /dev/null +++ b/packages/typespec-apiview/src/testing/index.ts @@ -0,0 +1,10 @@ +import { + createTestLibrary, + findTestPackageRoot, + TypeSpecTestLibrary, +} from "@typespec/compiler/testing"; + +export const ApiViewTestLibrary: TypeSpecTestLibrary = createTestLibrary({ + name: "@azure-tools/typespec-apiview", + packageRoot: await findTestPackageRoot(import.meta.url), +}); diff --git a/packages/typespec-apiview/src/util.ts b/packages/typespec-apiview/src/util.ts new file mode 100644 index 0000000000..9e5d24463e --- /dev/null +++ b/packages/typespec-apiview/src/util.ts @@ -0,0 +1,44 @@ +import { ReviewLine, ReviewToken } from "./schemas.js"; + +export function reviewLineText(line: ReviewLine, indent: number): string { + const indentString = " ".repeat(indent); + let tokenText = ""; + for (const token of line.Tokens) { + tokenText += reviewTokenText(token, tokenText); + } + const childrenText = line.Children.map((c) => reviewLineText(c, indent + 2)).join("\n"); + if (childrenText !== "") { + return `${indentString}${tokenText}\n${childrenText}`; + } else { + return `${indentString}${tokenText}`; + } +} + +function reviewTokenText(token: ReviewToken, preview: string): string { + const previewEndsInSpace = preview.endsWith(" "); + const hasSuffixSpace = token.HasSuffixSpace !== undefined ? token.HasSuffixSpace : true; + const suffixSpace = hasSuffixSpace ? " " : ""; + const prefixSpace = token.HasPrefixSpace && !previewEndsInSpace ? " " : ""; + const value = token.Value; + return `${prefixSpace}${value}${suffixSpace}`; +} + +export class NamespaceStack { + stack = new Array(); + + push(val: string) { + this.stack.push(val); + } + + pop(): string | undefined { + return this.stack.pop(); + } + + value(): string { + return this.stack.join("."); + } + + reset() { + this.stack = Array(); + } +} diff --git a/packages/typespec-apiview/src/version.ts b/packages/typespec-apiview/src/version.ts new file mode 100644 index 0000000000..3dabc791bd --- /dev/null +++ b/packages/typespec-apiview/src/version.ts @@ -0,0 +1 @@ +export const LIB_VERSION = "0.7.2"; diff --git a/packages/typespec-apiview/test/apiview-options.test.ts b/packages/typespec-apiview/test/apiview-options.test.ts new file mode 100644 index 0000000000..b0477dcaaf --- /dev/null +++ b/packages/typespec-apiview/test/apiview-options.test.ts @@ -0,0 +1,62 @@ +import { describe, it } from "vitest"; +import { apiViewFor, apiViewText, compare } from "./test-host.js"; + +describe("apiview-options: tests", () => { + it("omits namespaces that aren't proper subnamespaces", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test"} ) + namespace Azure.Test { + model Foo {}; + } + + namespace Azure.Test.Sub { + model SubFoo {}; + }; + + namespace Azure.TestBad { + model BadFoo {}; + }; + `; + const expect = ` + namespace Azure.Test { + model Foo {} + } + + namespace Azure.Test.Sub { + model SubFoo {} + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + }); + + it("outputs the global namespace when --include-global-namespace is set", async () => { + const input = ` + model SomeGlobal {}; + + @TypeSpec.service( #{ title: "Test"} ) + namespace Azure.Test { + model Foo {}; + } + `; + const expect = ` + namespace ::GLOBAL:: { + model SomeGlobal {} + } + + @TypeSpec.service(#{ + title: "Test" + }) + namespace Azure.Test { + model Foo {} + } + `; + const apiview = await apiViewFor(input, { + "include-global-namespace": true, + }); + // TODO: Update once bug is fixed: https://github.com/microsoft/typespec/issues/3165 + const actual = apiViewText(apiview); + compare(expect, actual, 3); + }); +}); diff --git a/packages/typespec-apiview/test/apiview.test.ts b/packages/typespec-apiview/test/apiview.test.ts new file mode 100644 index 0000000000..483978bd9e --- /dev/null +++ b/packages/typespec-apiview/test/apiview.test.ts @@ -0,0 +1,1434 @@ +import { fail } from "assert"; +import { isDeepStrictEqual } from "util"; +import { describe, it } from "vitest"; +import { CodeFile, ReviewLine } from "../src/schemas.js"; +import { apiViewFor, apiViewText, compare } from "./test-host.js"; + +interface ReviewLineData { + relatedToCount: number; + isContextEndCount: number; +} + +describe("apiview: tests", () => { + function validateReviewLineIds(definitionIds: Set, line: ReviewLine) { + // ensure that there are no repeated definition IDs. + if (line.LineId !== undefined) { + if (definitionIds.has(line.LineId)) { + fail(`Duplicate defintion ID ${line.LineId}.`); + } + if (line.LineId !== "") { + definitionIds.add(line.LineId); + } + for (const child of line.Children) { + validateReviewLineIds(definitionIds, child); + } + } + } + + /** Validates that there are no repeat defintion IDs. */ + function validateLineIds(apiview: CodeFile) { + const definitionIds = new Set(); + for (const line of apiview.ReviewLines) { + validateReviewLineIds(definitionIds, line); + } + } + + /** Validates that related lines point to a valid line. */ + function getRelatedLineMetadata(apiview: CodeFile): Map { + function getReviewLinesMetadata( + lines: ReviewLine[] | undefined, + ): Map | undefined { + if (lines === undefined || lines.length === 0) return undefined; + const mainMap = new Map(); + let lastKey: string | undefined = undefined; + for (const line of lines) { + const related = line.RelatedToLine; + const lineId = line.LineId; + const isEndContext = line.IsContextEndLine; + if (related) { + lastKey = related; + if (!mainMap.has(related)) { + mainMap.set(related, { relatedToCount: 0, isContextEndCount: 0 }); + } + mainMap.get(related)!.relatedToCount++; + } + if (isEndContext) { + if (lastKey === undefined) { + fail("isEndContext without a related line."); + } + if (!mainMap.has(lastKey)) { + mainMap.set(lastKey, { relatedToCount: 0, isContextEndCount: 0 }); + } + mainMap.get(lastKey)!.isContextEndCount++; + } + if (line.Children?.length > 0) { + if (lineId === undefined) { + fail("Children without a line ID."); + } + lastKey = lineId; + const childMap = getReviewLinesMetadata(line.Children); + if (childMap !== undefined && childMap.size > 0) { + for (const [key, value] of childMap) { + mainMap.set(key, value); + } + } + } + } + return mainMap; + } + const countMap = getReviewLinesMetadata(apiview.ReviewLines); + return countMap ?? new Map(); + } + + function compareCounts(lhs: Map, rhs: Map) { + // ensure the keys are the same + const lhsKeys = new Set([...lhs.keys()]); + const rhsKeys = new Set([...rhs.keys()]); + const combined = new Set([...lhsKeys, ...rhsKeys]); + if (combined.size !== lhsKeys.size) { + fail(`Keys mismatch: ${JSON.stringify([...lhsKeys])} vs ${JSON.stringify([...rhsKeys])}`); + } + isDeepStrictEqual(lhs, rhs); + } + + describe("models", () => { + it("composition", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + model Animal { + species: string; + } + + model Pet { + name?: string; + } + + model Dog { + ...Animal; + ...Pet; + } + + model Cat { + species: string; + name?: string = "fluffy"; + } + + model Pig extends Animal {} + } + `; + const expect = ` + namespace Azure.Test { + model Animal { + species: string; + } + + model Cat { + species: string; + name?: string = "fluffy"; + } + + model Dog { + ...Animal; + ...Pet; + } + + model Pet { + name?: string; + } + + model Pig extends Animal {} + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Animal", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Cat", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Dog", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Pet", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("templated", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + model Thing { + property: T; + } + + model StringThing is Thing; + + model NamedStringThing is Thing; + + model Page { + size: int16; + item: T[]; + } + + model StringPage { + ...Page; + } + + model ConstrainedSimple { + prop: X; + } + + model ConstrainedComplex { + prop: X; + } + + model ConstrainedWithDefault { + prop: X; + } + } + `; + const expect = ` + namespace Azure.Test { + model ConstrainedComplex { + prop: X; + } + + model ConstrainedSimple { + prop: X; + } + + model ConstrainedWithDefault { + prop: X; + } + + model NamedStringThing is Thing {} + + model Page { + size: int16; + item: T[]; + } + + model StringPage { + ...Page; + } + + model StringThing is Thing {} + + model Thing { + property: T; + } + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.ConstrainedComplex", { relatedToCount: 1, isContextEndCount: 1 }], + ["Azure.Test.ConstrainedSimple", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.ConstrainedWithDefault", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Page", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.StringPage", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Thing", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("with default values", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + model Foo { + name: string = "foo"; + array: string[] = #["a", "b"]; + obj: Record = #{val: 1, name: "foo"}; + } + } + `; + const expect = ` + namespace Azure.Test { + model Foo { + name: string = "foo"; + array: string[] = #["a", "b"]; + obj: Record = #{ + val: 1, + name: "foo" + }; + } + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Foo", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + }); + + describe("scalars", () => { + it("extends string", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + scalar Password extends string; + } + `; + const expect = ` + namespace Azure.Test { + scalar Password extends string + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts(counts, new Map([["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }]])); + }); + + it("new scalar type", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + scalar ternary; + } + `; + const expect = ` + namespace Azure.Test { + scalar ternary + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts(counts, new Map([["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }]])); + }); + + it("templated", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + @doc(T) + scalar Unreal; + } + `; + const expect = ` + namespace Azure.Test { + @doc(T) + scalar Unreal + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Unreal", { relatedToCount: 1, isContextEndCount: 0 }], + ]), + ); + }); + }); + + describe("aliases", () => { + it("simple alias", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + model Animal { + species: string; + } + + alias Creature = Animal; + } + `; + const expect = ` + namespace Azure.Test { + model Animal { + species: string; + } + + alias Creature = Animal; + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Animal", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("templated alias", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + model Animal { + species: string; + } + + alias Template = "Foo \${T} bar"; + } + `; + const expect = ` + namespace Azure.Test { + model Animal { + species: string; + } + + alias Template = "Foo \${T} bar"; + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Animal", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + }); + + describe("augment decorators", () => { + it("simple augment", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + model Animal { + species: string; + } + + @@doc(Animal, "My doc"); + } + `; + const expect = ` + namespace Azure.Test { + @@doc(Animal, "My doc") + + model Animal { + species: string; + } + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Animal", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + }); + + describe("enums", () => { + it("literal labels", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + enum SomeEnum { + Plain, + "Literal", + } + }`; + const expect = ` + namespace Azure.Test { + enum SomeEnum { + Plain, + "Literal", + } + }`; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.SomeEnum", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("string-backed values", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + enum SomeStringEnum { + A: "A", + B: "B", + } + }`; + const expect = ` + namespace Azure.Test { + enum SomeStringEnum { + A: "A", + B: "B", + } + }`; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.SomeStringEnum", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("int-backed values", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + enum SomeIntEnum { + A: 1, + B: 2, + } + }`; + const expect = ` + namespace Azure.Test { + enum SomeIntEnum { + A: 1, + B: 2, + } + }`; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.SomeIntEnum", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("spread labels", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + + enum SomeEnum {A} + + enum SomeSpreadEnum {...SomeEnum} + }`; + const expect = ` + namespace Azure.Test { + enum SomeEnum { + A, + } + + enum SomeSpreadEnum { + ...SomeEnum, + } + }`; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.SomeEnum", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.SomeSpreadEnum", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + }); + + describe("unions", () => { + it("discriminated union", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + union MyUnion { + cat: Cat, + dog: Dog, + snake: Snake + } + + model Cat { + name: string; + } + + model Dog { + name: string; + } + + model Snake { + name: string; + length: int16; + } + }`; + const expect = ` + namespace Azure.Test { + model Cat { + name: string; + } + + model Dog { + name: string; + } + + union MyUnion { + cat: Cat, + dog: Dog, + snake: Snake + } + + model Snake { + name: string; + length: int16; + } + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Cat", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Dog", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.MyUnion", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Snake", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("unnamed union", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + union Animals { Cat, Dog, Snake }; + + model Cat { + name: string; + } + + model Dog { + name: string; + } + + model Snake { + name: string; + length: int16; + } + }`; + const expect = ` + namespace Azure.Test { + union Animals { + Cat, + Dog, + Snake + } + + model Cat { + name: string; + } + + model Dog { + name: string; + } + + model Snake { + name: string; + length: int16; + } + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Cat", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Dog", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Animals", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Snake", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + }); + + describe("operations", () => { + it("templated with simple types", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + op ResourceRead(resource: TResource, params: TParams): TResource; + + op GetFoo is ResourceRead; + + @route("/named") + op NamedGetFoo is ResourceRead; + }`; + const expect = ` + namespace Azure.Test { + op GetFoo is ResourceRead; + + @route("/named") + op NamedGetFoo is ResourceRead; + + op ResourceRead( + resource: TResource, + params: TParams + ): TResource; + }`; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.NamedGetFoo", { relatedToCount: 1, isContextEndCount: 0 }], + ["Azure.Test.ResourceRead", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("templated with deeply nested models", async () => { + const input = ` + @service(#{title: "Service"}) + namespace Azure.Test { + op Foo is Temp< { + parameters: { + fooId: { + bar: { + baz: { + qux: string; + }; + }; + }; + }; + }>; + + op Temp( + params: T + ): void; + }`; + const expect = ` + namespace Azure.Test { + op Foo is Temp< + { + parameters: + { + fooId: + { + bar: + { + baz: + { + qux: string; + }; + }; + }; + }; + } + >; + + op Temp( + params: T + ): void; + }`; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Foo", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Temp", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("templated with model types", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + model FooParams { + a: string; + b: string; + } + + op ResourceRead(resource: TResource, params: TParams): TResource; + + op GetFoo is ResourceRead< + { + @query + @doc("The name") + name: string, + ...FooParams + }, + { + parameters: { + @query + @doc("The collection id.") + fooId: string + }; + } + >; + + @route("/named") + op NamedGetFoo is ResourceRead< + TResource = { + @query + @doc("The name") + name: string, + ...FooParams + }, + TParams = { + parameters: { + @query + @doc("The collection id.") + fooId: string + }; + } + >; + }`; + const expect = ` + namespace Azure.Test { + op GetFoo is ResourceRead< + { + @query + @doc("The name") + name: string; + ...FooParams; + }, + { + parameters: + { + @query + @doc("The collection id.") + fooId: string; + }; + } + >; + + @route("/named") + op NamedGetFoo is ResourceRead< + TResource = { + @query + @doc("The name") + name: string; + ...FooParams; + }, + TParams = { + parameters: + { + @query + @doc("The collection id.") + fooId: string; + }; + } + >; + + op ResourceRead( + resource: TResource, + params: TParams + ): TResource; + + model FooParams { + a: string; + b: string; + } + }`; + // Related line mismatches found!: {"Azure.Test.NamedGetFoo":{"relatedToCount":1,"isContextEndCount":-1}} + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.GetFoo", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.NamedGetFoo", { relatedToCount: 1, isContextEndCount: 1 }], + ["Azure.Test.ResourceRead", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.FooParams", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("templated with mixed types", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + model FooParams { + a: string; + b: string; + } + + op ResourceRead(resource: TResource, params: TParams): TResource; + + op GetFoo is ResourceRead< + string, + { + parameters: { + @query + @doc("The collection id.") + fooId: string + }; + } + >; + + @route("/named") + op NamedGetFoo is ResourceRead< + TResource = string, + TParams = { + parameters: + { + @query + @doc("The collection id.") + fooId: string; + }; + } + >; + }`; + const expect = ` + namespace Azure.Test { + op GetFoo is ResourceRead< + string, + { + parameters: + { + @query + @doc("The collection id.") + fooId: string; + }; + } + >; + + @route("/named") + op NamedGetFoo is ResourceRead< + TResource = string, + TParams = { + parameters: + { + @query + @doc("The collection id.") + fooId: string; + }; + } + >; + + op ResourceRead( + resource: TResource, + params: TParams + ): TResource; + + model FooParams { + a: string; + b: string; + } + }`; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.GetFoo", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.NamedGetFoo", { relatedToCount: 1, isContextEndCount: 1 }], + ["Azure.Test.ResourceRead", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.FooParams", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("templated with empty models", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + + op ResourceRead(resource: TResource, params: TParams): TResource; + + op GetFoo is ResourceRead<{}, {}>; + + @route("/named") + op NamedGetFoo is ResourceRead< + TResource = {}, + TParams = {} + >; + }`; + const expect = ` + namespace Azure.Test { + op GetFoo is ResourceRead< + {}, + {} + >; + + @route("/named") + op NamedGetFoo is ResourceRead< + TResource = {}, + TParams = {} + >; + + op ResourceRead( + resource: TResource, + params: TParams + ): TResource; + }`; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.GetFoo", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.NamedGetFoo", { relatedToCount: 1, isContextEndCount: 1 }], + ["Azure.Test.ResourceRead", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + + it("with anonymous models", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + op SomeOp( + param1: { + name: string + }, + param2: { + age: int16 + } + ): string; + }`; + const expect = ` + namespace Azure.Test { + op SomeOp( + param1: + { + name: string; + }, + param2: + { + age: int16; + } + ): string; + }`; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.SomeOp", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + }); + + describe("interfaces", () => { + it("simple interface", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + interface Foo { + @get + @route("get/{name}") + get(@path name: string): string; + + @get + @route("list") + list(): string[]; + } + } + `; + const expect = ` + namespace Azure.Test { + interface Foo { + @get + @route("get/{name}") + get( + @path + name: string + ): string; + + @get + @route("list") + list(): string[]; + } + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Foo", { relatedToCount: 0, isContextEndCount: 1 }], + ["Azure.Test.Foo.get", { relatedToCount: 2, isContextEndCount: 1 }], + ["Azure.Test.Foo.list", { relatedToCount: 2, isContextEndCount: 0 }], + ]), + ); + }); + }); + + describe("string literals", () => { + it("long strings", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + @doc(""" + A long string, + with line breaks + and stuff... + """) + model Bar {}; + } + `; + const expect = ` + namespace Azure.Test { + @doc(""" + A long string, + with line breaks + and stuff... + """) + model Bar {} + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Bar", { relatedToCount: 5, isContextEndCount: 0 }], + ]), + ); + }); + + it("short strings", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + @doc("Short string") + model Foo {}; + } + `; + const expect = ` + namespace Azure.Test { + @doc("Short string") + model Foo {} + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Foo", { relatedToCount: 1, isContextEndCount: 0 }], + ]), + ); + }); + }); + + describe("string templates", () => { + it("templates", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + alias myconst = "foobar"; + model Person { + simple: "Simple \${123} end"; + multiline: """ + Multi + \${123} + \${true} + line + """; + ref: "Ref this alias \${myconst} end"; + template: Template<"custom">; + } + alias Template = "Foo \${T} bar"; + }`; + + const expect = ` + namespace Azure.Test { + model Person { + simple: "Simple \${123} end"; + multiline: """ + Multi + \${123} + \${true} + line + """; + ref: "Ref this alias \${myconst} end"; + template: Template<"custom">; + } + + alias myconst = "foobar"; + + alias Template = "Foo \${T} bar"; + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Person", { relatedToCount: 0, isContextEndCount: 1 }], + ]), + ); + }); + }); + + describe("suppressions", () => { + it("suppression on model", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + #suppress "foo" "bar" + @doc("Foo Model") + model Foo { + name: string; + } + } + `; + const expect = ` + namespace Azure.Test { + #suppress "foo" "bar" + @doc("Foo Model") + model Foo { + name: string; + } + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.Foo", { relatedToCount: 2, isContextEndCount: 1 }], + ]), + ); + }); + + it("suppression on namespace", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + #suppress "foo" "bar" + @doc("SubNamespace") + namespace SubNamespace { + model Blah { + name: string; + } + } + } + `; + const expect = ` + namespace Azure.Test { + } + + #suppress "foo" "bar" + @doc("SubNamespace") + namespace Azure.Test.SubNamespace { + model Blah { + name: string; + } + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.SubNamespace", { relatedToCount: 2, isContextEndCount: 1 }], + ]), + ); + }); + + it("suppression on operation", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + #suppress "foo" "bar" + op someOp(): void; + } + `; + const expect = ` + namespace Azure.Test { + #suppress "foo" "bar" + op someOp(): void; + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts( + counts, + new Map([ + ["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }], + ["Azure.Test.someOp", { relatedToCount: 1, isContextEndCount: 0 }], + ]), + ); + }); + }); + + describe("constants", () => { + it("renders constants", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + const a = 123; + const b = #{name: "abc"}; + const c = a; + } + `; + const expect = ` + namespace Azure.Test { + const a = 123; + const b = #{ + name: "abc" + }; + const c = a; + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts(counts, new Map([["Azure.Test", { relatedToCount: 3, isContextEndCount: 1 }]])); + }); + }); + + it("renders examples with call expression constants", async () => { + const input = ` + @TypeSpec.service( #{ title: "Test" } ) + namespace Azure.Test { + const SomeExample: SomeData = #{ + timestamp: utcDateTime.fromISO("2020-12-09T13:50:19.9995668-08:00"), + name: "test" + }; + + @example(SomeExample) + model SomeData { + timestamp: utcDateTime; + name: string; + } + } + `; + const expect = ` + namespace Azure.Test { + @example(SomeExample) + model SomeData { + timestamp: utcDateTime; + name: string; + } + + const SomeExample = #{ + timestamp: utcDateTime.fromISO("2020-12-09T13:50:19.9995668-08:00"), + name: "test" + }; + } + `; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 6); + validateLineIds(apiview); + const counts = getRelatedLineMetadata(apiview); + compareCounts(counts, new Map([["Azure.Test", { relatedToCount: 1, isContextEndCount: 1 }]])); + }); +}); diff --git a/packages/typespec-apiview/test/test-host.ts b/packages/typespec-apiview/test/test-host.ts new file mode 100644 index 0000000000..74039ffd7b --- /dev/null +++ b/packages/typespec-apiview/test/test-host.ts @@ -0,0 +1,137 @@ +import "@azure-tools/typespec-apiview"; +import { AzureCoreTestLibrary } from "@azure-tools/typespec-azure-core/testing"; +import { Diagnostic, resolvePath } from "@typespec/compiler"; +import { createTestHost, createTestWrapper } from "@typespec/compiler/testing"; +import { HttpTestLibrary } from "@typespec/http/testing"; +import { RestTestLibrary } from "@typespec/rest/testing"; +import { VersioningTestLibrary } from "@typespec/versioning/testing"; +import { strictEqual } from "assert"; +import { ApiViewEmitterOptions } from "../src/lib.js"; +import { CodeFile } from "../src/schemas.js"; +import { ApiViewTestLibrary } from "../src/testing/index.js"; +import { reviewLineText } from "../src/util.js"; + +export async function createApiViewTestHost() { + return createTestHost({ + libraries: [ + ApiViewTestLibrary, + RestTestLibrary, + HttpTestLibrary, + VersioningTestLibrary, + AzureCoreTestLibrary, + ], + }); +} + +export async function createApiViewTestRunner({ + withVersioning, +}: { withVersioning?: boolean } = {}) { + const host = await createApiViewTestHost(); + const autoUsings = ["TypeSpec.Rest", "TypeSpec.Http"]; + if (withVersioning) { + autoUsings.push("TypeSpec.Versioning"); + } + return createTestWrapper(host, { + autoUsings: autoUsings, + compilerOptions: { + emit: ["@azure-tools/typespec-apiview"], + }, + }); +} + +export async function diagnosticsFor( + code: string, + options: ApiViewEmitterOptions, +): Promise { + const runner = await createApiViewTestRunner({ withVersioning: true }); + const outPath = resolvePath("/apiview.json"); + const diagnostics = await runner.diagnose(code, { + noEmit: false, + emit: ["@azure-tools/typespec-apiview"], + options: { + "@azure-tools/typespec-apiview": { + ...options, + "output-file": outPath, + }, + }, + miscOptions: { "disable-linter": true }, + }); + return diagnostics; +} + +export async function apiViewFor(code: string, options: ApiViewEmitterOptions): Promise { + const runner = await createApiViewTestRunner({ withVersioning: true }); + const outPath = resolvePath("/apiview.json"); + await runner.compile(code, { + noEmit: false, + emit: ["@azure-tools/typespec-apiview"], + options: { + "@azure-tools/typespec-apiview": { + ...options, + "output-file": outPath, + }, + }, + miscOptions: { "disable-linter": true }, + }); + + const jsonText = runner.fs.get(outPath)!; + const apiview = JSON.parse(jsonText) as CodeFile; + return apiview; +} + +export function apiViewText(apiview: CodeFile): string[] { + return apiview.ReviewLines.map((l) => reviewLineText(l, 0)) + .join("\n") + .split("\n"); +} + +function getBaseIndent(lines: string[]): number { + for (const line of lines) { + if (line.trim() !== "") { + return line.length - line.trimStart().length; + } + } + return 0; +} + +/** Eliminates leading indentation and blank links that can mess with comparisons */ +function trimLines(lines: string[]): string[] { + const trimmed: string[] = []; + const indent = getBaseIndent(lines); + + // if first line is blank, skip it + if (lines[0].trim() === "") { + lines = lines.slice(1); + } + + for (const line of lines) { + if (line.trim() === "") { + // ensure blank lines are compared consistently + trimmed.push(""); + } else { + // remove any leading indentation + trimmed.push(line.substring(indent)); + } + } + + // if last line is blank, skip it + const lastLine = trimmed.pop(); + if (lastLine && lastLine.trim() !== "") { + trimmed.push(lastLine); + } + return trimmed; +} + +/** Compares an expected string to a subset of the actual output. */ +export function compare(expect: string, lines: string[], offset: number) { + // split the input into lines and ignore leading or trailing empty lines. + const expectedLines = trimLines(expect.split("\n")); + const actualLines = trimLines(lines.slice(offset)); + for (let x = 0; x < actualLines.length; x++) { + strictEqual( + actualLines[x], + expectedLines[x], + `Actual differed from expected at line #${x + 1}\nACTUAL: '${actualLines[x]}'\nEXPECTED: '${expectedLines[x]}'`, + ); + } +} diff --git a/packages/typespec-apiview/tsconfig.json b/packages/typespec-apiview/tsconfig.json new file mode 100644 index 0000000000..8d8cb28bf5 --- /dev/null +++ b/packages/typespec-apiview/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../core/tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "tsBuildInfoFile": "./temp/tsconfig.tsbuildinfo" + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/typespec-apiview/vitest.config.ts b/packages/typespec-apiview/vitest.config.ts new file mode 100644 index 0000000000..dec912f113 --- /dev/null +++ b/packages/typespec-apiview/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import { defaultTypeSpecVitestConfig } from "../../core/vitest.config"; + +export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6051c634a9..01df0636c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2770,6 +2770,51 @@ importers: specifier: ^4.0.15 version: 4.0.15(@types/node@25.0.2)(@vitest/ui@4.0.15)(happy-dom@20.0.11)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + packages/typespec-apiview: + devDependencies: + '@azure-tools/typespec-azure-core': + specifier: workspace:^ + version: link:../typespec-azure-core + '@types/node': + specifier: ~25.0.2 + version: 25.0.2 + '@typespec/compiler': + specifier: workspace:^ + version: link:../../core/packages/compiler + '@typespec/http': + specifier: workspace:^ + version: link:../../core/packages/http + '@typespec/library-linter': + specifier: workspace:^ + version: link:../../core/packages/library-linter + '@typespec/rest': + specifier: workspace:^ + version: link:../../core/packages/rest + '@typespec/tspd': + specifier: workspace:^ + version: link:../../core/packages/tspd + '@typespec/versioning': + specifier: workspace:^ + version: link:../../core/packages/versioning + '@vitest/coverage-v8': + specifier: ^4.0.15 + version: 4.0.15(vitest@4.0.15) + '@vitest/ui': + specifier: ^4.0.15 + version: 4.0.15(vitest@4.0.15) + c8: + specifier: ^10.1.3 + version: 10.1.3 + rimraf: + specifier: ~6.1.2 + version: 6.1.2 + typescript: + specifier: ~5.9.2 + version: 5.9.3 + vitest: + specifier: ^4.0.15 + version: 4.0.15(@types/node@25.0.2)(@vitest/ui@4.0.15)(happy-dom@20.0.11)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2) + packages/typespec-autorest: dependencies: '@typespec/xml': diff --git a/tsconfig.ws.json b/tsconfig.ws.json index d34c15b6c8..9e26119de3 100644 --- a/tsconfig.ws.json +++ b/tsconfig.ws.json @@ -1,6 +1,7 @@ { "references": [ { "path": "core/tsconfig.ws.json" }, + { "path": "packages/typespec-apiview/tsconfig.json" }, { "path": "packages/typespec-autorest/tsconfig.json" }, { "path": "packages/typespec-autorest-canonical/tsconfig.json" }, { "path": "packages/typespec-azure-core/tsconfig.json" },