From 1a75b70fa06bde344a0f896245e477de511341f5 Mon Sep 17 00:00:00 2001 From: Travis Prescott Date: Wed, 5 Oct 2022 11:43:10 -0700 Subject: [PATCH] [CADL APIView] Add cadl-apiview emitter (#4160) * Add cadl-apiview emitter. * Code review feedback. * Top-level organization updates. * Code review feedback. * Refactoring per Timothee's suggestion. * Emitter updates. * Adding tests. * Support union types. Fix anonymous model formatting. * Improve inline model display. * Progress on expanded inline models. * Add more tests. * Refactor tests. * Fix singleton NamespaceStack. * Add yaml to create pipeline to build, test and release cadl APIView emitter * Enable cadl emmitter test * Fix wonky definition IDs. * Support rendering template parameters. * More tests. Verify no duplicate definitionIds. * Give return types on expanded operation signatures a definition ID. * Add changelog and fix issue with definition IDs. Co-authored-by: praveenkuttappan --- .editorconfig | 4 +- .gitignore | 4 + .../apiview/emitters/cadl-apiview/.c8rc.json | 3 + .../emitters/cadl-apiview/.eslintrc.cjs | 6 + .../emitters/cadl-apiview/.mocharc.yaml | 5 + .../emitters/cadl-apiview/.prettierignore | 319 ++++++ .../emitters/cadl-apiview/.prettierrc.yaml | 8 + .../emitters/cadl-apiview/CHANGELOG.md | 4 + tools/apiview/emitters/cadl-apiview/README.md | 65 ++ tools/apiview/emitters/cadl-apiview/ci.yml | 105 ++ .../apiview/emitters/cadl-apiview/cspell.yaml | 67 ++ .../emitters/cadl-apiview/package.json | 81 ++ .../emitters/cadl-apiview/src/apiview.ts | 964 ++++++++++++++++++ .../emitters/cadl-apiview/src/diagnostic.ts | 23 + .../emitters/cadl-apiview/src/emitter.ts | 67 ++ .../emitters/cadl-apiview/src/index.ts | 4 + .../apiview/emitters/cadl-apiview/src/lib.ts | 22 + .../cadl-apiview/src/namespace-model.ts | 213 ++++ .../emitters/cadl-apiview/src/navigation.ts | 122 +++ .../cadl-apiview/src/testing/index.ts | 20 + .../emitters/cadl-apiview/src/version.ts | 1 + .../cadl-apiview/test/apiview.test.ts | 448 ++++++++ .../emitters/cadl-apiview/test/test-host.ts | 28 + .../emitters/cadl-apiview/tsconfig.json | 29 + 24 files changed, 2611 insertions(+), 1 deletion(-) create mode 100644 tools/apiview/emitters/cadl-apiview/.c8rc.json create mode 100644 tools/apiview/emitters/cadl-apiview/.eslintrc.cjs create mode 100644 tools/apiview/emitters/cadl-apiview/.mocharc.yaml create mode 100644 tools/apiview/emitters/cadl-apiview/.prettierignore create mode 100644 tools/apiview/emitters/cadl-apiview/.prettierrc.yaml create mode 100644 tools/apiview/emitters/cadl-apiview/CHANGELOG.md create mode 100644 tools/apiview/emitters/cadl-apiview/README.md create mode 100644 tools/apiview/emitters/cadl-apiview/ci.yml create mode 100644 tools/apiview/emitters/cadl-apiview/cspell.yaml create mode 100644 tools/apiview/emitters/cadl-apiview/package.json create mode 100644 tools/apiview/emitters/cadl-apiview/src/apiview.ts create mode 100644 tools/apiview/emitters/cadl-apiview/src/diagnostic.ts create mode 100644 tools/apiview/emitters/cadl-apiview/src/emitter.ts create mode 100644 tools/apiview/emitters/cadl-apiview/src/index.ts create mode 100644 tools/apiview/emitters/cadl-apiview/src/lib.ts create mode 100644 tools/apiview/emitters/cadl-apiview/src/namespace-model.ts create mode 100644 tools/apiview/emitters/cadl-apiview/src/navigation.ts create mode 100644 tools/apiview/emitters/cadl-apiview/src/testing/index.ts create mode 100644 tools/apiview/emitters/cadl-apiview/src/version.ts create mode 100644 tools/apiview/emitters/cadl-apiview/test/apiview.test.ts create mode 100644 tools/apiview/emitters/cadl-apiview/test/test-host.ts create mode 100644 tools/apiview/emitters/cadl-apiview/tsconfig.json diff --git a/.editorconfig b/.editorconfig index 48ed961520b..4ea6d3bc8ac 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,6 +5,8 @@ root = true # All files [*] indent_style = space +charset = utf-8 +end_of_line=lf # XML project files [*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] @@ -15,7 +17,7 @@ indent_size = 2 indent_size = 2 # Code files -[*.{cs,csx,vb,vbx}] +[*.{cs,csx,vb,vbx,ts}] indent_size = 4 insert_final_newline = true diff --git a/.gitignore b/.gitignore index c38f793a908..57d04b2e371 100644 --- a/.gitignore +++ b/.gitignore @@ -499,3 +499,7 @@ tools/oav-traffic-converter/build/ # ios src/swift/*.xcworkspace/xcuserdata/ src/swift/**/*.xcodeproj/xcuserdata/ + +# Cadl APIView +cadl-output/ +tools/apiview/emitters/cadl-apiview/temp/ \ No newline at end of file diff --git a/tools/apiview/emitters/cadl-apiview/.c8rc.json b/tools/apiview/emitters/cadl-apiview/.c8rc.json new file mode 100644 index 00000000000..6ce87a95dee --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/.c8rc.json @@ -0,0 +1,3 @@ +{ + "reporter": ["cobertura", "json", "text"] +} \ No newline at end of file diff --git a/tools/apiview/emitters/cadl-apiview/.eslintrc.cjs b/tools/apiview/emitters/cadl-apiview/.eslintrc.cjs new file mode 100644 index 00000000000..8ce089ccb78 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/.eslintrc.cjs @@ -0,0 +1,6 @@ +require("@cadl-lang/eslint-config-cadl/patch/modern-module-resolution"); + +module.exports = { + extends: "@cadl-lang/eslint-config-cadl", + parserOptions: { tsconfigRootDir: __dirname }, +}; diff --git a/tools/apiview/emitters/cadl-apiview/.mocharc.yaml b/tools/apiview/emitters/cadl-apiview/.mocharc.yaml new file mode 100644 index 00000000000..325aa75f436 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/.mocharc.yaml @@ -0,0 +1,5 @@ +timeout: 5000 +require: source-map-support/register +spec: "dist/test/**/*.test.js" +ignore: "dist/test/manual/**/*.js" + diff --git a/tools/apiview/emitters/cadl-apiview/.prettierignore b/tools/apiview/emitters/cadl-apiview/.prettierignore new file mode 100644 index 00000000000..645633c0707 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/.prettierignore @@ -0,0 +1,319 @@ +#------------------------------------------------------------------------------------------------------------------- +# Keep this section in sync with .gitignore +#------------------------------------------------------------------------------------------------------------------- + +## Ignore generated code +PackageTest/NugetPackageTest/Generated +src/generator/AutoRest.NodeJS.Tests/AcceptanceTests/*.js + +## Ignore user-specific files, temporary files, build results, etc. +compare-results/* + +# User-specific files +*.suo +*.user +*.sln.docstates +.vs +launchSettings.json + +# Build results +binaries/ +[Dd]ebug*/ +[Rr]elease/ + +[Tt]est[Rr]esult* +[Bb]uild[Ll]og.* +[Bb]uild.out + +*_i.c +*_p.c +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.vspscc +*.vssscc +.builds + +*.pidb + +*.log* +*.scc +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opensdf +*.sdf + +# Visual Studio profiler +*.psess +*.vsp + +# VS Code settings +*.vscode + +# Code analysis +*.CodeAnalysisLog.xml + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a Visual Studio add-in +_ReSharper*/ +*.[Rr]e[Ss]harper + +# NCrunch +*.ncrunch* +.*crunch*.local.xml + +# Installshield output folder +[Ee]xpress + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish + +# Publish Web Output +*.[Pp]ublish.xml + +# Others +[Bb]in +[Oo]bj +sql +*.Cache +ClientBin +[Ss]tyle[Cc]op.* +~$* +*.dbmdl + +# Build tasks +[Tt]ools/*.dll +*.class + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.ear + +# Azure Tooling # +node_modules +.ntvs_analysis.dat + +# Eclipse # +*.pydevproject +.project +.metadata +bin/** +tmp/** +tmp/**/* +*.bak +*.swp +*~.nib +local.properties +.classpath +.settings/ +.loadpath + +# Xamarin # +*.userprefs + +# Other Tooling # +.classpath +.project +target +build +reports +.gradle +.idea +*.iml +Tools/7-Zip +.gitrevision + +# Sensitive files +*.keys +*.pfx +*.cer +*.pem +*.jks + +# Backup & report files from converting a project to a new version of VS. +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML + +# Mac OS # +.DS_Store +.DS_Store? + +# Windows # +Thumbs.db + +# Mono +*dll.mdb +*exe.mdb + +#old nuget restore folder +.nuget/ +src/generator/AutoRest.Ruby*Tests/Gemfile.lock +src/generator/AutoRest.Ruby*/*/RspecTests/Generated/* + +#netcore +/NetCore +*.lock.json + +#dnx installation +dnx-clr-win-x86*/ +dnx-coreclr-win-x86*/ +/dnx + +# Gemfile.lock +Gemfile.lock + +# go ignore +src/generator/AutoRest.Go.Tests/pkg/* +src/generator/AutoRest.Go.Tests/bin/* +src/generator/AutoRest.Go.Tests/src/github.com/* +src/generator/AutoRest.Go.Tests/src/tests/generated/* +src/generator/AutoRest.Go.Tests/src/tests/vendor/* +src/generator/AutoRest.Go.Tests/src/tests/glide.lock + +autorest/**/*.js +core/**/*.js + +*.js.map + +# backup files +*~ + +#client runtime +src/client/**/* + +src/extension/old/**/* +*.d.ts + +src/bootstrapper +src/extension/out +src/next-gen + +package/nuget/tools +package/chocolatey/*.nupkg + +Samples/**/*.map + +# npm (we do want to test for most recent versions) +**/package-lock.json +**/dist/ +src/*/nm +/nm/ +*.tgz + + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +packages/*/coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# next.js build output +.next + +# Common toolchain intermediate files +temp + +# Rush files +common/temp/** +package-deps.json + +# Code generation output for regression tests +core/test/regression + +#------------------------------------------------------------------------------------------------------------------- +# Prettier-specific overrides +#------------------------------------------------------------------------------------------------------------------- + +# Rush files +common/changes/ +common/scripts/ +CHANGELOG.* + +# Package manager files +pnpm-lock.yaml +yarn.lock +package-lock.json +shrinkwrap.json + +# Build outputs +dist + +# MICROSOFT SECURITY.md +/SECURITY.md \ No newline at end of file diff --git a/tools/apiview/emitters/cadl-apiview/.prettierrc.yaml b/tools/apiview/emitters/cadl-apiview/.prettierrc.yaml new file mode 100644 index 00000000000..544f4b35e98 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/.prettierrc.yaml @@ -0,0 +1,8 @@ +trailingComma: "all" +printWidth: 120 +quoteProps: "consistent" +endOfLine: lf +arrowParens: always +plugins: + - "./node_modules/@cadl-lang/prettier-plugin-cadl" +overrides: [{ "files": "*.cadl", "options": { "parser": "cadl" } }] diff --git a/tools/apiview/emitters/cadl-apiview/CHANGELOG.md b/tools/apiview/emitters/cadl-apiview/CHANGELOG.md new file mode 100644 index 00000000000..d7da63cae65 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/CHANGELOG.md @@ -0,0 +1,4 @@ +# Release History + +## Version 0.1.0 (10-5-2022) +Initial release. \ No newline at end of file diff --git a/tools/apiview/emitters/cadl-apiview/README.md b/tools/apiview/emitters/cadl-apiview/README.md new file mode 100644 index 00000000000..ab09eb61347 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/README.md @@ -0,0 +1,65 @@ +# Cadl APIView Emitter + +This package provides the [Cadl](https://github.com/microsoft/cadl) emitter to produce APIView token file output from Cadl source. + +## Install + +Add `@azure-tools/cadl-apiview` to your `package.json` and run `npm install`. + +## Emit APIView spec + +1. Via the command line + +```bash +cadl compile {path to cadl project} --emit=@azure-tools/cadl-apiview +``` + +2. Via the config + +Add the following to the `cadl-project.yaml` file. + +```yaml +emitters: + @azure-tools/cadl-apiview: true +``` + +For configuration [see options](#emitter-options) + +## Use APIView-specific decorators: + +Currently there are no APIView-specific decorators... + +## Emitter options: + +Emitter options can be configured via the `cadl-project.yaml` configuration: + +```yaml +emitters: + '@azure-tools/cadl-apiview': + : + + +# For example +emitters: + '@azure-tools/cadl-apiview': + output-file: my-custom-apiview.json +``` + +or via the command line with + +```bash +--option "@azure-tools/cadl-apiview.=" + +# For example +--option "@azure-tools/cadl-apiview.output-file=my-custom-apiview.json" +``` + +### `output-file` + +Configure the name of the output JSON token file relative to the compiler `output-path`. + +## See also + +- [Cadl Getting Started](https://github.com/microsoft/cadl#getting-started) +- [Cadl Tutorial](https://github.com/microsoft/cadl/blob/main/docs/tutorial.md) +- [Cadl for the OpenAPI Developer](https://github.com/microsoft/cadl/blob/main/docs/cadl-for-openapi-dev.md) diff --git a/tools/apiview/emitters/cadl-apiview/ci.yml b/tools/apiview/emitters/cadl-apiview/ci.yml new file mode 100644 index 00000000000..a65515eb2cf --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/ci.yml @@ -0,0 +1,105 @@ +# NOTE: Please refer to https://aka.ms/azsdk/engsys/ci-yaml before editing this file. +trigger: + branches: + include: + - main + - feature/* + - release/* + - hotfix/* + paths: + include: + - tools/apiview/emitters/cadl-apiview + +pr: + branches: + include: + - main + - feature/* + - release/* + - hotfix/* + paths: + include: + - tools/apiview/emitters/cadl-apiview + +variables: + NodeVersion: '16.x' + CadlEmitterDirectory: 'tools/apiview/emitters/cadl-apiview' + ArtifactName: 'apiview' + FeedRegistry: 'https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js/npm/registry/' + +stages: + - stage: 'Build' + jobs: + - job: 'Build' + + pool: + name: azsdk-pool-mms-ubuntu-2004-general + vmImage: MMSUbuntu20.04 + + steps: + - task: NodeTool@0 + inputs: + versionSpec: '$(NodeVersion)' + displayName: 'Use NodeJS $(NodeVersion)' + + - script: | + npm install -g npm@8.16.0 + displayName: "Install npm 8.16.0" + + - script: | + npm install + workingDirectory: $(CadlEmitterDirectory) + displayName: "Install npm packages for CADL emiter" + + - script: | + npm run-script build + workingDirectory: $(CadlEmitterDirectory) + displayName: "Build CADL emitter" + + - script: | + npm run-script test + workingDirectory: $(CadlEmitterDirectory) + displayName: "Test CADL emitter" + + - pwsh: | + npm pack $(CadlEmitterDirectory) + Copy-Item ./*.tgz $(Build.ArtifactStagingDirectory) + displayName: "Pack CADL Emitter" + + - task: PublishBuildArtifacts@1 + inputs: + pathtoPublish: '$(Build.ArtifactStagingDirectory)' + artifactName: $(ArtifactName) + + - ${{if and(eq(variables['Build.Reason'], 'Manual'), eq(variables['System.TeamProject'], 'internal'))}}: + - stage: 'Release' + dependsOn: Build + condition: Succeeded() + jobs: + - job: PublishPackage + displayName: 'Publish cadl-apiview package to devops feed' + pool: + name: azsdk-pool-mms-ubuntu-2004-general + vmImage: MMSUbuntu20.04 + steps: + - checkout: none + - download: current + + - pwsh: | + $detectedPackageName=Get-ChildItem $(Pipeline.Workspace)/$(ArtifactName)/*.tgz + Write-Host "Detected package name: $detectedPackageName" + $registry="$(FeedRegistry)" + $regAuth=$registry.replace("https:","") + $npmReg = $regAuth.replace("registry/",""); + $env:NPM_TOKEN="$(azure-sdk-devops-npm-token)" + Write-Host "Publishing to $($regAuth)" + npm config set $regAuth`:username=azure-sdk + npm config set $regAuth`:_password=`$`{NPM_TOKEN`} + npm config set $regAuth`:email=not_set + npm config set $npmReg`:username=azure-sdk + npm config set $npmReg`:_password=`$`{NPM_TOKEN`} + npm config set $npmReg`:email=not_set + Write-Host "Publishing package" + Write-Host "npm publish $detectedPackageName --registry=$registry --always-auth=true" + npm publish $detectedPackageName --registry=$registry --always-auth=true + displayName: Publish package diff --git a/tools/apiview/emitters/cadl-apiview/cspell.yaml b/tools/apiview/emitters/cadl-apiview/cspell.yaml new file mode 100644 index 00000000000..76aaf687b3a --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/cspell.yaml @@ -0,0 +1,67 @@ +version: "0.2" +language: en +allowCompoundWords: true +dictionaries: + - node + - typescript +words: + - autorest + - azsdkengsys + - azurecr + - blockful + - blockless + - cadl + - Contoso + - CRUDL + - devdriven + - dogfood + - eastus + - esbuild + - globby + - Hdvcmxk + - inmemory + - instanceid + - intrinsics + - ints + - jsyaml + - msbuild + - MSRC + - munge + - 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 +ignorePaths: + - "**/node_modules/**" + - "**/dist/**" + - "**/dist-dev/**" + - "**/cadl-output/**" +enableFiletypes: + - cadl \ No newline at end of file diff --git a/tools/apiview/emitters/cadl-apiview/package.json b/tools/apiview/emitters/cadl-apiview/package.json new file mode 100644 index 00000000000..57ececba635 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/package.json @@ -0,0 +1,81 @@ +{ + "name": "@azure-tools/cadl-apiview", + "version": "0.1.0", + "author": "Microsoft Corporation", + "description": "Cadl library for emitting APIView token files from Cadl specifications", + "homepage": "https://github.com/Azure/azure-sdk-tools", + "readme": "https://github.com/Azure/azure-sdk-tools/blob/master/README.md", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/Azure/azure-sdk-tools.git" + }, + "bugs": { + "url": "https://github.com/Azure/azure-sdk-tools/issues" + }, + "keywords": [ + "cadl", + "apiview" + ], + "type": "module", + "main": "dist/src/index.js", + "exports": { + ".": "./dist/src/index.js", + "./testing": "./dist/src/testing/index.js" + }, + "typesVersions": { + "*": { + "*": [ + "./dist/src/index.d.ts" + ], + "testing": [ + "./dist/src/testing/index.d.ts" + ] + } + }, + "cadlMain": "dist/src/index.js", + "engines": { + "node": ">=16.0.0" + }, + "scripts": { + "clean": "rimraf ./dist ./temp", + "prebuild": "node -p \"'export const LIB_VERSION = ' + JSON.stringify(require('./package.json').version) + ';'\" > src/version.ts", + "build": "npm run prebuild && tsc -p . && npm run lint-cadl-library", + "watch": "tsc -p . --watch", + "lint-cadl-library": "cadl compile . --warn-as-error --import @cadl-lang/library-linter --no-emit", + "test": "mocha", + "test-official": "c8 mocha --forbid-only --reporter mocha-multi-reporters", + "lint": "eslint . --ext .ts --max-warnings=0", + "lint:fix": "eslint . --fix --ext .ts" + }, + "files": [ + "lib/*.cadl", + "dist/**", + "!dist/test/**" + ], + "devDependencies": { + "@azure-tools/cadl-azure-core": "~0.7.0", + "@cadl-lang/compiler": "~0.35.0", + "@cadl-lang/eslint-config-cadl": "~0.4.1", + "@cadl-lang/eslint-plugin": "~0.1.1", + "@cadl-lang/library-linter": "~0.2.0", + "@cadl-lang/rest": "~0.17.0", + "@cadl-lang/versioning": "~0.8.0", + "@types/mocha": "~9.1.0", + "@types/node": "~16.0.3", + "@cadl-lang/prettier-plugin-cadl": "^0.5.15", + "@changesets/cli": "^2.24.4", + "c8": "~7.11.0", + "cspell": "^6.8.1", + "eslint": "^8.23.1", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-unicorn": "^43.0.2", + "prettier": "^2.7.1", + "rimraf": "^3.0.2", + "typescript": "^4.8.3", + "mocha": "~9.2.0", + "mocha-junit-reporter": "~2.0.2", + "mocha-multi-reporters": "~1.5.1", + "source-map-support": "^0.5.19" + } +} diff --git a/tools/apiview/emitters/cadl-apiview/src/apiview.ts b/tools/apiview/emitters/cadl-apiview/src/apiview.ts new file mode 100644 index 00000000000..066f830bd20 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/src/apiview.ts @@ -0,0 +1,964 @@ +import { + ArrayExpressionNode, + BaseNode, + BooleanLiteralNode, + DecoratorExpressionNode, + EnumMemberNode, + EnumSpreadMemberNode, + EnumStatementNode, + Expression, + IdentifierNode, + InterfaceStatementNode, + IntersectionExpressionNode, + MemberExpressionNode, + ModelExpressionNode, + ModelPropertyNode, + ModelSpreadPropertyNode, + ModelStatementNode, + Namespace, + navigateProgram, + NumericLiteralNode, + OperationSignatureDeclarationNode, + OperationSignatureReferenceNode, + OperationStatementNode, + Program, + StringLiteralNode, + SyntaxKind, + TemplateParameterDeclarationNode, + TupleExpressionNode, + TypeReferenceNode, + UnionExpressionNode, + UnionStatementNode, + UnionVariantNode, +} from "@cadl-lang/compiler"; +import { ApiViewDiagnostic, ApiViewDiagnosticLevel } from "./diagnostic.js"; +import { ApiViewNavigation } from "./navigation.js"; +import { generateId, NamespaceModel } from "./namespace-model.js"; +import { LIB_VERSION } from "./version.js"; +import { type } from "os"; + +const WHITESPACE = " "; + +export const enum ApiViewTokenKind { + Text = 0, + Newline = 1, + Whitespace = 2, + Punctuation = 3, + Keyword = 4, + LineIdMarker = 5, // use this if there are no visible tokens with ID on the line but you still want to be able to leave a comment for it + TypeName = 6, + MemberName = 7, + StringLiteral = 8, + Literal = 9, + Comment = 10, + DocumentRangeStart = 11, + DocumentRangeEnd = 12, + DeprecatedRangeStart = 13, + DeprecatedRangeEnd = 14, + SkipDiffRangeStart = 15, + SkipDiffRangeEnd = 16 +} + +export interface ApiViewToken { + Kind: ApiViewTokenKind; + Value?: string; + DefinitionId?: string; + NavigateToId?: string; + CrossLanguageDefinitionId?: string; +} + +export interface ApiViewDocument { + Name: string; + PackageName: string; + Tokens: ApiViewToken[]; + Navigation: ApiViewNavigation[]; + Diagnostics: ApiViewDiagnostic[]; + VersionString: string; + Language: string; +} + +export class ApiView { + name: string; + packageName: string; + tokens: ApiViewToken[] = []; + navigationItems: ApiViewNavigation[] = []; + diagnostics: ApiViewDiagnostic[] = []; + versionString: string; + + indentString: string = ""; + indentSize: number = 2; + namespaceStack = new NamespaceStack(); + typeDeclarations = new Set(); + + constructor(name: string, packageName: string, versionString: string) { + this.name = name; + this.packageName = packageName; + this.versionString = versionString; + + this.emitHeader(); + } + + token(kind: ApiViewTokenKind, value?: string, lineId?: string, navigateToId?: string) { + this.tokens.push({ + Kind: kind, + Value: value, + DefinitionId: lineId, + NavigateToId: navigateToId, + }); + } + + indent() { + this.trim(); + this.indentString = WHITESPACE.repeat(this.indentString.length + this.indentSize); + if (this.indentString.length) { + this.tokens.push({ Kind: ApiViewTokenKind.Whitespace, Value: this.indentString }); + } + } + + deindent() { + this.trim(); + this.indentString = WHITESPACE.repeat(this.indentString.length - this.indentSize); + if (this.indentString.length) { + this.tokens.push({ Kind: ApiViewTokenKind.Whitespace, Value: this.indentString }); + } + } + + trim() { + let last = this.tokens[this.tokens.length - 1] + while (last) { + if (last.Kind == ApiViewTokenKind.Whitespace) { + this.tokens.pop(); + last = this.tokens[this.tokens.length - 1]; + } else { + return; + } + } + } + + beginGroup() { + this.punctuation("{", true, false); + this.blankLines(0); + this.indent(); + } + + endGroup() { + this.blankLines(0); + this.deindent(); + this.punctuation("}"); + } + + whitespace(count: number = 1) { + this.tokens.push({ + Kind: ApiViewTokenKind.Whitespace, + Value: WHITESPACE.repeat(count), + }); + } + + space() { + if (this.tokens[this.tokens.length - 1]?.Kind != ApiViewTokenKind.Whitespace) { + this.tokens.push({ + Kind: ApiViewTokenKind.Whitespace, + Value: WHITESPACE, + }); + } + } + + newline() { + this.trim(); + this.tokens.push({ + Kind: ApiViewTokenKind.Newline, + }); + if (this.indentString.length) { + this.tokens.push({ Kind: ApiViewTokenKind.Whitespace, Value: this.indentString }); + } + } + + blankLines(count: number) { + // count the number of trailing newlines (ignoring indent whitespace) + let newlineCount: number = 0; + for (let i = this.tokens.length; i > 0; i--) { + const token = this.tokens[i - 1]; + if (token.Kind == ApiViewTokenKind.Newline) { + newlineCount++; + } else if (token.Kind == ApiViewTokenKind.Whitespace) { + continue; + } else { + break; + } + } + if (newlineCount < count + 1) { + // if there aren't new enough newlines, add some + const toAdd = count + 1 - newlineCount; + for (let i = 0; i < toAdd; i++) { + this.newline(); + } + } else if (newlineCount > count + 1) { + // if there are too many newlines, remove some + let toRemove = newlineCount - (count + 1); + while (toRemove) { + const popped = this.tokens.pop(); + if (popped?.Kind == ApiViewTokenKind.Newline) { + toRemove--; + } + } + } + } + + punctuation(value: string, prefixSpace: boolean = false, postfixSpace: boolean = false) { + if (prefixSpace) { + this.space(); + } + this.tokens.push({ + Kind: ApiViewTokenKind.Punctuation, + Value: value, + }); + if (postfixSpace) { + this.space(); + } + } + + lineMarker() { + this.tokens.push({ + Kind: ApiViewTokenKind.LineIdMarker, + DefinitionId: this.namespaceStack.value(), + }); + } + + text(text: string, addCrossLanguageId: boolean = false) { + const token = { + Kind: ApiViewTokenKind.Text, + Value: text, + }; + // TODO: Cross-language definition ID + this.tokens.push(token); + } + + keyword(keyword: string, prefixSpace: boolean = false, postfixSpace: boolean = false) { + if (prefixSpace) { + this.space(); + } + this.tokens.push({ + Kind: ApiViewTokenKind.Keyword, + Value: keyword, + }); + if (postfixSpace) { + this.space(); + } + } + + typeDeclaration(typeName: string, typeId: string | undefined) { + if (typeId) { + if (this.typeDeclarations.has(typeId)) { + throw new Error(`Duplication ID "${typeId}" for declaration will result in bugs.`); + } + this.typeDeclarations.add(typeId); + } + this.tokens.push({ + Kind: ApiViewTokenKind.TypeName, + DefinitionId: typeId, + Value: typeName, + }); + } + + typeReference(typeName: string, targetId?: string) { + this.tokens.push({ + Kind: ApiViewTokenKind.TypeName, + Value: typeName, + NavigateToId: targetId ?? "__MISSING__", + }); + } + + member(name: string) { + this.tokens.push({ + Kind: ApiViewTokenKind.MemberName, + Value: name, + }); + } + + stringLiteral(value: string) { + this.tokens.push({ + Kind: ApiViewTokenKind.StringLiteral, + Value: `\u0022${value}\u0022`, + }); + } + + literal(value: string) { + this.tokens.push({ + Kind: ApiViewTokenKind.StringLiteral, + Value: value, + }); + } + + diagnostic(message: string, targetId: string, level: ApiViewDiagnosticLevel) { + this.diagnostics.push(new ApiViewDiagnostic(message, targetId, level)); + } + + navigation(item: ApiViewNavigation) { + this.navigationItems.push(item); + } + + emit(program: Program) { + let allNamespaces = new Map(); + + // collect namespaces in program + navigateProgram(program, { + namespace(obj) { + const name = program.checker.getNamespaceString(obj); + allNamespaces.set(name, obj); + }, + }); + allNamespaces = new Map([...allNamespaces].sort()); + + // Skip namespaces which are outside the root namespace. + for (const [name, ns] of allNamespaces.entries()) { + if (!name.startsWith(this.packageName)) { + continue; + } + const nsModel = new NamespaceModel(name, ns); + this.tokenizeNamespaceModel(nsModel); + this.buildNavigation(nsModel); + } + } + + private emitHeader() { + const toolVersion = LIB_VERSION; + const headerText = `// Package parsed using @azure-tools/cadl-apiview (version:${toolVersion})`; + this.token(ApiViewTokenKind.SkipDiffRangeStart); + this.literal(headerText); + this.namespaceStack.push("GLOBAL"); + this.lineMarker(); + this.namespaceStack.pop(); + // TODO: Source URL? + this.token(ApiViewTokenKind.SkipDiffRangeEnd); + this.blankLines(2); + } + + tokenize(node: BaseNode) { + let obj; + switch (node.kind) { + case SyntaxKind.AliasStatement: + throw new Error(`Case "AliasStatement" not implemented`); + case SyntaxKind.ArrayExpression: + obj = node as ArrayExpressionNode; + this.tokenize(obj.elementType); + this.punctuation("[]"); + 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.CadlScript: + throw new Error(`Case "CadlScript" not implemented`); + case SyntaxKind.DecoratorExpression: + obj = node as DecoratorExpressionNode; + this.punctuation("@", false, false); + this.tokenizeIdentifier(obj.target, "member"); + this.lineMarker(); + if (obj.arguments.length) { + const last = obj.arguments.length - 1; + this.punctuation("(", false, false); + for (let x = 0; x < obj.arguments.length; x++) { + const arg = obj.arguments[x]; + this.tokenize(arg); + if (x != last) { + this.punctuation(",", false, true); + } + } + this.punctuation(")", false, false); + } + break; + case SyntaxKind.DirectiveExpression: + throw new Error(`Case "DirectiveExpression" not implemented`); + case SyntaxKind.EmptyStatement: + throw new Error(`Case "EmptyStatement" not implemented`); + case SyntaxKind.EnumMember: + obj = node as EnumMemberNode; + this.tokenizeDecorators(obj.decorators, false); + this.tokenizeIdentifier(obj.id, "member"); + this.lineMarker(); + if (obj.value != undefined) { + this.punctuation(":", false, true); + this.tokenize(obj.value); + } + break; + case SyntaxKind.EnumSpreadMember: + obj = node as EnumSpreadMemberNode; + this.punctuation("...", false, false); + this.tokenize(obj.target); + this.lineMarker(); + break; + case SyntaxKind.EnumStatement: + this.tokenizeEnumStatement(node as EnumStatementNode); + break; + 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, 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("&", true, 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.tokenizeModelExpression(node as ModelExpressionNode, true, false); + 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", true, true); + break; + case SyntaxKind.NumericLiteral: + obj = node as NumericLiteralNode; + this.literal(obj.value.toString()); + break; + case SyntaxKind.OperationStatement: + this.tokenizeOperationStatement(node as OperationStatementNode); + break; + case SyntaxKind.OperationSignatureDeclaration: + obj = node as OperationSignatureDeclarationNode; + this.punctuation("(", false, false); + // TODO: heuristic for whether operation signature should be inlined or not. + const inline = false; + this.tokenizeModelExpression(obj.parameters, false, inline); + this.punctuation("):", false, true); + this.tokenizeReturnType(obj, inline); + break; + case SyntaxKind.OperationSignatureReference: + obj = node as OperationSignatureReferenceNode; + this.keyword("is", true, 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.TemplateParameterDeclaration: + obj = node as TemplateParameterDeclarationNode; + this.tokenize(obj.id); + if (obj.constraint != undefined) { + this.keyword("extends", true, true); + this.tokenize(obj.constraint); + } + if (obj.default != undefined) { + this.punctuation("=", true, true); + this.tokenize(obj.default); + } + break; + case SyntaxKind.TupleExpression: + obj = node as TupleExpressionNode; + this.punctuation("[", true, 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.renderComma(); + } + } + this.punctuation("]", true, false); + break; + case SyntaxKind.TypeReference: + obj = node as TypeReferenceNode; + this.tokenizeIdentifier(obj.target, "reference"); + if (obj.arguments.length) { + this.punctuation("<", false, 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.renderComma(); + } + } + this.punctuation(">"); + } + 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("|", true, 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("any", true, true); + break; + case SyntaxKind.UsingStatement: + throw new Error(`Case "UsingStatement" not implemented`); + case SyntaxKind.VoidKeyword: + this.keyword("void", true, true); + break; + default: + // All Projection* cases should fall in here... + throw new Error(`Case "${node.kind.toString()}" not implemented`); + } + } + + private tokenizeModelStatement(node: ModelStatementNode) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecorators(node.decorators, false); + this.keyword("model", false, true); + this.tokenizeIdentifier(node.id, "declaration"); + if (node.extends != undefined) { + this.keyword("extends", true, true); + this.tokenize(node.extends); + } + if (node.is != undefined) { + this.keyword("is", true, true); + this.tokenize(node.is); + } + this.tokenizeTemplateParameters(node.templateParameters); + if (node.properties.length) { + this.beginGroup(); + for (const prop of node.properties) { + const propName = this.getNameForNode(prop); + this.namespaceStack.push(propName); + this.tokenize(prop); + this.punctuation(";", false, false); + this.namespaceStack.pop(); + this.blankLines(0); + } + this.endGroup(); + } else { + this.punctuation("{}", true, false); + } + this.namespaceStack.pop(); + } + + private tokenizeInterfaceStatement(node: InterfaceStatementNode) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecorators(node.decorators, false); + this.keyword("interface", false, true); + this.tokenizeIdentifier(node.id, "declaration"); + this.tokenizeTemplateParameters(node.templateParameters); + this.beginGroup(); + for (let x = 0; x < node.operations.length; x++) { + const op = node.operations[x]; + this.tokenizeOperationStatement(op, true); + this.blankLines((x != node.operations.length -1) ? 1 : 0); + } + this.endGroup(); + this.namespaceStack.pop(); + } + + private tokenizeEnumStatement(node: EnumStatementNode) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecorators(node.decorators, false); + this.keyword("enum", false, true); + this.tokenizeIdentifier(node.id, "declaration"); + this.beginGroup(); + for (const member of node.members) { + const memberName = this.getNameForNode(member); + this.namespaceStack.push(memberName); + this.tokenize(member); + this.punctuation(","); + this.namespaceStack.pop(); + this.blankLines(0); + } + this.endGroup(); + this.namespaceStack.pop(); + } + + private tokenizeUnionStatement(node: UnionStatementNode) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecorators(node.decorators, false); + this.keyword("union", false, true); + this.tokenizeIdentifier(node.id, "declaration"); + this.beginGroup(); + 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.blankLines(0); + } + this.namespaceStack.pop(); + this.endGroup(); + } + + private tokenizeUnionVariant(node: UnionVariantNode) { + this.tokenizeDecorators(node.decorators, false); + this.tokenizeIdentifier(node.id, "member"); + this.lineMarker(); + this.punctuation(":", false, true); + this.tokenize(node.value); + } + + private tokenizeModelProperty(node: ModelPropertyNode, inline: boolean) { + this.tokenizeDecorators(node.decorators, inline); + this.tokenizeIdentifier(node.id, "member"); + this.lineMarker(); + this.punctuation(node.optional ? "?:" : ":", false, true); + this.tokenize(node.value); + if (node.default != undefined) { + this.punctuation("=", true, true); + this.tokenize(node.default); + } + } + + private tokenizeModelExpressionInline(node: ModelExpressionNode, displayBrackets: boolean) { + if (node.properties.length) { + if (displayBrackets) { + this.punctuation("{", true, true); + } + for (let x = 0; x < node.properties.length; x++) { + const prop = node.properties[x]; + switch (prop.kind) { + case SyntaxKind.ModelProperty: + this.tokenizeModelProperty(prop, true); + break; + case SyntaxKind.ModelSpreadProperty: + this.tokenize(prop); + break; + } + if (x != node.properties.length - 1) { + this.punctuation(",", false, true); + } + } + if (displayBrackets) { + this.punctuation("}", true, true); + } + } + } + + private tokenizeModelExpressionExpanded(node: ModelExpressionNode, displayBrackets: boolean) { + if (node.properties.length) { + this.blankLines(0); + this.indent(); + if (displayBrackets) { + this.punctuation("{", false, false); + this.blankLines(0); + 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 (x != node.properties.length - 1) { + this.renderComma(); + } + this.blankLines(0); + } + this.namespaceStack.pop(); + this.blankLines(0); + if (displayBrackets) { + this.deindent(); + this.punctuation("}", false, false); + this.blankLines(0); + } + this.trim(); + this.deindent(); + } else if (displayBrackets) { + this.punctuation("{}", true, false); + } + } + + private tokenizeModelExpression( + node: ModelExpressionNode, + displayBrackets: boolean, + inline: boolean + ) { + if (inline) { + this.tokenizeModelExpressionInline(node, displayBrackets) + } else { + this.tokenizeModelExpressionExpanded(node, displayBrackets) + } + } + + private tokenizeOperationStatement(node: OperationStatementNode, suppressOpKeyword: boolean = false) { + this.namespaceStack.push(node.id.sv); + this.tokenizeDecorators(node.decorators, false); + if (!suppressOpKeyword) { + this.keyword("op", false, true); + } + this.tokenizeIdentifier(node.id, "declaration"); + this.tokenizeTemplateParameters(node.templateParameters); + this.tokenize(node.signature); + this.punctuation(";", false, false); + this.namespaceStack.pop(); + } + + private tokenizeNamespaceModel(model: NamespaceModel) { + this.namespaceStack.push(model.name); + this.tokenizeDecorators(model.node.decorators, false); + this.keyword("namespace", false, true); + this.typeDeclaration(model.name, this.namespaceStack.value()); + this.beginGroup(); + 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); + } + this.endGroup(); + this.newline(); + this.namespaceStack.pop(); + } + + private filterDecorators(nodes: readonly DecoratorExpressionNode[]): DecoratorExpressionNode[] { + const filterOut = ["doc", "summary", "example"]; + const filtered = Array(); + for (const node of nodes) { + if (filterOut.includes((node.target as IdentifierNode).sv)) { + continue; + } + filtered.push(node); + } + return filtered; + } + + private tokenizeDecorators(nodes: readonly DecoratorExpressionNode[], inline: boolean) { + const filteredNodes = this.filterDecorators(nodes); + // ensure there is no blank line after opening brace for non-inlined decorators + if (!inline && filteredNodes.length) { + while (this.tokens.length) { + const item = this.tokens.pop()!; + if (item.Kind == ApiViewTokenKind.LineIdMarker && item.DefinitionId == "GLOBAL") { + this.tokens.push(item); + this.blankLines(2); + break; + } else if (item.Kind == ApiViewTokenKind.Punctuation) { + this.tokens.push(item); + const lineCount = ["{", "("].includes(item.Value!) ? 0 : 1; + this.blankLines(lineCount); + break; + } + } + } + // render each decorator + for (const node of filteredNodes) { + this.namespaceStack.push(generateId(node)!); + this.tokenize(node); + if (inline) { + this.whitespace(); + } + this.namespaceStack.pop(); + if (!inline) { + this.blankLines(0); + } + } + } + + 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" + ) { + 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 "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()); + break; + case "reference": + const defId = this.definitionIdFor(node.sv); + this.typeReference(node.sv, defId); + break; + case "member": + this.member(node.sv); + break; + } + } + } + + private tokenizeTemplateParameters(nodes: readonly TemplateParameterDeclarationNode[]) { + if (nodes.length) { + this.punctuation("<", false, false); + for (let x = 0; x < nodes.length; x++) { + const param = nodes[x]; + this.tokenize(param); + if (x != nodes.length - 1) { + this.renderComma(); + this.whitespace(); + } + } + this.punctuation(">"); + } + } + + private tokenizeReturnType(node: OperationSignatureDeclarationNode, inline: boolean) { + if (!inline && node.parameters.properties.length) { + const offset = this.tokens.length; + this.tokenize(node.returnType); + const returnTokens = this.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.navigation(new ApiViewNavigation(ns, this.namespaceStack)); + } + + private getNameForNode(node: BaseNode | NamespaceModel): string { + const id = generateId(node); + if (id != undefined) { + return id.split(".").splice(-1)[0]; + } else { + throw new Error("Unable to get name for node."); + } + } + + /** Will collect the return type tokens and return them as a string */ + private getReturnTypeString(offset: number): string { + return ""; + } + + private renderComma() { + const last = this.tokens.pop()!; + if (last?.Kind == ApiViewTokenKind.Whitespace) { + // hacky workaround to ensure comma is after trailing bracket for expanded anonymous models + this.tokens.pop(); + } else { + this.tokens.push(last); + } + this.punctuation(",", false, false); + } + + resolveMissingTypeReferences() { + for (const token of this.tokens) { + if (token.Kind == ApiViewTokenKind.TypeName && token.NavigateToId == "__MISSING__") { + token.NavigateToId = this.definitionIdFor(token.Value!); + } + } + } + + asApiViewDocument(): ApiViewDocument { + return { + Name: this.name, + PackageName: this.packageName, + Tokens: this.tokens, + Navigation: this.navigationItems, + Diagnostics: this.diagnostics, + VersionString: this.versionString, + Language: "Cadl" + }; + } + + definitionIdFor(value: string): string | undefined { + if (value.includes(".")) { + return this.typeDeclarations.has(value) ? value : undefined; + } + for (const item of this.typeDeclarations) { + if (item.split(".").splice(-1)[0] == value) { + return item; + } + } + return undefined; + } +} + +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/tools/apiview/emitters/cadl-apiview/src/diagnostic.ts b/tools/apiview/emitters/cadl-apiview/src/diagnostic.ts new file mode 100644 index 00000000000..b43e5819035 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/src/diagnostic.ts @@ -0,0 +1,23 @@ +export class ApiViewDiagnostic { + static idCounter: number = 1; + diagnosticNumber: number; + text: string; + targetId: string; + level: ApiViewDiagnosticLevel; + helpLinkUri?: string; + + constructor(message: string, targetId: string, level: ApiViewDiagnosticLevel) { + this.diagnosticNumber = ApiViewDiagnostic.idCounter; + ApiViewDiagnostic.idCounter++; + this.text = message; + this.targetId = targetId; + this.level = level; + } +} + +export enum ApiViewDiagnosticLevel { + Default = 0, + Info = 1, + Warning = 2, + Error = 3, +} diff --git a/tools/apiview/emitters/cadl-apiview/src/emitter.ts b/tools/apiview/emitters/cadl-apiview/src/emitter.ts new file mode 100644 index 00000000000..69f834469d3 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/src/emitter.ts @@ -0,0 +1,67 @@ +// See: https://cadlwebsite.z1.web.core.windows.net/docs/extending-cadl/emitters-basics + +import { + emitFile, + getServiceNamespace, + getServiceTitle, + getServiceVersion, + Program, + resolvePath, +} from "@cadl-lang/compiler"; +import { ApiView } from "./apiview.js"; +import { ApiViewEmitterOptions } from "./lib.js"; + +const defaultOptions = { + "output-file": "apiview.json", +} as const; + +export interface ResolvedApiViewEmitterOptions { + "output-file": string; +} + +export async function $onEmit(program: Program, emitterOptions?: ApiViewEmitterOptions) { + const options = resolveOptions(program, emitterOptions ?? {}); + const emitter = createApiViewEmitter(program, options); + await emitter.emitApiView(); +} + +export function resolveOptions( + program: Program, + options: ApiViewEmitterOptions +): ResolvedApiViewEmitterOptions { + const resolvedOptions = { ...defaultOptions, ...options }; + + return { + "output-file": resolvePath( + program.compilerOptions.outputPath ?? "./output", + resolvedOptions["output-file"] + ), + }; +} + +function createApiViewEmitter(program: Program, options: ResolvedApiViewEmitterOptions) { + return { emitApiView }; + + async function emitApiView() { + const serviceNs = getServiceNamespace(program); + if (!serviceNs) { + return; + } + const versionString = "TODO"; + await emitApiViewFromVersion(program.checker.getNamespaceString(serviceNs), versionString); + } + + async function emitApiViewFromVersion(namespaceString: string, version?: string) { + const serviceTitle = getServiceTitle(program); + const serviceVersion = version ?? getServiceVersion(program); + const apiview = new ApiView(serviceTitle, namespaceString, serviceVersion); + apiview.emit(program); + apiview.resolveMissingTypeReferences(); + + const tokenJson = JSON.stringify(apiview.asApiViewDocument()) + "\n"; + await emitFile(program, { + path: options["output-file"], + content: tokenJson, + }); + } +} diff --git a/tools/apiview/emitters/cadl-apiview/src/index.ts b/tools/apiview/emitters/cadl-apiview/src/index.ts new file mode 100644 index 00000000000..bc7cadcc0bb --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/src/index.ts @@ -0,0 +1,4 @@ +export const namespace = "ApiView"; + +export { $lib } from "./lib.js"; +export * from "./emitter.js"; diff --git a/tools/apiview/emitters/cadl-apiview/src/lib.ts b/tools/apiview/emitters/cadl-apiview/src/lib.ts new file mode 100644 index 00000000000..4ef2a731c13 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/src/lib.ts @@ -0,0 +1,22 @@ +import { createCadlLibrary, JSONSchemaType } from "@cadl-lang/compiler"; + +export interface ApiViewEmitterOptions { + "output-file"?: string; +} + +const ApiViewEmitterOptionsSchema: JSONSchemaType = { + type: "object", + additionalProperties: false, + properties: { + "output-file": { type: "string", nullable: true }, + }, + required: [], +}; + +export const $lib = createCadlLibrary({ + name: "ApiView", + diagnostics: {}, + emitter: { + options: ApiViewEmitterOptionsSchema, + }, +}); diff --git a/tools/apiview/emitters/cadl-apiview/src/namespace-model.ts b/tools/apiview/emitters/cadl-apiview/src/namespace-model.ts new file mode 100644 index 00000000000..0f406ae9c56 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/src/namespace-model.ts @@ -0,0 +1,213 @@ +import { + Namespace, + ModelStatementNode, + OperationStatementNode, + InterfaceStatementNode, + EnumStatementNode, + NamespaceStatementNode, + ModelExpressionNode, + IntersectionExpressionNode, + ProjectionModelExpressionNode, + SyntaxKind, + BaseNode, + IdentifierNode, + ModelPropertyNode, + EnumMemberNode, + ModelSpreadPropertyNode, + EnumSpreadMemberNode, + DecoratorExpressionNode, + MemberExpressionNode, + UnionStatementNode, + UnionExpressionNode, + UnionVariantNode, +} from "@cadl-lang/compiler"; + +export class NamespaceModel { + kind = SyntaxKind.NamespaceStatement; + name: string; + node: NamespaceStatementNode; + operations = new Map(); + resources = new Map< + string, + | ModelStatementNode + | ModelExpressionNode + | IntersectionExpressionNode + | ProjectionModelExpressionNode + | EnumStatementNode + | UnionStatementNode + | UnionExpressionNode + >(); + models = new Map< + string, + | ModelStatementNode + | ModelExpressionNode + | IntersectionExpressionNode + | ProjectionModelExpressionNode + | EnumStatementNode + | UnionStatementNode + | UnionExpressionNode + >(); + + constructor(name: string, ns: Namespace) { + this.name = name; + this.node = ns.node; + + // Gather operations + for (const [opName, op] of ns.operations) { + this.operations.set(opName, op.node); + } + for (const [intName, int] of ns.interfaces) { + this.operations.set(intName, int.node); + } + + // 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) { + this.models.set(enumName, en.node); + } + for (const [unionName, un] of ns.unions) { + this.models.set(unionName, un.node); + } + + // sort operations and models + this.operations = new Map([...this.operations].sort()); + this.resources = new Map([...this.resources].sort()); + this.models = new Map([...this.models].sort()); + } +} + +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; + switch (node.id.kind) { + case SyntaxKind.Identifier: + name = node.id.sv; + break; + case SyntaxKind.StringLiteral: + name = node.id.value; + break; + } + 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; + switch (node.id.kind) { + case SyntaxKind.Identifier: + name = node.id.sv; + break; + case SyntaxKind.StringLiteral: + name = node.id.value; + break; + } + parentId = generateId(node.parent); + break; + case SyntaxKind.ModelSpreadProperty: + node = obj as ModelSpreadPropertyNode; + switch (node.target.target.kind) { + case SyntaxKind.Identifier: + name = (node.target.target as IdentifierNode).sv; + break; + case SyntaxKind.MemberExpression: + name = (node.target.target as MemberExpressionNode).id.sv; + break; + } + 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.UnionStatement: + node = obj as UnionStatementNode; + name = node.id.sv; + parentId = generateId(node.parent); + break; + case SyntaxKind.UnionVariant: + node = obj as UnionVariantNode; + switch (node.id.kind) { + case SyntaxKind.Identifier: + name = node.id.sv; + break; + case SyntaxKind.StringLiteral: + name = node.id.value; + break; + } + parentId = generateId(node.parent); + break; + default: + return undefined; + } + if (parentId != undefined) { + return `${parentId}.${name}`; + } else { + return name; + } +} diff --git a/tools/apiview/emitters/cadl-apiview/src/navigation.ts b/tools/apiview/emitters/cadl-apiview/src/navigation.ts new file mode 100644 index 00000000000..c7b8d526118 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/src/navigation.ts @@ -0,0 +1,122 @@ +import { + EnumStatementNode, + InterfaceStatementNode, + IntersectionExpressionNode, + ModelExpressionNode, + ModelStatementNode, + OperationStatementNode, + ProjectionModelExpressionNode, + SyntaxKind, + UnionExpressionNode, + UnionStatementNode, +} from "@cadl-lang/compiler"; +import { NamespaceStack } from "./apiview.js"; +import { NamespaceModel } from "./namespace-model.js"; + +export class ApiViewNavigation { + Text: string; + NavigationId: string | undefined; + ChildItems: ApiViewNavigation[]; + Tags: ApiViewNavigationTag; + + constructor( + objNode: + | NamespaceModel + | ModelStatementNode + | OperationStatementNode + | InterfaceStatementNode + | EnumStatementNode + | ModelExpressionNode + | IntersectionExpressionNode + | ProjectionModelExpressionNode + | UnionStatementNode + | UnionExpressionNode, + 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 ApiViewNavigation(node, stack)); + } + const resourceItems = new Array(); + for (const node of objNode.resources.values()) { + resourceItems.push(new ApiViewNavigation(node, stack)); + } + const modelItems = new Array(); + for (const node of objNode.models.values()) { + modelItems.push(new ApiViewNavigation(node, stack)); + } + this.ChildItems = [ + { Text: "Operations", ChildItems: operationItems, Tags: { TypeKind: ApiViewNavigationKind.Method }, NavigationId: "" }, + { Text: "Resources", ChildItems: resourceItems, Tags: { TypeKind: ApiViewNavigationKind.Class }, NavigationId: "" }, + { Text: "Models", ChildItems: modelItems, 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 ApiViewNavigation(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.ModelExpression: + throw new Error(`Navigation unsupported for "ModelExpression".`); + case SyntaxKind.IntersectionExpression: + throw new Error(`Navigation unsupported for "IntersectionExpression".`); + case SyntaxKind.ProjectionModelExpression: + throw new Error(`Navigation unsupported for "ProjectionModelExpression".`); + 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/tools/apiview/emitters/cadl-apiview/src/testing/index.ts b/tools/apiview/emitters/cadl-apiview/src/testing/index.ts new file mode 100644 index 00000000000..07a6d3bac90 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/src/testing/index.ts @@ -0,0 +1,20 @@ +import { resolvePath } from "@cadl-lang/compiler"; +import { CadlTestLibrary } from "@cadl-lang/compiler/testing"; +import { fileURLToPath } from "url"; + +export const ApiViewTestLibrary: CadlTestLibrary = { + name: "@azure-tools/cadl-apiview", + packageRoot: resolvePath(fileURLToPath(import.meta.url), "../../../../"), + files: [ + { + realDir: "", + pattern: "package.json", + virtualPath: "./node_modules/@azure-tools/cadl-apiview", + }, + { + realDir: "dist/src", + pattern: "*.js", + virtualPath: "./node_modules/@azure-tools/cadl-apiview/dist/src", + }, + ], +}; diff --git a/tools/apiview/emitters/cadl-apiview/src/version.ts b/tools/apiview/emitters/cadl-apiview/src/version.ts new file mode 100644 index 00000000000..3f61066a36e --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/src/version.ts @@ -0,0 +1 @@ +export const LIB_VERSION = "0.1.0"; diff --git a/tools/apiview/emitters/cadl-apiview/test/apiview.test.ts b/tools/apiview/emitters/cadl-apiview/test/apiview.test.ts new file mode 100644 index 00000000000..00da9a22091 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/test/apiview.test.ts @@ -0,0 +1,448 @@ +import { resolvePath } from "@cadl-lang/compiler"; +import { expectDiagnosticEmpty } from "@cadl-lang/compiler/testing"; +import assert, { fail, strictEqual } from "assert"; +import { ApiViewDocument, ApiViewTokenKind } from "../src/apiview.js"; +import { ApiViewEmitterOptions } from "../src/lib.js"; +import { createApiViewTestRunner } from "./test-host.js"; + +describe("apiview: tests", () => { + async function apiViewFor(code: string, options: ApiViewEmitterOptions): Promise { + const runner = await createApiViewTestRunner({withVersioning: true}); + const outPath = resolvePath("/apiview.json"); + const diagnostics = await runner.diagnose(code, { + noEmit: false, + emitters: { "@azure-tools/cadl-apiview": { ...options, "output-file": outPath } }, + }); + expectDiagnosticEmpty(diagnostics); + + const jsonText = runner.fs.get(outPath)!; + const apiview = JSON.parse(jsonText) as ApiViewDocument; + return apiview; + } + + function apiViewText(apiview: ApiViewDocument): string[] { + const vals = new Array; + for (const token of apiview.Tokens) { + switch (token.Kind) { + case ApiViewTokenKind.Newline: + vals.push("\n"); + break; + default: + if (token.Value != undefined) { + vals.push(token.Value); + } + break; + } + } + return vals.join("").split("\n"); + } + + /** Compares an expected string to a subset of the actual output. */ + function compare(expect: string, lines: string[], offset: number) { + // split the input into lines and ignore leading or trailing empty lines. + let expectedLines = expect.split("\n"); + if (expectedLines[0].trim() == '') { + expectedLines = expectedLines.slice(1); + } + if (expectedLines[expectedLines.length - 1].trim() == '') { + expectedLines = expectedLines.slice(0, -1); + } + // remove any leading indentation + const indent = expectedLines[0].length - expectedLines[0].trimStart().length; + for (let x = 0; x < expectedLines.length; x++) { + expectedLines[x] = expectedLines[x].substring(indent); + } + const checkLines = lines.slice(offset, offset + expectedLines.length); + strictEqual(expectedLines.length, checkLines.length); + for (let x = 0; x < checkLines.length; x++) { + strictEqual(expectedLines[x], checkLines[x], `Actual differed from expected at line #${x + 1}\nACTUAL: '${checkLines[x]}'\nEXPECTED: '${expectedLines[x]}'`); + } + } + + /** Validates that there are no repeat defintion IDs and that each line has only one definition ID. */ + function validateDefinitionIds(apiview: ApiViewDocument) { + const definitionIds = new Set(); + const defIdsPerLine = new Array>(); + let index = 0; + defIdsPerLine[index] = new Array(); + for (const token of apiview.Tokens) { + // ensure that there are no repeated definition IDs. + if (token.DefinitionId != undefined) { + if (definitionIds.has(token.DefinitionId)) { + fail(`Duplicate defintion ID ${token.DefinitionId}.`); + } + definitionIds.add(token.DefinitionId); + } + // Collect the definition IDs that exist on each line + if (token.DefinitionId != undefined) { + defIdsPerLine[index].push(token.DefinitionId); + } + if (token.Kind == ApiViewTokenKind.Newline) { + index++; + defIdsPerLine[index] = new Array(); + } + } + // ensure that each line has either 0 or 1 definition ID. + for (let x = 0; x < defIdsPerLine.length; x++) { + const row = defIdsPerLine[x]; + assert(row.length == 0 || row.length == 1, `Too many definition IDs (${row.length}) on line ${x}`); + } + } + + it("describes model", async () => { + const input = ` + @Cadl.serviceTitle("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 = ` + @Cadl.serviceTitle("Test") + 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, 3); + validateDefinitionIds(apiview); + }); + + it("describes templated model", async () => { + const input = ` + @Cadl.serviceTitle("Test") + namespace Azure.Test { + model Thing { + property: T; + } + + model StringThing 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 = ` + @Cadl.serviceTitle("Test") + namespace Azure.Test { + model ConstrainedComplex { + prop: X; + } + + model ConstrainedSimple { + prop: X; + } + + model ConstrainedWithDefault { + prop: X; + } + + 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, 3); + validateDefinitionIds(apiview); + }); + + it("describes enum", async () => { + const input = ` + @Cadl.serviceTitle("Test") + namespace Azure.Test { + + enum SomeEnum { + Plain, + "Literal", + } + + enum SomeStringEnum { + A: "A", + B: "B", + } + + enum SomeIntEnum { + A: 1, + B: 2, + } + }`; + const expect = ` + @Cadl.serviceTitle("Test") + namespace Azure.Test { + enum SomeEnum { + Plain, + "Literal", + } + + enum SomeIntEnum { + A: 1, + B: 2, + } + + enum SomeStringEnum { + A: "A", + B: "B", + } + }`; + const apiview = await apiViewFor(input, {}); + const actual = apiViewText(apiview); + compare(expect, actual, 3); + validateDefinitionIds(apiview); + }); + + it("describes union", async () =>{ + const input = ` + @Cadl.serviceTitle("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 = ` + @Cadl.serviceTitle("Test") + 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, 3); + validateDefinitionIds(apiview); + }); + + it("describes template operation", async () =>{ + const input = ` + @Cadl.serviceTitle("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 + }; + } + >; + }`; + const expect = ` + @Cadl.serviceTitle("Test") + namespace Azure.Test { + op GetFoo is ResourceRead< + { + @query + name: string, + ...FooParams + }, + { + parameters: + { + @query + fooId: string + } + } + >; + + op ResourceRead( + resource: TResource, + params: TParams + ): TResource; + + model FooParams { + a: string; + b: string; + } + }`; + const apiview = await apiViewFor(input, {}); + const lines = apiViewText(apiview); + compare(expect, lines, 3); + validateDefinitionIds(apiview); + }); + + it("describes operation with anonymous models", async () =>{ + const input = ` + @Cadl.serviceTitle("Test") + namespace Azure.Test { + op SomeOp( + param1: { + name: string + }, + param2: { + age: int16 + } + ): string; + }`; + const expect = ` + @Cadl.serviceTitle("Test") + namespace Azure.Test { + op SomeOp( + param1: + { + name: string + }, + param2: + { + age: int16 + } + ): string; + }`; + const apiview = await apiViewFor(input, {}); + const lines = apiViewText(apiview); + compare(expect, lines, 3); + validateDefinitionIds(apiview); + }); + + it("describes interface", async () => { + const input = ` + @Cadl.serviceTitle("Test") + namespace Azure.Test { + interface Foo { + @get + @route("get/{name}") + get(@path name: string): string; + + + @get + @route("list") + list(): string[]; + } + } + `; + const expect = ` + @Cadl.serviceTitle("Test") + 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 lines = apiViewText(apiview); + compare(expect, lines, 3); + validateDefinitionIds(apiview); + }); +}); diff --git a/tools/apiview/emitters/cadl-apiview/test/test-host.ts b/tools/apiview/emitters/cadl-apiview/test/test-host.ts new file mode 100644 index 00000000000..aa975bf634e --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/test/test-host.ts @@ -0,0 +1,28 @@ +import { createTestHost, createTestWrapper } from "@cadl-lang/compiler/testing"; +import { RestTestLibrary } from "@cadl-lang/rest/testing"; +import { VersioningTestLibrary } from "@cadl-lang/versioning/testing"; +import { AzureCoreTestLibrary } from "@azure-tools/cadl-azure-core/testing"; +import { ApiViewTestLibrary } from "../src/testing/index.js"; +import "@azure-tools/cadl-apiview"; + +export async function createApiViewTestHost() { + return createTestHost({ + libraries: [ApiViewTestLibrary, RestTestLibrary, VersioningTestLibrary, AzureCoreTestLibrary], + }); +} + +export async function createApiViewTestRunner({ + withVersioning, +}: { withVersioning?: boolean } = {}) { + const host = await createApiViewTestHost(); + const importAndUsings = ` + import "@cadl-lang/rest"; + ${withVersioning ? `import "@cadl-lang/versioning"` : ""}; + using Cadl.Rest; + using Cadl.Http; + ${withVersioning ? "using Cadl.Versioning;" : ""} + `; + return createTestWrapper(host, (code: string) => `${importAndUsings} ${code}`, { + emitters: { "@azure-tools/cadl-apiview": {} }, + }); +} diff --git a/tools/apiview/emitters/cadl-apiview/tsconfig.json b/tools/apiview/emitters/cadl-apiview/tsconfig.json new file mode 100644 index 00000000000..d3f0860e787 --- /dev/null +++ b/tools/apiview/emitters/cadl-apiview/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "tsBuildInfoFile": "temp/tsconfig.tsbuildinfo", + "types": ["node", "mocha"], + "composite": true, + "alwaysStrict": true, + "forceConsistentCasingInFileNames": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "sourceMap": true, + "declarationMap": true, + "strict": true, + "declaration": true, + "stripInternal": true, + "noEmitHelpers": false, + "target": "es2019", + "lib": ["es2019"], + "experimentalDecorators": true, + "newLine": "LF", + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}