diff --git a/CHANGELOG.md b/CHANGELOG.md index 38d50fe..430f946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## v0.6.0 (Preview) 2024-01-29 + +- Update to Cedar 2.4.3 +- IntelliSense for entity types and attributes +- Hover help supports properties and documentation links +- Support files outside of workspace folder +- "Go to Definition" updates and fixes + ## v0.5.4 (Preview) 2023-12-18 - Update to Cedar 2.4.2 diff --git a/README.md b/README.md index 8ca2502..f1e54c6 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ The Cedar policy language extension for Visual Studio Code supports syntax highlighting, formatting, and validation. Install from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=cedar-policy.vscode-cedar) or by [searching within VS Code](https://code.visualstudio.com/docs/editor/extension-gallery#_search-for-an-extension). -Cedar is a language for writing authorization policies and making authorization decisions based on those policies. Visit the [Cedar policy language reference guide](https://docs.cedarpolicy.com/) for the documentation and the language specification. +Cedar is an open-source language for writing authorization policies and making authorization decisions based on those policies. Visit the [Cedar policy language reference guide](https://docs.cedarpolicy.com/) for the documentation and the language specification. ## Features ### Cedar policy language -Files matching `*.cedar` are detected as a Cedar policy language and receive syntax highlighting. Validation is performed on document open, document save, during formatting, and via context menu. Formatting can disabled per file using a leading comment line of `// @formatter:off`. Policy navigation using Outline or Breadcrumb. "Go to Definition" on Cedar entity types and action names. Policies exportable to their JSON representation. +Files matching `*.cedar` are detected as a Cedar policy language and receive syntax highlighting. Validation is performed on document open, document save, during formatting, and via context menu. IntelliSense for entity types and attributes. Formatting can disabled per file using a leading comment line of `// @formatter:off`. Policy navigation using Outline or Breadcrumb. "Go to Definition" on Cedar entity types and action names. Policies exportable to their JSON representation. ![Cedar policy validation and navigation](https://raw.githubusercontent.com/cedar-policy/vscode-cedar/main/docs/marketplace/cedar_policy.gif) diff --git a/development.md b/development.md index 65b5c04..35fef73 100644 --- a/development.md +++ b/development.md @@ -93,7 +93,7 @@ Then build and test the extension inside the container. This will take several This extension can locally be installed to `~/.vscode/extensions` using the command palette and selecting **Extensions: Install from VSIX...** or running the following [Visual Studio Code command-line interface](https://code.visualstudio.com/docs/editor/command-line) command (see link if `code` is not in your PATH): ```bash -code --install-extension vscode-cedar-0.5.4.vsix +code --install-extension vscode-cedar-0.6.0.vsix ``` Note: Preview install may see a `[DEP0005] DeprecationWarning` tracked in GitHub issue [install-extension command throws Buffer deprecated warning #82524](https://github.com/microsoft/vscode/issues/82524) diff --git a/package-lock.json b/package-lock.json index 5ff5cf0..f8c43f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,29 +1,29 @@ { "name": "vscode-cedar", - "version": "0.5.4", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-cedar", - "version": "0.5.4", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { - "jsonc-parser": "^3.2.0", + "jsonc-parser": "^3.2.1", "vscode-cedar-wasm": "file:vscode-cedar-wasm/pkg" }, "devDependencies": { "@types/mocha": "^10.0.6", - "@types/node": "^18.19.3", + "@types/node": "^18.19.15", "@types/vscode": "=1.82.0", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vscode/test-cli": "^0.0.4", - "@vscode/test-electron": "^2.3.8", - "@vscode/vsce": "^2.22.0", + "@vscode/test-electron": "^2.3.9", + "@vscode/vsce": "^2.23.0", "eslint": "^8.56.0", "js-yaml": "^4.1.0", - "mocha": "^10.2.0", + "mocha": "^10.3.0", "typescript": "^4.9.5" }, "engines": { @@ -118,13 +118,13 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", "minimatch": "^3.0.5" }, "engines": { @@ -167,9 +167,9 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, "node_modules/@isaacs/cliui": { @@ -283,18 +283,18 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.19.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.3.tgz", - "integrity": "sha512-k5fggr14DwAytoA/t8rPrIz++lXK7/DqckthCmoZOKNsEbJkId4Z//BqgApXBUGrGddrigYa1oqheo/7YmW4rg==", + "version": "18.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.15.tgz", + "integrity": "sha512-AMZ2UWx+woHNfM11PyAEQmfSxi05jm9OlkxczuHeEqmvwPkYj6MWv44gbzDPefYOLysTOFyI3ziiy2ONmUZfpA==", "dev": true, "dependencies": { "undici-types": "~5.26.4" } }, "node_modules/@types/semver": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", - "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz", + "integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==", "dev": true }, "node_modules/@types/vscode": { @@ -516,9 +516,9 @@ } }, "node_modules/@vscode/test-electron": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.3.8.tgz", - "integrity": "sha512-b4aZZsBKtMGdDljAsOPObnAi7+VWIaYl3ylCz1jTs+oV6BZ4TNHcVNC3xUn0azPeszBmwSBDQYfFESIaUQnrOg==", + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.3.9.tgz", + "integrity": "sha512-z3eiChaCQXMqBnk2aHHSEkobmC2VRalFQN0ApOAtydL172zXGxTwGrRtviT5HnUB+Q+G3vtEYFtuQkYqBzYgMA==", "dev": true, "dependencies": { "http-proxy-agent": "^4.0.1", @@ -531,15 +531,16 @@ } }, "node_modules/@vscode/vsce": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.22.0.tgz", - "integrity": "sha512-8df4uJiM3C6GZ2Sx/KilSKVxsetrTBBIUb3c0W4B1EWHcddioVs5mkyDKtMNP0khP/xBILVSzlXxhV+nm2rC9A==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.23.0.tgz", + "integrity": "sha512-Wf9yN8feZf4XmUW/erXyKQvCL577u72AQv4AI4Cwt5o5NyE49C5mpfw3pN78BJYYG3qnSIxwRo7JPvEurkQuNA==", "dev": true, "dependencies": { "azure-devops-node-api": "^11.0.1", "chalk": "^2.4.2", "cheerio": "^1.0.0-rc.9", "commander": "^6.2.1", + "find-yarn-workspace-root": "^2.0.0", "glob": "^7.0.6", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", @@ -610,9 +611,9 @@ } }, "node_modules/acorn": { - "version": "8.11.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", - "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -857,14 +858,18 @@ } }, "node_modules/call-bind": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", - "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.6.tgz", + "integrity": "sha512-Mj50FLHtlsoVfRfnHaZvyrooHcrlceNZdL/QBvJJVd9Ta55qCQK0gs4ss2oZDeV9zFCs6ewzYgVE5yfVmfFpVg==", "dev": true, "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.1", - "set-function-length": "^1.1.1" + "get-intrinsic": "^1.2.3", + "set-function-length": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -956,16 +961,10 @@ } }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -978,6 +977,9 @@ "engines": { "node": ">= 8.10.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, "optionalDependencies": { "fsevents": "~2.3.2" } @@ -1213,17 +1215,21 @@ "dev": true }, "node_modules/define-data-property": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", - "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.3.tgz", + "integrity": "sha512-h3GBouC+RPtNX2N0hHVLo2ZwPYurq8mLmXpOLTsw71gr7lHt5VaI4vVkDUNOfiWmm48JEXe3VM7PmLX45AMmmg==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.1", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/detect-libc": { @@ -1358,10 +1364,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "dev": true, "engines": { "node": ">=6" @@ -1719,9 +1734,9 @@ "dev": true }, "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, "dependencies": { "reusify": "^1.0.4" @@ -1776,6 +1791,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -1867,16 +1891,20 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", - "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", "dev": true, "dependencies": { + "es-errors": "^1.3.0", "function-bind": "^1.1.2", "has-proto": "^1.0.1", "has-symbols": "^1.0.3", "hasown": "^2.0.0" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2021,9 +2049,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", + "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", "dev": true, "dependencies": { "function-bind": "^1.1.2" @@ -2121,9 +2149,9 @@ "optional": true }, "node_modules/ignore": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz", - "integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", "dev": true, "engines": { "node": ">= 4" @@ -2325,9 +2353,9 @@ "dev": true }, "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==" + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", + "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==" }, "node_modules/jszip": { "version": "3.10.1", @@ -2641,9 +2669,9 @@ "optional": true }, "node_modules/mocha": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", - "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.3.0.tgz", + "integrity": "sha512-uF2XJs+7xSLsrmIvn37i/wnc91nw7XjOQB8ccyx5aEgdnohr7n+rEiZP23WkCYHjilR6+EboEnbq/ZQDz4LSbg==", "dev": true, "dependencies": { "ansi-colors": "4.1.1", @@ -2653,13 +2681,12 @@ "diff": "5.0.0", "escape-string-regexp": "4.0.0", "find-up": "5.0.0", - "glob": "7.2.0", + "glob": "8.1.0", "he": "1.2.0", "js-yaml": "4.1.0", "log-symbols": "4.1.0", "minimatch": "5.0.1", "ms": "2.1.3", - "nanoid": "3.3.3", "serialize-javascript": "6.0.0", "strip-json-comments": "3.1.1", "supports-color": "8.1.1", @@ -2674,10 +2701,6 @@ }, "engines": { "node": ">= 14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mochajs" } }, "node_modules/mocha/node_modules/ansi-styles": { @@ -2695,6 +2718,33 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/mocha/node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/mocha/node_modules/cliui": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", @@ -2743,47 +2793,24 @@ } }, "node_modules/mocha/node_modules/glob": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", - "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimatch": "^5.0.1", + "once": "^1.3.0" }, "engines": { - "node": "*" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/mocha/node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/mocha/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2887,18 +2914,6 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, - "node_modules/nanoid": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", - "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", - "dev": true, - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -2919,9 +2934,9 @@ "dev": true }, "node_modules/node-abi": { - "version": "3.52.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.52.0.tgz", - "integrity": "sha512-JJ98b02z16ILv7859irtXn4oUaFWADtvkzy2c0IAatNVX2Mc9Yoh8z6hZInn3QwvMEYhHuQloYi+TTQy67SIdQ==", + "version": "3.54.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.54.0.tgz", + "integrity": "sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA==", "dev": true, "optional": true, "dependencies": { @@ -3129,9 +3144,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", - "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", + "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", "dev": true, "engines": { "node": "14 || >=16.14" @@ -3456,9 +3471,9 @@ "dev": true }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dev": true, "dependencies": { "lru-cache": "^6.0.0" @@ -3480,15 +3495,17 @@ } }, "node_modules/set-function-length": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", - "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", + "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", "dev": true, "dependencies": { - "define-data-property": "^1.1.1", - "get-intrinsic": "^1.2.1", + "define-data-property": "^1.1.2", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -3522,14 +3539,18 @@ } }, "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", + "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -4229,8 +4250,7 @@ } }, "vscode-cedar-wasm/pkg": { - "name": "vscode-cedar-wasm", - "version": "0.5.4", + "version": "0.7.0", "license": "SEE LICENSE IN LICENSE" } } diff --git a/package.json b/package.json index 241fbca..3a71fd4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/cedar-policy/vscode-cedar/issues" }, "qna": "https://github.com/cedar-policy/vscode-cedar/issues", - "version": "0.5.4", + "version": "0.6.0", "preview": true, "icon": "icons/cedar-policy.png", "engines": { @@ -29,6 +29,7 @@ ], "activationEvents": [ "workspaceContains:**/*.cedar", + "workspaceContains:**/*.cedar.json", "workspaceContains:**/*.cedarschema.json", "workspaceContains:**/cedarschema.json", "workspaceContains:**/*.cedarentities.json", @@ -73,6 +74,16 @@ "category": "Cedar", "title": "Validate Cedar policy" }, + { + "command": "cedar.activate", + "category": "Cedar", + "title": "Activate Cedar extension" + }, + { + "command": "cedar.about", + "category": "Cedar", + "title": "About Cedar extension" + }, { "command": "cedar.export", "category": "Cedar", @@ -138,6 +149,10 @@ } ], "commandPalette": [ + { + "command": "cedar.activate", + "when": "!cedar.activated" + }, { "command": "cedar.validate", "when": "editorLangId == cedar" @@ -183,12 +198,6 @@ "path": "./syntaxes/cedarschema.tmLanguage.json" } ], - "snippets": [ - { - "language": "cedar", - "path": "./snippets.json" - } - ], "configuration": { "title": "Cedar", "properties": { @@ -217,21 +226,21 @@ "wasm-pkg": "cd vscode-cedar-wasm/pkg && npm pack && mv *.tgz ../.." }, "dependencies": { - "jsonc-parser": "^3.2.0", + "jsonc-parser": "^3.2.1", "vscode-cedar-wasm": "file:vscode-cedar-wasm/pkg" }, "devDependencies": { "@types/mocha": "^10.0.6", - "@types/node": "^18.19.3", + "@types/node": "^18.19.15", "@types/vscode": "=1.82.0", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", "@vscode/test-cli": "^0.0.4", - "@vscode/test-electron": "^2.3.8", - "@vscode/vsce": "^2.22.0", + "@vscode/test-electron": "^2.3.9", + "@vscode/vsce": "^2.23.0", "eslint": "^8.56.0", "js-yaml": "^4.1.0", - "mocha": "^10.2.0", + "mocha": "^10.3.0", "typescript": "^4.9.5" } } diff --git a/schemas/cedarschema.schema.json b/schemas/cedarschema.schema.json index b1455d9..b06016b 100644 --- a/schemas/cedarschema.schema.json +++ b/schemas/cedarschema.schema.json @@ -180,7 +180,8 @@ { "$ref": "#/$defs/extensionTypeDef" }, { "$ref": "#/$defs/entityTypeDef" }, { "$ref": "#/$defs/setTypeDef" }, - { "$ref": "#/$defs/recordTypeDef" } + { "$ref": "#/$defs/recordTypeDef" }, + { "$ref": "#/$defs/commonShapeDef" } ] }, "primitiveTypeDef": { diff --git a/snippets.json b/snippets.json deleted file mode 100644 index f5ece3b..0000000 --- a/snippets.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "permit rule": { - "prefix": "permit", - "body": [ - "permit (", - " principal == ${1:type}::\"${2:id}\",", - " action == Action::\"${3:id}\",", - " resource == ${4:type}::\"${5:id}\"", - ");" - ], - "description": "Permit rule" - }, - "permit when": { - "prefix": "permit", - "body": [ - "permit (principal, action, resource)", - "when { $0 };" - ], - "description": "Permit when" - }, - "forbid when": { - "prefix": "forbid", - "body": [ - "forbid (principal, action, resource)", - "when { $0 };" - ], - "description": "Forbid when" - }, - "forbid unless": { - "prefix": "forbid", - "body": [ - "forbid (principal, action, resource)", - "unless { $0 };" - ], - "description": "Forbid unless" - }, - "decimal": { - "prefix": "decimal", - "body": [ - "decimal(\"${0:0.1234}\");" - ], - "description": "decimal constructor" - }, - "ip": { - "prefix": "ip", - "body": [ - "ip(\"${0:127.0.0.1}\");" - ], - "description": "ip constructor" - }, - "description comment": { - "prefix": "description", - "body": [ - "// description: ${0:file description}" - ], - "description": "description comment" - }, - "formatter off comment": { - "prefix": "formatter", - "body": [ - "// @formatter:off" - ], - "description": "description comment" - } -} \ No newline at end of file diff --git a/src/about.ts b/src/about.ts new file mode 100644 index 0000000..e7600db --- /dev/null +++ b/src/about.ts @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as vscode from 'vscode'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as cedar from 'vscode-cedar-wasm'; + +const packageJsonFile = path.join(__dirname, '..', 'package.json'); +const packageJson = JSON.parse(fs.readFileSync(packageJsonFile, 'utf8')); +const packageVersion = `${packageJson.publisher}.${packageJson.name}: ${packageJson.version}\n`; +const vsCodeVersion = `Visual Studio Code: ${vscode.version}\n`; +const nodeVersion = process.versions.node + ? `node: ${process.versions.node}\n` + : ''; + +export async function aboutExtension(): Promise { + const extensionDetails = + packageVersion + + `Cedar: ${cedar.getCedarVersion()}\n` + + vsCodeVersion + + nodeVersion; + + const result = await vscode.window.showInformationMessage( + extensionDetails, + { modal: true }, + 'Copy' + ); + if (result === 'Copy') { + void vscode.env.clipboard.writeText(extensionDetails); + } +} diff --git a/src/commands.ts b/src/commands.ts index 6547b66..066bdd9 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 // strings need to match commands in package.json +export const COMMAND_CEDAR_ACTIVATE = 'cedar.activate'; +export const COMMAND_CEDAR_ABOUT = 'cedar.about'; export const COMMAND_CEDAR_VALIDATE = 'cedar.validate'; export const COMMAND_CEDAR_EXPORT = 'cedar.export'; export const COMMAND_CEDAR_SCHEMAVALIDATE = 'cedar.schemavalidate'; diff --git a/src/completion.ts b/src/completion.ts new file mode 100644 index 0000000..ef9de87 --- /dev/null +++ b/src/completion.ts @@ -0,0 +1,608 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as vscode from 'vscode'; +import { FUNCTION_HELP_DEFINITIONS } from './help'; +import { + PRIMITIVE_TYPES, + SchemaCompletionRecord, + parseCedarPoliciesDoc, + parseCedarSchemaDoc, + traversePropertyChain, +} from './parser'; +import { getSchemaTextDocument } from './fileutil'; +import { narrowEntityTypes } from './validate'; +import { PROPERTY_CHAIN_REGEX } from './regex'; + +// helper method for set, IPAddr, and Decimal functions +const createFunctionItem = ( + range: vscode.Range, + label: string, + snippetString?: string +): vscode.CompletionItem => { + // use first line of Hover Help as detail for completion item + const help = FUNCTION_HELP_DEFINITIONS[label]; + + let item = new vscode.CompletionItem( + help && help.length > 1 + ? { label: label, detail: help[0].substring(label.length) } + : label, + vscode.CompletionItemKind.Function + ); + item.range = range; + if (snippetString) { + item.insertText = new vscode.SnippetString(snippetString); + } + + return item; +}; + +// Set functions +const createContainsItems = ( + position: vscode.Position +): vscode.CompletionItem[] => { + const items: vscode.CompletionItem[] = []; + const range = new vscode.Range(position, position); + + items.push(createFunctionItem(range, 'contains', 'contains($1) $0')); + items.push(createFunctionItem(range, 'containsAll', 'containsAll([$1]) $0')); + items.push(createFunctionItem(range, 'containsAny', 'containsAny([$1]) $0')); + + return items; +}; + +// [^\s"]* inside the (" ") avoids a greedy match +const IP_REGEX = /\bip\("[^\s"]*"\)\.$/; +const DECIMAL_REGEX = /\bdecimal\("[^\s"]*"\)\.$/; + +// IPAddr extension functions +const createIpFunctionItem = (range: vscode.Range): vscode.CompletionItem => { + return createFunctionItem(range, 'ip', 'ip("${1:127.0.0.1}")$0'); +}; +const createIPAddrItems = ( + position: vscode.Position +): vscode.CompletionItem[] => { + const items: vscode.CompletionItem[] = []; + const range = new vscode.Range(position, position); + + items.push(createFunctionItem(range, 'isIpv4', 'isIpv4() $0')); + items.push(createFunctionItem(range, 'isIpv6', 'isIpv6() $0')); + items.push(createFunctionItem(range, 'isLoopback', 'isLoopback() $0')); + items.push(createFunctionItem(range, 'isMulticast', 'isMulticast() $0')); + items.push(createFunctionItem(range, 'isInRange', 'isInRange($1) $0')); + + return items; +}; + +// Decimal extension functions +const createDecimalFunctionItem = ( + range: vscode.Range +): vscode.CompletionItem => { + return createFunctionItem(range, 'decimal', 'decimal("${1:0.1234}")$0'); +}; +const createDecimalItems = ( + position: vscode.Position +): vscode.CompletionItem[] => { + const items: vscode.CompletionItem[] = []; + const range = new vscode.Range(position, position); + + items.push(createFunctionItem(range, 'lessThan', 'lessThan($1) $0')); + items.push( + createFunctionItem(range, 'lessThanOrEqual', 'lessThanOrEqual($1) $0') + ); + items.push(createFunctionItem(range, 'greaterThan', 'greaterThan($1) $0')); + items.push( + createFunctionItem(range, 'greaterThanOrEqual', 'greaterThanOrEqual($1) $0') + ); + + return items; +}; + +const ENTITY_REGEX = /(?:\s|=|\[|\()(?(?:[_a-zA-Z][_a-zA-Z0-9]*::)+)$/; +const SCOPE_REGEX = + /(?(principal|action|resource))(\s*==\s*|\s+in\s+\[?)(?.?)$/; + +export const splitPropertyChain = (property: string) => { + const parts: string[] = []; + let start = 0; + let insideQuotes = false; + for (let pos = start; pos < property.length; pos++) { + const char = property[pos]; + if (insideQuotes) { + if (char === '"') { + // doesn't handled embedded " e.g "5' 10\"" and don't care + parts.push(property.substring(start, pos)); + pos += 3; + start = pos; + insideQuotes = false; + } + } else if (char === '.') { + parts.push(property.substring(start, pos)); + start = pos + 1; + } else if (char === '[') { + parts.push(property.substring(start, pos)); + pos += 2; + insideQuotes = true; + start = pos; + } else if (pos === property.length - 1) { + parts.push(property.substring(start)); + } + } + + return parts; +}; + +const createEntityItems = ( + position: vscode.Position, + schemaDoc: vscode.TextDocument, + element: string, + trigger: string, + typeOnly: boolean = false +): vscode.CompletionItem[] => { + const items: vscode.CompletionItem[] = []; + const range = trigger + ? new vscode.Range( + new vscode.Position(position.line, position.character - trigger.length), + position + ) + : new vscode.Range(position, position); + + const definitionRanges = parseCedarSchemaDoc(schemaDoc).definitionRanges; + + definitionRanges.forEach((definition) => { + if (element === 'action') { + if (definition.collection === 'actions') { + let item = new vscode.CompletionItem( + definition.etype, + vscode.CompletionItemKind.Value + ); + item.range = range; + items.push(item); + } + } else if (definition.collection === 'entityTypes') { + let item = new vscode.CompletionItem( + definition.etype, + vscode.CompletionItemKind.Class + ); + if (!typeOnly) { + item.insertText = new vscode.SnippetString(definition.etype + '::"$1"'); + } + item.range = range; + items.push(item); + } + }); + + return items; +}; + +const createAttributeItems = ( + position: vscode.Position, + entityType: string, + attributes: SchemaCompletionRecord +): vscode.CompletionItem[] => { + const items: vscode.CompletionItem[] = []; + const range = new vscode.Range(position, position); + + Object.keys(attributes).forEach((key) => { + let item = new vscode.CompletionItem( + { + label: key, + detail: `: ${attributes[key].description}`, + description: entityType, + }, + vscode.CompletionItemKind.Field + ); + item.range = range; + if (key.indexOf(' ') > -1) { + // properties containing a space need a different notation + item.insertText = new vscode.SnippetString(`["${key}"]`); + // and remove the preceding . that triggered the completion + item.additionalTextEdits = [ + vscode.TextEdit.delete( + new vscode.Range( + new vscode.Position(position.line, position.character - 1), + position + ) + ), + ]; + } + items.push(item); + }); + + return items; +}; + +const createEntityTypesAttributeItems = ( + position: vscode.Position, + schemaDoc: vscode.TextDocument, + entityTypes: string[] +): vscode.CompletionItem[] => { + let items: vscode.CompletionItem[] = []; + const completions = parseCedarSchemaDoc(schemaDoc).completions; + entityTypes.forEach((entityType) => { + const attributes = completions[entityType]; + items = items.concat( + createAttributeItems(position, entityType, attributes) + ); + }); + + return items; +}; + +const createVariableItem = ( + range: vscode.Range, + label: string +): vscode.CompletionItem => { + const item = new vscode.CompletionItem( + label, + vscode.CompletionItemKind.Variable + ); + item.range = range; + return item; +}; + +const createInvokeItems = ( + position: vscode.Position +): vscode.CompletionItem[] => { + const items: vscode.CompletionItem[] = []; + const range = new vscode.Range(position, position); + let item; + + ['principal', 'action', 'resource', 'context'].forEach((element) => { + item = createVariableItem(range, element); + items.push(item); + }); + + items.push(createIpFunctionItem(range)); + items.push(createDecimalFunctionItem(range)); + + return items; +}; + +const provideCedarPeriodTriggerItems = async ( + position: vscode.Position, + linePrefix: string, + document: vscode.TextDocument +): Promise => { + if (linePrefix.endsWith(').')) { + if (linePrefix.match(IP_REGEX)) { + return createIPAddrItems(position); + } else if (linePrefix.match(DECIMAL_REGEX)) { + return createDecimalItems(position); + } + } + + let found = linePrefix.match(PROPERTY_CHAIN_REGEX); + if (found?.groups) { + const properties = splitPropertyChain(found[0]); + const schemaDoc = await getSchemaTextDocument(undefined, document); + if (schemaDoc) { + let entities = narrowEntityTypes(schemaDoc, properties[0]); + + if (properties.length === 1) { + return createEntityTypesAttributeItems(position, schemaDoc, entities); + } else { + let items: vscode.CompletionItem[] = []; + const completions = parseCedarSchemaDoc(schemaDoc).completions; + const lastTypes = new Set(); + entities.forEach((entityType) => { + const { lastType, completion } = traversePropertyChain( + completions, + properties, + entityType + ); + + if (lastType) { + if (lastType === 'Record' && completion) { + items = items.concat( + createAttributeItems(position, entityType, completion) + ); + } else if (!lastTypes.has(lastType)) { + lastTypes.add(lastType); + if (lastType.startsWith('Set<')) { + items = items.concat(createContainsItems(position)); + } else if (lastType === 'ipaddr') { + items = items.concat(createIPAddrItems(position)); + } else if (lastType === 'decimal') { + items = items.concat(createDecimalItems(position)); + } else if (!PRIMITIVE_TYPES.includes(lastType)) { + items = items.concat( + createEntityTypesAttributeItems(position, schemaDoc, [ + lastType, + ]) + ); + } + } + } + }); + return items; + } + } + } + + if (linePrefix.endsWith('].')) { + return createContainsItems(position); + } + + return undefined; +}; + +const provideCedarTriggerCharacterCompletionItems = async ( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + context: vscode.CompletionContext +) => { + const linePrefix = document + .lineAt(position) + .text.substring(0, position.character); + + if (context.triggerCharacter === '.') { + return provideCedarPeriodTriggerItems(position, linePrefix, document); + } else if (context.triggerCharacter === ':') { + if (linePrefix.endsWith('::')) { + let found = linePrefix.match(ENTITY_REGEX); + if (found?.groups) { + const entity = found?.groups.entity; + const schemaDoc = await getSchemaTextDocument(undefined, document); + if (schemaDoc) { + return createEntityItems(position, schemaDoc, '', entity); + } + } + } + } else if (context.triggerCharacter === '@') { + if (linePrefix === '// @') { + let item = new vscode.CompletionItem( + '@formatter:off', + vscode.CompletionItemKind.Property + ); + item.insertText = new vscode.SnippetString('formatter:off$0'); + item.range = new vscode.Range(position, position); + return [item]; + } else if (linePrefix.trim() === '@') { + const annotations = parseCedarPoliciesDoc(document).annotations; + const items: vscode.CompletionItem[] = []; + annotations.forEach((annotation) => { + let item = new vscode.CompletionItem( + '@' + annotation, + vscode.CompletionItemKind.Property + ); + item.insertText = new vscode.SnippetString(annotation + '("$1")$0'); + item.range = new vscode.Range(position, position); + items.push(item); + }); + return items; + } + } else if (context.triggerCharacter === '?') { + // ?principal and ?resource + let found = linePrefix + .substring(0, linePrefix.length - 1) + .match(SCOPE_REGEX); + if (found?.groups) { + const element = found?.groups.element; + let item = new vscode.CompletionItem( + '?' + element, + vscode.CompletionItemKind.Variable + ); + item.insertText = new vscode.SnippetString(element); + item.range = new vscode.Range(position, position); + return [item]; + } + } + + return undefined; +}; + +const createSnippetItem = ( + label: string, + description: string, + insertText: vscode.SnippetString, + range: vscode.Range +): vscode.CompletionItem => { + const item = new vscode.CompletionItem( + { label: label, description: description }, + vscode.CompletionItemKind.Snippet + ); + item.insertText = insertText; + item.range = range; + + return item; +}; + +const createPermitSnippetItems = ( + range: vscode.Range +): vscode.CompletionItem[] => { + const item1 = createSnippetItem( + 'permit', + 'permit when', + new vscode.SnippetString( + 'permit (principal, action, resource)\n' + 'when { ${0:Expr} };' + ), + range + ); + + const item2 = createSnippetItem( + 'permit', + 'permit', + new vscode.SnippetString( + 'permit (\n' + + ' principal == ${1:Path}::"${2:id}",\n' + + ' action == Action::"${3:id}",\n' + + ' resource == ${4:Path}::"${5:id}"\n' + + ')$0;' + ), + range + ); + + return [item1, item2]; +}; + +const createForbidSnippetItems = ( + range: vscode.Range +): vscode.CompletionItem[] => { + const item1 = createSnippetItem( + 'forbid', + 'forbid when', + new vscode.SnippetString( + 'forbid (principal, action, resource)\n' + 'when { ${0:Expr} };' + ), + range + ); + + const item2 = createSnippetItem( + 'forbid', + 'forbid unless', + new vscode.SnippetString( + 'forbid (principal, action, resource)\n' + 'unless { ${0:Expr} };' + ), + range + ); + + return [item1, item2]; +}; + +const createWhenSnippetItems = ( + range: vscode.Range +): vscode.CompletionItem[] => { + const item1 = createSnippetItem( + 'when', + 'when condition', + new vscode.SnippetString('when { ${0:Expr} }'), + range + ); + + return [item1]; +}; + +const createUnlessSnippetItems = ( + range: vscode.Range +): vscode.CompletionItem[] => { + const item1 = createSnippetItem( + 'unless', + 'unless condition', + new vscode.SnippetString('unless { ${0:Expr} }'), + range + ); + + return [item1]; +}; + +const SKIP_I_REGEX = /\b(principal|action|resource)\s+i$/; + +const provideCedarInvokeCompletionItems = async ( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + context: vscode.CompletionContext +) => { + const lineText = document.lineAt(position).text; + const linePrefix = lineText.substring(0, position.character); + const lineSuffix = lineText.substring(position.character); + const range = new vscode.Range( + new vscode.Position(position.line, position.character - 1), + position + ); + + // some completion items are only suggests at beginning of line + if (linePrefix.length === 1) { + switch (linePrefix) { + case 'p': + return createPermitSnippetItems(range); + case 'w': + return createWhenSnippetItems(range); + case 'u': + return createUnlessSnippetItems(range); + case 'f': + return createForbidSnippetItems(range); + default: + return undefined; + } + } + + let found = linePrefix.match(SCOPE_REGEX); + if (found?.groups) { + const element = found?.groups.element; + const trigger = found?.groups.trigger; + const schemaDoc = await getSchemaTextDocument(undefined, document); + if (schemaDoc) { + const typeOnly = lineSuffix.startsWith('::"'); + return createEntityItems(position, schemaDoc, element, trigger, typeOnly); + } + } + + const lastChar = linePrefix.substring(linePrefix.length - 1); + if (lastChar === ' ') { + // hotkey triggered completion + if (linePrefix.endsWith(' has ')) { + return provideCedarPeriodTriggerItems( + position, + linePrefix.substring(0, linePrefix.length - 5), + document + ); + } + + return createInvokeItems(position); + } + const penultimateChar = linePrefix.substring( + linePrefix.length - 2, + linePrefix.length - 1 + ); + if ([' ', '(', '{', '['].includes(penultimateChar)) { + switch (lastChar) { + case 'p': + return [createVariableItem(range, 'principal')]; + + case 'a': + return [createVariableItem(range, 'action')]; + + case 'r': + return [createVariableItem(range, 'resource')]; + + case 'c': + return [createVariableItem(range, 'context')]; + + case 'i': + if (linePrefix.match(SKIP_I_REGEX)) { + break; + } + return [createIpFunctionItem(range)]; + + case 'd': + return [createDecimalFunctionItem(range)]; + + default: + break; + } + } + + return undefined; +}; + +export class CedarCompletionItemProvider + implements vscode.CompletionItemProvider +{ + async provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + context: vscode.CompletionContext + ) { + if (context.triggerKind === vscode.CompletionTriggerKind.TriggerCharacter) { + return await provideCedarTriggerCharacterCompletionItems( + document, + position, + token, + context + ); + } else if (context.triggerKind === vscode.CompletionTriggerKind.Invoke) { + return await provideCedarInvokeCompletionItems( + document, + position, + token, + context + ); + } + + return undefined; + } +} diff --git a/src/definition.ts b/src/definition.ts index 49db6d3..e6513c6 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -9,27 +9,23 @@ import { parseCedarSchemaDoc, parseCedarTemplateLinksDoc, parseCedarJsonPolicyDoc, + ReferencedRange, } from './parser'; import { getSchemaTextDocument } from './fileutil'; const findSchemaDefinition = async ( - doc: vscode.TextDocument, + schemaDoc: vscode.TextDocument, position: vscode.Position, - entityTypeRanges: vscode.Range[], - actionRanges: vscode.Range[], - schemaDoc: vscode.TextDocument + referencedTypes: ReferencedRange[], + actionIds: ReferencedRange[] = [] ): Promise => { // TODO: update from O(n^2) to something more efficient const schemaItem = parseCedarSchemaDoc(schemaDoc); - const schemaEntityTypeRanges = schemaItem.entities; - for (let entityTypeRange of entityTypeRanges) { - if (entityTypeRange.contains(position)) { - const text = doc.getText(entityTypeRange); - for (let schemaRange of schemaEntityTypeRanges) { - if ( - schemaRange.collection === 'entityTypes' && - schemaRange.deftype === text - ) { + const schemaDefinitionRanges = schemaItem.definitionRanges; + for (let referencedType of referencedTypes) { + if (referencedType.range.contains(position)) { + for (let schemaRange of schemaDefinitionRanges) { + if (schemaRange.etype === referencedType.name) { const loc = new vscode.Location(schemaDoc.uri, schemaRange.range); return Promise.resolve(loc); } @@ -38,14 +34,11 @@ const findSchemaDefinition = async ( return null; } } - for (let actionRange of actionRanges) { - if (actionRange.contains(position)) { - const text = doc.getText(actionRange); - for (let schemaRange of schemaEntityTypeRanges) { - if ( - schemaRange.collection === 'actions' && - schemaRange.deftype === text - ) { + + for (let actionId of actionIds) { + if (actionId.range.contains(position)) { + for (let schemaRange of schemaDefinitionRanges) { + if (schemaRange.etype === actionId.name) { const loc = new vscode.Location(schemaDoc.uri, schemaRange.range); return Promise.resolve(loc); } @@ -66,15 +59,9 @@ export class CedarEntitiesDefinitionProvider ): Promise { const schemaDoc = await getSchemaTextDocument(undefined, cedarEntitiesDoc); if (schemaDoc) { - const entityTypeRanges = - parseCedarEntitiesDoc(cedarEntitiesDoc).entityTypes; - return findSchemaDefinition( - cedarEntitiesDoc, - position, - entityTypeRanges, - [], - schemaDoc - ); + const referencedTypes = + parseCedarEntitiesDoc(cedarEntitiesDoc).referencedTypes; + return findSchemaDefinition(schemaDoc, position, referencedTypes); } return null; @@ -94,16 +81,10 @@ export class CedarTemplateLinksDefinitionProvider cedarTemplateLinksDoc ); if (schemaDoc) { - const entityTypeRanges = parseCedarTemplateLinksDoc( + const referencedTypes = parseCedarTemplateLinksDoc( cedarTemplateLinksDoc - ).entityTypes; - return findSchemaDefinition( - cedarTemplateLinksDoc, - position, - entityTypeRanges, - [], - schemaDoc - ); + ).referencedTypes; + return findSchemaDefinition(schemaDoc, position, referencedTypes); } return null; @@ -119,14 +100,13 @@ export class CedarAuthDefinitionProvider implements vscode.DefinitionProvider { const schemaDoc = await getSchemaTextDocument(undefined, cedarAuthDoc); if (schemaDoc) { const authItem = parseCedarAuthDoc(cedarAuthDoc); - const entityTypeRanges = authItem.entityTypes; - const actionRanges = authItem.actions; + const referencedTypes = authItem.referencedTypes; + const actionIds = authItem.actionIds; return findSchemaDefinition( - cedarAuthDoc, + schemaDoc, position, - entityTypeRanges, - actionRanges, - schemaDoc + referencedTypes, + actionIds ); } @@ -142,15 +122,14 @@ export class CedarJsonDefinitionProvider implements vscode.DefinitionProvider { ): Promise { const schemaDoc = await getSchemaTextDocument(undefined, cedarJsonDoc); if (schemaDoc) { - const authItem = parseCedarJsonPolicyDoc(cedarJsonDoc); - const entityTypeRanges = authItem.entityTypes; - const actionRanges = authItem.actions; + const policyItem = parseCedarJsonPolicyDoc(cedarJsonDoc); + const referencedTypes = policyItem.referencedTypes; + const actionIds = policyItem.actionIds; return findSchemaDefinition( - cedarJsonDoc, + schemaDoc, position, - entityTypeRanges, - actionRanges, - schemaDoc + referencedTypes, + actionIds ); } @@ -167,14 +146,13 @@ export class CedarSchemaDefinitionProvider token: vscode.CancellationToken ): Promise { const schemaItem = parseCedarSchemaDoc(schemaDoc); - const entityTypeRanges = schemaItem.entityTypes; - const actionRanges = schemaItem.actions; + const referencedTypes = schemaItem.referencedTypes; + const actionIds = schemaItem.actionIds; return findSchemaDefinition( schemaDoc, position, - entityTypeRanges, - actionRanges, - schemaDoc + referencedTypes, + actionIds ); } } @@ -188,14 +166,13 @@ export class CedarDefinitionProvider implements vscode.DefinitionProvider { const schemaDoc = await getSchemaTextDocument(undefined, cedarDoc); if (schemaDoc) { const policyItem = parseCedarPoliciesDoc(cedarDoc); - const entityTypeRanges = policyItem.entityTypes; - const actionRanges = policyItem.actions; + const referencedTypes = policyItem.referencedTypes; + const actionIds = policyItem.actionIds; return findSchemaDefinition( - cedarDoc, + schemaDoc, position, - entityTypeRanges, - actionRanges, - schemaDoc + referencedTypes, + actionIds ); } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 24786e4..c70db86 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -14,7 +14,6 @@ import { EXPECTED_ATTR_REGEX, MISMATCH_ATTR_REGEX, OFFSET_POLICY_REGEX, - //FOUND_AT_REGEX, AT_LINE_SCHEMA_REGEX, UNDECLARED_REGEX, UNRECOGNIZED_REGEX, @@ -88,9 +87,10 @@ const determineRangeFromError = ( ): { error: string; range: vscode.Range } => { let error = vse.message; let range = DEFAULT_RANGE; - if (vse.offset > 0 && vse.length > 0) { + if (vse.offset > 0) { const startCharacter = vse.offset; - const endCharacter = vse.offset + vse.length; + // "invalid token" is 0 length, make range at least 1 character + const endCharacter = vse.offset + Math.max(vse.length, 1); let lineStart = 0; // not efficient, but Cedar policies are small for (let i = 0; i < document.lineCount; i++) { @@ -126,10 +126,13 @@ const determineRangeFromError = ( } else if ( error === 'Entity type `Action` declared in `entityTypes` list.' ) { - const entityRanges = parseCedarSchemaDoc(document).entities; - for (let entityRange of entityRanges) { - if (entityRange.etype === 'Action') { - range = entityRange.etypeRange; + const definitionRanges = parseCedarSchemaDoc(document).definitionRanges; + for (let definitionRange of definitionRanges) { + if ( + definitionRange.etype === 'Action' || + definitionRange.etype.endsWith('::Action') + ) { + range = definitionRange.etypeRange; break; } } diff --git a/src/documentsymbols.ts b/src/documentsymbols.ts index 6f99408..23d4c2d 100644 --- a/src/documentsymbols.ts +++ b/src/documentsymbols.ts @@ -162,8 +162,9 @@ export class CedarSchemaDocumentSymbolProvider try { const symbols: vscode.DocumentSymbol[] = []; - const entityRanges = parseCedarSchemaDoc(cedarSchemaDoc).entities; - entityRanges.forEach((entityRange, index) => { + const definitionRanges = + parseCedarSchemaDoc(cedarSchemaDoc).definitionRanges; + definitionRanges.forEach((entityRange, index) => { symbols.push( new vscode.DocumentSymbol( entityRange.etype, diff --git a/src/extension.ts b/src/extension.ts index 6b676ca..161d574 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,7 +8,6 @@ import { generateDiagram, schemaExportTypeExtension, } from './generate'; -import { HOVER_HELP_DEFINITIONS } from './help'; import { handleWillDeleteFiles, handleDidRenameFiles, @@ -31,6 +30,8 @@ import { } from './validate'; import { CedarSchemaJSONQuickFix, CedarQuickFix } from './quickfix'; import { + COMMAND_CEDAR_ABOUT, + COMMAND_CEDAR_ACTIVATE, COMMAND_CEDAR_CLEARPROBLEMS, COMMAND_CEDAR_ENTITIESVALIDATE, COMMAND_CEDAR_EXPORT, @@ -64,6 +65,10 @@ import { cedarJsonTokensProvider, } from './parser'; import { exportCedarDocPolicyById, getPolicyQuickPickItems } from './policy'; +import { CedarCompletionItemProvider } from './completion'; +import { CedarHoverProvider } from './hover'; +import { aboutExtension } from './about'; +import * as cedar from 'vscode-cedar-wasm'; // This method is called when your extension is activated export async function activate(context: vscode.ExtensionContext) { @@ -71,6 +76,8 @@ export async function activate(context: vscode.ExtensionContext) { // This line of code will only be executed once when your extension is activated console.log('Cedar extension activated'); + cedar.setPanicHook(); + let diagnosticCollection = createDiagnosticCollection(); vscode.window.visibleTextEditors.forEach((editor) => { @@ -84,19 +91,7 @@ export async function activate(context: vscode.ExtensionContext) { */ context.subscriptions.push( - vscode.languages.registerHoverProvider('cedar', { - provideHover(document, position, token) { - const word = document.getText( - document.getWordRangeAtPosition(position) - ); - - if (HOVER_HELP_DEFINITIONS[word]) { - return { - contents: HOVER_HELP_DEFINITIONS[word], - }; - } - }, - }) + vscode.languages.registerHoverProvider('cedar', new CedarHoverProvider()) ); context.subscriptions.push( @@ -130,6 +125,17 @@ export async function activate(context: vscode.ExtensionContext) { ) ); + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + { language: 'cedar' }, + new CedarCompletionItemProvider(), + '.', // functions + ':', // entities + '@', // annotations + '?' // templates + ) + ); + context.subscriptions.push( vscode.languages.registerCodeActionsProvider( { pattern: CEDAR_SCHEMA_GLOB, scheme: 'file' }, @@ -189,7 +195,25 @@ export async function activate(context: vscode.ExtensionContext) { /* * vscode commands */ - + context.subscriptions.push( + vscode.commands.registerCommand(COMMAND_CEDAR_ABOUT, (args: any[]) => { + aboutExtension(); + }) + ); + context.subscriptions.push( + vscode.commands.registerTextEditorCommand( + COMMAND_CEDAR_ACTIVATE, + ( + textEditor: vscode.TextEditor, + edit: vscode.TextEditorEdit, + args: any[] + ) => { + // force activation when .json files are opened outside of a workspace + vscode.commands.executeCommand('setContext', 'cedar.activated', true); + vscode.window.showInformationMessage('Cedar extension activated'); + } + ) + ); context.subscriptions.push( vscode.commands.registerTextEditorCommand( COMMAND_CEDAR_VALIDATE, @@ -401,169 +425,154 @@ export async function activate(context: vscode.ExtensionContext) { }) ); - if ( - vscode.workspace.workspaceFolders && - vscode.workspace.workspaceFolders.length > 0 - ) { - // @ts-ignore - const schemaPattern: vscode.RelativePattern = { - baseUri: vscode.workspace.workspaceFolders[0].uri, - pattern: CEDAR_SCHEMA_GLOB, - }; - const schemaSelector = { - language: 'json', - pattern: schemaPattern, - }; - - context.subscriptions.push( - vscode.languages.registerDocumentSemanticTokensProvider( - schemaSelector, - schemaTokensProvider, - semanticTokensLegend - ) - ); - - // display uid strings in outline and breadcrumb - const schemaSymbolProvider = new CedarSchemaDocumentSymbolProvider(); - context.subscriptions.push( - vscode.languages.registerDocumentSymbolProvider( - schemaSelector, - schemaSymbolProvider - ) - ); - - const schemaDefinitionProvider = new CedarSchemaDefinitionProvider(); - context.subscriptions.push( - vscode.languages.registerDefinitionProvider( - schemaSelector, - schemaDefinitionProvider - ) - ); - - // @ts-ignore - const entitiesPattern: vscode.RelativePattern = { - baseUri: vscode.workspace.workspaceFolders[0].uri, - pattern: CEDAR_ENTITIES_GLOB, - }; - const entitiesSelector = { - language: 'json', - pattern: entitiesPattern, - }; - - context.subscriptions.push( - vscode.languages.registerDocumentSemanticTokensProvider( - entitiesSelector, - entitiesTokensProvider, - semanticTokensLegend - ) - ); - - // display uid strings in outline and breadcrumb - const entitiesSymbolProvider = new CedarEntitiesDocumentSymbolProvider(); - context.subscriptions.push( - vscode.languages.registerDocumentSymbolProvider( - entitiesSelector, - entitiesSymbolProvider - ) - ); - - const entitiesDefinitionProvider = new CedarEntitiesDefinitionProvider(); - context.subscriptions.push( - vscode.languages.registerDefinitionProvider( - entitiesSelector, - entitiesDefinitionProvider - ) - ); - - // @ts-ignore - const templateLinksPattern: vscode.RelativePattern = { - baseUri: vscode.workspace.workspaceFolders[0].uri, - pattern: CEDAR_TEMPLATELINKS_GLOB, - }; - const templateLinksSelector = { - language: 'json', - pattern: templateLinksPattern, - }; - - context.subscriptions.push( - vscode.languages.registerDocumentSemanticTokensProvider( - templateLinksSelector, - templateLinksTokensProvider, - semanticTokensLegend - ) - ); - - // display template link id strings in outline and breadcrumb - const templateLinksSymbolProvider = - new CedarTemplateLinksDocumentSymbolProvider(); - context.subscriptions.push( - vscode.languages.registerDocumentSymbolProvider( - templateLinksSelector, - templateLinksSymbolProvider - ) - ); - - const templateLinksDefinitionProvider = - new CedarTemplateLinksDefinitionProvider(); - context.subscriptions.push( - vscode.languages.registerDefinitionProvider( - templateLinksSelector, - templateLinksDefinitionProvider - ) - ); - - // @ts-ignore - const authPattern: vscode.RelativePattern = { - baseUri: vscode.workspace.workspaceFolders[0].uri, - pattern: CEDAR_AUTH_GLOB, - }; - const authSelector = { - language: 'json', - pattern: authPattern, - }; - - context.subscriptions.push( - vscode.languages.registerDocumentSemanticTokensProvider( - authSelector, - authTokensProvider, - semanticTokensLegend - ) - ); - - const authDefinitionProvider = new CedarAuthDefinitionProvider(); - context.subscriptions.push( - vscode.languages.registerDefinitionProvider( - authSelector, - authDefinitionProvider - ) - ); - - // @ts-ignore - const cedarJsonPattern: vscode.RelativePattern = { - baseUri: vscode.workspace.workspaceFolders[0].uri, - pattern: CEDAR_JSON_GLOB, - }; - const cedarJsonSelector = { - language: 'json', - pattern: cedarJsonPattern, - }; - - context.subscriptions.push( - vscode.languages.registerDocumentSemanticTokensProvider( - cedarJsonSelector, - cedarJsonTokensProvider, - semanticTokensLegend - ) - ); - - const cedarJsonDefinitionProvider = new CedarJsonDefinitionProvider(); - context.subscriptions.push( - vscode.languages.registerDefinitionProvider( - cedarJsonSelector, - cedarJsonDefinitionProvider - ) - ); - } + /* + * Cedar schema (JSON) file providers + */ + const schemaSelector = { + language: 'json', + pattern: CEDAR_SCHEMA_GLOB, + }; + + context.subscriptions.push( + vscode.languages.registerDocumentSemanticTokensProvider( + schemaSelector, + schemaTokensProvider, + semanticTokensLegend + ) + ); + + // display uid strings in outline and breadcrumb + const schemaSymbolProvider = new CedarSchemaDocumentSymbolProvider(); + context.subscriptions.push( + vscode.languages.registerDocumentSymbolProvider( + schemaSelector, + schemaSymbolProvider + ) + ); + + const schemaDefinitionProvider = new CedarSchemaDefinitionProvider(); + context.subscriptions.push( + vscode.languages.registerDefinitionProvider( + schemaSelector, + schemaDefinitionProvider + ) + ); + + /* + * Cedar entities (JSON) file providers + */ + const entitiesSelector = { + language: 'json', + pattern: CEDAR_ENTITIES_GLOB, + }; + + context.subscriptions.push( + vscode.languages.registerDocumentSemanticTokensProvider( + entitiesSelector, + entitiesTokensProvider, + semanticTokensLegend + ) + ); + + // display uid strings in outline and breadcrumb + const entitiesSymbolProvider = new CedarEntitiesDocumentSymbolProvider(); + context.subscriptions.push( + vscode.languages.registerDocumentSymbolProvider( + entitiesSelector, + entitiesSymbolProvider + ) + ); + + const entitiesDefinitionProvider = new CedarEntitiesDefinitionProvider(); + context.subscriptions.push( + vscode.languages.registerDefinitionProvider( + entitiesSelector, + entitiesDefinitionProvider + ) + ); + + /* + * Cedar template links (JSON) file providers + */ + const templateLinksSelector = { + language: 'json', + pattern: CEDAR_TEMPLATELINKS_GLOB, + }; + + context.subscriptions.push( + vscode.languages.registerDocumentSemanticTokensProvider( + templateLinksSelector, + templateLinksTokensProvider, + semanticTokensLegend + ) + ); + + // display template link id strings in outline and breadcrumb + const templateLinksSymbolProvider = + new CedarTemplateLinksDocumentSymbolProvider(); + context.subscriptions.push( + vscode.languages.registerDocumentSymbolProvider( + templateLinksSelector, + templateLinksSymbolProvider + ) + ); + + const templateLinksDefinitionProvider = + new CedarTemplateLinksDefinitionProvider(); + context.subscriptions.push( + vscode.languages.registerDefinitionProvider( + templateLinksSelector, + templateLinksDefinitionProvider + ) + ); + + /* + * Cedar authorization request (JSON) file providers + */ + const authSelector = { + language: 'json', + pattern: CEDAR_AUTH_GLOB, + }; + + context.subscriptions.push( + vscode.languages.registerDocumentSemanticTokensProvider( + authSelector, + authTokensProvider, + semanticTokensLegend + ) + ); + + const authDefinitionProvider = new CedarAuthDefinitionProvider(); + context.subscriptions.push( + vscode.languages.registerDefinitionProvider( + authSelector, + authDefinitionProvider + ) + ); + + /* + * Cedar (JSON) file providers + */ + const cedarJsonSelector = { + language: 'json', + pattern: CEDAR_JSON_GLOB, + }; + + context.subscriptions.push( + vscode.languages.registerDocumentSemanticTokensProvider( + cedarJsonSelector, + cedarJsonTokensProvider, + semanticTokensLegend + ) + ); + + const cedarJsonDefinitionProvider = new CedarJsonDefinitionProvider(); + context.subscriptions.push( + vscode.languages.registerDefinitionProvider( + cedarJsonSelector, + cedarJsonDefinitionProvider + ) + ); } // This method is called when your extension is deactivated diff --git a/src/help.ts b/src/help.ts index 0946659..532c190 100644 --- a/src/help.ts +++ b/src/help.ts @@ -1,54 +1,79 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export const HOVER_HELP_DEFINITIONS: Record = { - contains: ['contains(any) → bool', 'Set membership (is B an element of A)'], +export const FUNCTION_HELP_DEFINITIONS: Record = { + contains: [ + 'contains(any): Boolean', + 'Function that evaluates to `true` if the operand is a member of the receiver on the left side of the function. The receiver must be of type `Set`.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-contains', + ], containsAll: [ - 'containsAll(set) → bool', - 'Tests if set A contains all of the elements in set B', + 'containsAll(Set): Boolean', + 'Function that evaluates to `true` if every member of the operand set is a member of the receiver set. Both the receiver and the operand must be of type `Set`.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-containsAll', ], containsAny: [ - 'containsAny(set) → bool', - 'Tests if set A contains any of the elements in set B', + 'containsAny(Set): Boolean', + 'Function that evaluates to `true` if any one or more members of the operand set is a member of the receiver set. Both the receiver and the operand must be of type `Set`.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-containsAny', ], // IPAddr extension functions ip: [ - 'ip(string) → ipaddr', - 'Parse a string representing an IP address or range. Supports both IPv4 and IPv6. Ranges are indicated with CIDR notation (e.g. /24).', + 'ip(String): ipaddr', + "Function that parses the `String` and attempts to convert it to type `ipaddr`. If the string doesn't represent a valid IP address or range, then it generates an error." + + ' Supports both IPv4 and IPv6. Ranges are indicated with CIDR notation (e.g. /24).' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-ip', + ], + isIpv4: [ + 'isIpv4(): Boolean', + 'Evaluates to `true` if the receiver is an IPv4 address. This function takes no operand.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-isIpv4', + ], + isIpv6: [ + 'isIpv6(): Boolean', + 'Function that evaluates to `true` if the receiver is an IPv6 address. This function takes no operand.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-isIpv6.title', ], - isIpv4: ['isIpv4() → bool', 'Tests whether an IP address is an IPv4 address'], - isIpv6: ['isIpv6() → bool', 'Tests whether an IP address is an IPv6 address'], isLoopback: [ - 'isLoopback() → bool', - 'Tests whether an IP address is a loopback address', + 'isLoopback(): Boolean', + 'Function that evaluates to `true` if the receiver is a valid loopback address for its IP version type. This function takes no operand.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-isLoopback.title', ], isMulticast: [ - 'isMulticast() → bool', - 'Tests whether an IP address is a multicast address', + 'isMulticast(): Boolean', + 'Function that evaluates to `true` if the receiver is a multicast address for its IP version type. This function takes no operand.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-isMulticast.title', ], isInRange: [ - 'isInRange(ipaddr) → bool', - 'Tests if ipaddr A is in the range indicated by ipaddr B. (If A is a range, tests whether A is a subrange of B. If B is a single address rather than a range, B is treated as a range containing a single address.)', + 'isInRange(ipaddr): Boolean', + 'Function that evaluates to `true` if the receiver is an IP address or a range of addresses that fall completely within the range specified by the operand.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-isInRange.title', ], // Decimal extension functions decimal: [ - 'decimal(string) → decimal', - 'Parse a string representing a decimal value. Matches against the regular expression -?[0-9]+.[0-9]+, allowing at most 4 digits after the decimal point.', + 'decimal(String): decimal', + "Function that parses the `String` and tries to convert it to type `decimal`. If the string doesn't represent a valid `decimal` value, it generates an error." + + '\n\nTo be interpreted successfully as a `decimal` value, the string must contain a decimal separator (.) and at least one digit before and at least one digit after the separator. There can be no more than 4 digits after the separator. The value must be within the valid range of the `decimal` type, from `-922337203685477.5808` to `922337203685477.5807`.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-decimal', ], lessThan: [ - 'lessThan(decimal) → bool', - 'Tests whether the first decimal value is less than the second', + 'lessThan(decimal): Boolean', + 'Function that compares two `decimal` operands and evaluates to `true` if the left operand is numerically less than the right operand.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-lessThan', ], lessThanOrEqual: [ - 'lessThanOrEqual(decimal) → bool', - 'Tests whether the first decimal value is less than or equal to the second', + 'lessThanOrEqual(decimal): Boolean', + 'Function that compares two `decimal` operands and evaluates to `true` if the left operand is numerically less than or equal to the right operand.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-lessThanOrEqual', ], greaterThan: [ - 'greaterThan(decimal) → bool', - 'Tests whether the first decimal value is greater than the second', + 'greaterThan(decimal): Boolean', + 'Function that compares two `decimal` operands and evaluates to `true` if the left operand is numerically greater than the right operand.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-greaterThan', ], greaterThanOrEqual: [ - 'greaterThanOrEqual(decimal) → bool', - 'Tests whether the first decimal value is greater than or equal to the second', + 'greaterThanOrEqual(decimal): Boolean', + 'Function that compares two `decimal` operands and evaluates to `true` if the left operand is numerically greater than or equal to the right operand.' + + '\n\nhttps://docs.cedarpolicy.com/policies/syntax-operators.html#function-greaterThanOrEqual', ], }; diff --git a/src/hover.ts b/src/hover.ts new file mode 100644 index 0000000..f1c9c79 --- /dev/null +++ b/src/hover.ts @@ -0,0 +1,143 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as vscode from 'vscode'; +import { FUNCTION_HELP_DEFINITIONS } from './help'; +import { getSchemaTextDocument } from './fileutil'; +import { narrowEntityTypes } from './validate'; +import { PROPERTY_CHAIN_REGEX } from './regex'; +import { parseCedarSchemaDoc, traversePropertyChain } from './parser'; +import { splitPropertyChain } from './completion'; + +const getPrevNextCharacters = ( + document: vscode.TextDocument, + range: vscode.Range +): { prevChar: string; nextChar: string } => { + let prevChar = '', + nextChar = ''; + const nextCharPos = new vscode.Position( + range.end.line, + range.end.character + 1 + ); + if (document.validatePosition(nextCharPos)) { + nextChar = document.getText(new vscode.Range(range.end, nextCharPos)); + } + const prevCharPos = new vscode.Position( + range.start.line, + range.start.character - 1 + ); + if (document.validatePosition(prevCharPos)) { + prevChar = document.getText(new vscode.Range(prevCharPos, range.start)); + } + + return { prevChar: prevChar, nextChar: nextChar }; +}; + +const createVariableHover = async ( + document: vscode.TextDocument, + word: string +): Promise => { + let mdarray: vscode.MarkdownString[] = []; + const schemaDoc = await getSchemaTextDocument(undefined, document); + if (schemaDoc) { + let entities = narrowEntityTypes(schemaDoc, word); + entities.forEach((entityType) => { + const md = new vscode.MarkdownString(); + md.appendCodeblock(entityType, 'cedar'); + mdarray.push(md); + }); + } + + if (mdarray.length > 0) { + return { + contents: mdarray, + }; + } + return undefined; +}; + +const createPropertyHover = async ( + document: vscode.TextDocument, + properties: string[] +): Promise => { + let mdarray: vscode.MarkdownString[] = []; + const schemaDoc = await getSchemaTextDocument(undefined, document); + if (schemaDoc) { + let entities = narrowEntityTypes(schemaDoc, properties[0]); + const completions = parseCedarSchemaDoc(schemaDoc).completions; + let word = properties[properties.length - 1]; + entities.forEach((entityType) => { + const { lastType } = traversePropertyChain( + completions, + properties, + entityType + ); + if (lastType) { + let md = new vscode.MarkdownString(); + md.appendCodeblock(`(${entityType}) ${word}: ${lastType}`, 'cedar'); + mdarray.push(md); + } + }); + } + + if (mdarray.length > 0) { + return { + contents: mdarray, + }; + } + return undefined; +}; + +export class CedarHoverProvider implements vscode.HoverProvider { + provideHover( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken + ): vscode.ProviderResult { + const range = document.getWordRangeAtPosition(position); + if (range) { + const word = document.getText(range); + + const { prevChar, nextChar } = getPrevNextCharacters(document, range); + if ( + prevChar !== '.' && + prevChar !== '?' && + ['principal', 'resource', 'context', 'action'].includes(word) + ) { + return new Promise(async (resolve) => { + let result = await createVariableHover(document, word); + resolve(result); + }); + } + + if (nextChar === '(') { + if (FUNCTION_HELP_DEFINITIONS[word]) { + return { + contents: FUNCTION_HELP_DEFINITIONS[word], + }; + } + } + + const line = document.lineAt(position).text; + const lineBeforeWord = line.substring(0, range.start.character); + + // TODO: match quoted properties (e.g. principal["propname with space"]) + if (prevChar === '.' || lineBeforeWord.endsWith(' has ')) { + const lineIncludingWord = document.getText( + new vscode.Range(new vscode.Position(range.start.line, 0), range.end) + ); + let found = lineIncludingWord + .replaceAll(' has ', '.') + .match(PROPERTY_CHAIN_REGEX); + if (found && found?.groups) { + const properties = splitPropertyChain(found[0]); + return new Promise(async (resolve) => { + let result = undefined; + result = await createPropertyHover(document, properties); + resolve(result); + }); + } + } + } + } +} diff --git a/src/parser.ts b/src/parser.ts index de903af..9e40745 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -4,11 +4,7 @@ import * as vscode from 'vscode'; import * as jsonc from 'jsonc-parser'; import { DEFAULT_RANGE } from './diagnostics'; -import { - EFFECT_ACTION_REGEX, - EFFECT_ENTITY_REGEX, - ENTITY_REGEX, -} from './regex'; +import { ENTITY_REGEXG } from './regex'; /* * see https://code.visualstudio.com/api/language-extensions/semantic-highlight-guide @@ -18,6 +14,7 @@ import { const tokenTypes = [ 'namespace', 'type', + 'struct', 'property', 'macro', 'function', @@ -30,6 +27,15 @@ export const semanticTokensLegend = new vscode.SemanticTokensLegend( tokenModifiers ); +const ensureNamespace = (type: string, namespace: string) => { + const pos = Math.max(type.indexOf('::')); + if (pos === -1) { + return namespace + type; + } else { + return type; + } +}; + const determineDefinitionRange = ( type: string, line: number, @@ -46,7 +52,14 @@ const determineDefinitionRange = ( return defRange; }; -// Cedar policies +export type ReferencedRange = { + name: string; + range: vscode.Range; +}; + +/* + * Cedar policies + */ export type PolicyRange = { id: string; @@ -58,13 +71,15 @@ type PolicyCacheItem = { version: number; policies: PolicyRange[]; tokens: vscode.SemanticTokens; - entityTypes: vscode.Range[]; - actions: vscode.Range[]; + referencedTypes: ReferencedRange[]; + actionIds: ReferencedRange[]; + annotations: Set; }; const policyCache: Record = {}; -const ID_ATTR = /@id\("(?(.+))"\)/; +const ID_ANNOTATION = /@id\("(?(.+))"\)/; +const ANNOTATION = /@(?[_a-zA-Z][_a-zA-Z0-9]*)\(".*"\)/; export const parseCedarPoliciesDoc = ( cedarDoc: vscode.TextDocument, @@ -82,8 +97,9 @@ export const parseCedarPoliciesDoc = ( } const policies: PolicyRange[] = []; - const entityTypes: vscode.Range[] = []; - const actions: vscode.Range[] = []; + const referencedTypes: ReferencedRange[] = []; + const actionIds: ReferencedRange[] = []; + const annotations = new Set(['id']); let count = 0; let id: string | null = null; const tokensBuilder = new vscode.SemanticTokensBuilder(semanticTokensLegend); @@ -94,10 +110,16 @@ export const parseCedarPoliciesDoc = ( for (let i = 0; i < cedarDoc.lineCount; i++) { const textLine = cedarDoc.lineAt(i).text; const trimmed = textLine.trim(); - if (id === null && trimmed.startsWith('@id(')) { - const found = trimmed.match(ID_ATTR); + if (id === null && trimmed.startsWith('@')) { + let found = trimmed.match(ID_ANNOTATION); if (found?.groups) { id = found.groups.id; + } else { + found = trimmed.match(ANNOTATION); + if (found?.groups && found?.groups.name) { + // collect annotation names for completion items + annotations.add(found.groups.name); + } } } if (trimmed.startsWith('permit') || trimmed.startsWith('forbid')) { @@ -124,32 +146,33 @@ export const parseCedarPoliciesDoc = ( 0, commentPos > -1 ? commentPos : textLine.length ); - let foundArray = [...linePreComment.matchAll(EFFECT_ENTITY_REGEX)]; - foundArray.forEach((found) => { - if (found && found?.groups) { - const type = found?.groups.type; - const startCharacter = - linePreComment.indexOf(type + '::"', found.index) || 0; - entityTypes.push( - determineDefinitionRange(type, i, startCharacter - 1, type.length + 1) - ); - } - }); - - foundArray = [...linePreComment.matchAll(EFFECT_ACTION_REGEX)]; + let foundArray = [...linePreComment.matchAll(ENTITY_REGEXG)]; foundArray.forEach((found) => { if (found && found?.groups) { - const id = found?.groups.id; - const startCharacter = - linePreComment.indexOf(`"${id}"`, found.index) || 0; - - actions.push( - new vscode.Range( - new vscode.Position(i, startCharacter + 1), - new vscode.Position(i, startCharacter + id.length + 1) - ) - ); + const type = found?.groups.type; + if (type === 'Action' || type.endsWith('::Action')) { + const id = found?.groups.id; + const startCharacter = + linePreComment.indexOf(`"${id}"`, found.index) || 0; + actionIds.push({ + name: `${type}::"${id}"`, + range: new vscode.Range( + new vscode.Position(i, startCharacter + 1), + new vscode.Position(i, startCharacter + 1 + id.length) + ), + }); + } else { + referencedTypes.push({ + name: type, + range: determineDefinitionRange( + type, + i, + (found.index as number) - 1, + type.length + 1 + ), + }); + } } }); @@ -179,8 +202,9 @@ export const parseCedarPoliciesDoc = ( version: cedarDoc.version, policies: policies, tokens: tokensBuilder.build(), - entityTypes: entityTypes, - actions: actions, + referencedTypes: referencedTypes, + actionIds: actionIds, + annotations: annotations, }; policyCache[cedarDoc.uri.toString()] = cachedItem; @@ -195,13 +219,15 @@ export const cedarTokensProvider: vscode.DocumentSemanticTokensProvider = { }, }; -// Cedar policy (JSON) +/* + * Cedar policy (JSON) + */ type PolicyJsonCacheItem = { version: number; tokens: vscode.SemanticTokens; - entityTypes: vscode.Range[]; - actions: vscode.Range[]; + referencedTypes: ReferencedRange[]; + actionIds: ReferencedRange[]; }; const policyJsonCache: Record = {}; @@ -217,8 +243,9 @@ export const parseCedarJsonPolicyDoc = ( const tokensBuilder = new vscode.SemanticTokensBuilder(semanticTokensLegend); - const entityTypes: vscode.Range[] = []; - const actions: vscode.Range[] = []; + const referencedTypes: ReferencedRange[] = []; + const actionIds: ReferencedRange[] = []; + let tmpActionType = ''; jsonc.visit(cedarJsonDoc.getText(), { onObjectProperty( @@ -238,6 +265,11 @@ export const parseCedarJsonPolicyDoc = ( if (['principal', 'action', 'resource'].includes(property)) { tokensBuilder.push(range, 'variable', []); } + } else if ( + property === '__entity' && + pathSupplier()[jsonPathLen - 1] === 'Value' + ) { + tokensBuilder.push(range, 'macro', []); } }, onLiteralValue( @@ -258,6 +290,25 @@ export const parseCedarJsonPolicyDoc = ( tokensBuilder.push(range, 'keyword', []); } + if (jsonPathLen > 2 && pathSupplier()[jsonPathLen - 1] === 'type') { + // most things directly under "type" are a type + tokensBuilder.push(range, 'type', []); + + if (value === 'Action' || value.endsWith('::Action')) { + tmpActionType = value; + } else { + referencedTypes.push({ + name: value, + range: determineDefinitionRange( + value, + startLine, + startCharacter, + length - 1 + ), + }); + } + } + if ( jsonPathLen === 3 && pathSupplier()[0] === 'conditions' && @@ -267,24 +318,21 @@ export const parseCedarJsonPolicyDoc = ( tokensBuilder.push(range, 'keyword', []); } else if ( jsonPathLen > 2 && - pathSupplier()[0] === 'action' && - pathSupplier()[jsonPathLen - 1] === 'id' + // type / id under top level 'action' + (pathSupplier()[0] === 'action' || + // type / id directly under '__entity' + pathSupplier()[jsonPathLen - 2] === '__entity') ) { - const innerRange = new vscode.Range( - new vscode.Position(startLine, startCharacter + 1), - new vscode.Position(startLine, startCharacter + length - 1) - ); - actions.push(innerRange); - } else if ( - jsonPathLen > 2 && - pathSupplier()[jsonPathLen - 1] === 'type' - ) { - // most things directly under "type" are a type - tokensBuilder.push(range, 'type', []); - - entityTypes.push( - determineDefinitionRange(value, startLine, startCharacter, length - 1) - ); + if (pathSupplier()[jsonPathLen - 1] === 'id') { + actionIds.push({ + name: `${tmpActionType}::"${value}"`, + range: new vscode.Range( + new vscode.Position(startLine, startCharacter + 1), + new vscode.Position(startLine, startCharacter + length - 1) + ), + }); + tmpActionType = ''; + } } else if (jsonPathLen > 2 && pathSupplier()[jsonPathLen - 1] === 'Var') { // most things directly under "Var" are a variable tokensBuilder.push(range, 'variable', []); @@ -295,8 +343,8 @@ export const parseCedarJsonPolicyDoc = ( cachedItem = { version: cedarJsonDoc.version, tokens: tokensBuilder.build(), - entityTypes: entityTypes, - actions: actions, + referencedTypes: referencedTypes, + actionIds: actionIds, }; policyJsonCache[cedarJsonDoc.uri.toString()] = cachedItem; @@ -311,7 +359,9 @@ export const cedarJsonTokensProvider: vscode.DocumentSemanticTokensProvider = { }, }; -// Cedar entities +/* + * Cedar entities + */ export type EntityRange = { uid: string; @@ -329,7 +379,7 @@ export type EntityCacheItem = { version: number; entities: EntityRange[]; tokens: vscode.SemanticTokens; - entityTypes: vscode.Range[]; + referencedTypes: ReferencedRange[]; }; const entityCache: Record = {}; @@ -366,7 +416,7 @@ export const parseCedarEntitiesDoc = ( } const entities: EntityRange[] = []; - const entityTypes: vscode.Range[] = []; + const referencedTypes: ReferencedRange[] = []; let uidType: string = ''; let uid: string = ''; @@ -509,9 +559,16 @@ export const parseCedarEntitiesDoc = ( uidId = value; uid = `${uidType}::"${uidId}"`; } - if (pathSupplier()[jsonPathLen - 1] === UID) { - uid = value; - let found = uid.match(ENTITY_REGEX); + } + + // entities as strings under UID or string array element under PARENTS + if ( + (jsonPathLen === 2 && pathSupplier()[1] === UID) || + (jsonPathLen === 3 && pathSupplier()[1] === PARENTS) + ) { + uid = value; + let foundArray = [...uid.matchAll(ENTITY_REGEXG)]; + foundArray.forEach((found) => { if (found?.groups) { const type = found?.groups.type; const typeRange = new vscode.Range( @@ -519,17 +576,17 @@ export const parseCedarEntitiesDoc = ( new vscode.Position(startLine, startCharacter + type.length + 1) ); tokensBuilder.push(typeRange, TYPE, []); - - entityTypes.push( - determineDefinitionRange( + referencedTypes.push({ + name: type, + range: determineDefinitionRange( type, startLine, startCharacter, type.length + 1 - ) - ); + ), + }); } - } + }); } if ( @@ -537,18 +594,25 @@ export const parseCedarEntitiesDoc = ( pathSupplier()[jsonPathLen - 1] === TYPE && (pathSupplier()[1] === UID || pathSupplier()[1] === PARENTS || + (pathSupplier()[1] === ATTRS && jsonPathLen > 3) || pathSupplier()[jsonPathLen - 2] === ENTITY) ) { // most things directly under "type" are a type tokensBuilder.push(range, 'type', []); - - entityTypes.push( - determineDefinitionRange(value, startLine, startCharacter, length - 1) - ); + referencedTypes.push({ + name: value, + range: determineDefinitionRange( + value, + startLine, + startCharacter, + length - 1 + ), + }); } else if ( jsonPathLen > 2 && pathSupplier()[jsonPathLen - 1] === 'fn' && - pathSupplier()[jsonPathLen - 2] === '__extn' + (pathSupplier()[jsonPathLen - 2] === '__extn' || + ['ip', 'decimal'].includes(value)) ) { // things under "__extn" then "fn" are a function tokensBuilder.push(range, 'function', []); @@ -560,7 +624,7 @@ export const parseCedarEntitiesDoc = ( version: entitiesDoc.version, entities: entities, tokens: tokensBuilder.build(), - entityTypes: entityTypes, + referencedTypes: referencedTypes, }; entityCache[entitiesDoc.uri.toString()] = cachedItem; @@ -576,7 +640,9 @@ export const entitiesTokensProvider: vscode.DocumentSemanticTokensProvider = { }, }; -// Cedar template links +/* + * Cedar template links + */ export type TemplateLinkRange = { id: string; @@ -588,7 +654,7 @@ export type TemplateLinksCacheItem = { version: number; links: TemplateLinkRange[]; tokens: vscode.SemanticTokens; - entityTypes: vscode.Range[]; + referencedTypes: ReferencedRange[]; }; const templateLinksCache: Record = {}; @@ -610,7 +676,7 @@ export const parseCedarTemplateLinksDoc = ( let linkIdRange: vscode.Range | null = null; let depth = 0; const links: TemplateLinkRange[] = []; - const entityTypes: vscode.Range[] = []; + const referencedTypes: ReferencedRange[] = []; jsonc.visit(cedarTemplateLinksDoc.getText(), { onObjectBegin(offset, length, startLine, startCharacter, pathSupplier) { @@ -645,24 +711,26 @@ export const parseCedarTemplateLinksDoc = ( if (pathSupplier()[1] === 'link_id') { linkId = value; } else if (pathSupplier()[1] === 'args' && jsonPathLen === 3) { - let found = value.match(ENTITY_REGEX); - if (found?.groups) { - const type = found?.groups.type; - const typeRange = new vscode.Range( - new vscode.Position(startLine, startCharacter + 1), - new vscode.Position(startLine, startCharacter + type.length + 1) - ); - tokensBuilder.push(typeRange, 'type', []); - - entityTypes.push( - determineDefinitionRange( - type, - startLine, - startCharacter, - type.length + 1 - ) - ); - } + let foundArray = [...value.matchAll(ENTITY_REGEXG)]; + foundArray.forEach((found) => { + if (found?.groups) { + const type = found?.groups.type; + const typeRange = new vscode.Range( + new vscode.Position(startLine, startCharacter + 1), + new vscode.Position(startLine, startCharacter + type.length + 1) + ); + tokensBuilder.push(typeRange, 'type', []); + referencedTypes.push({ + name: type, + range: determineDefinitionRange( + type, + startLine, + startCharacter, + type.length + 1 + ), + }); + } + }); } }, onObjectProperty( @@ -697,7 +765,7 @@ export const parseCedarTemplateLinksDoc = ( version: cedarTemplateLinksDoc.version, links: links, tokens: tokensBuilder.build(), - entityTypes: entityTypes, + referencedTypes: referencedTypes, }; templateLinksCache[cedarTemplateLinksDoc.uri.toString()] = cachedItem; @@ -714,13 +782,14 @@ export const templateLinksTokensProvider: vscode.DocumentSemanticTokensProvider }, }; -// Cedar authorization requests (PARC) - +/* + * Cedar authorization requests (PARC) + */ export type AuthCacheItem = { version: number; tokens: vscode.SemanticTokens; - entityTypes: vscode.Range[]; - actions: vscode.Range[]; + referencedTypes: ReferencedRange[]; + actionIds: ReferencedRange[]; }; const authCache: Record = {}; @@ -735,8 +804,8 @@ export const parseCedarAuthDoc = ( } const tokensBuilder = new vscode.SemanticTokensBuilder(semanticTokensLegend); - const entityTypes: vscode.Range[] = []; - const actions: vscode.Range[] = []; + const referencedTypes: ReferencedRange[] = []; + const actionIds: ReferencedRange[] = []; jsonc.visit(cedarAuthDoc.getText(), { onLiteralValue( @@ -753,40 +822,44 @@ export const parseCedarAuthDoc = ( ['principal', 'action', 'resource'].includes(property) && jsonPathLen === 1 ) { - let found = value.match(ENTITY_REGEX); - if (found?.groups) { - const type = found?.groups.type; - const typeRange = new vscode.Range( - new vscode.Position(startLine, startCharacter + 1), - new vscode.Position(startLine, startCharacter + type.length + 1) - ); - tokensBuilder.push(typeRange, 'type', []); - - if (property === 'action') { - const actionId = found?.groups.id; - const pos = value.lastIndexOf(actionId); - if (pos > -1) { - actions.push( - new vscode.Range( - new vscode.Position(startLine, startCharacter + pos + 2), - new vscode.Position( - startLine, - startCharacter + pos + 2 + actionId.length - ) - ) - ); - } - } else { - entityTypes.push( - determineDefinitionRange( - type, - startLine, - startCharacter, - type.length + 1 - ) + let foundArray = [...value.matchAll(ENTITY_REGEXG)]; + foundArray.forEach((found) => { + if (found?.groups) { + const type = found?.groups.type; + const typeRange = new vscode.Range( + new vscode.Position(startLine, startCharacter + 1), + new vscode.Position(startLine, startCharacter + type.length + 1) ); + tokensBuilder.push(typeRange, 'type', []); + + if (property === 'action') { + const actionId = found?.groups.id; + const pos = value.lastIndexOf(actionId); + if (pos > -1) { + actionIds.push({ + name: value, + range: new vscode.Range( + new vscode.Position(startLine, startCharacter + pos + 2), + new vscode.Position( + startLine, + startCharacter + pos + 2 + actionId.length + ) + ), + }); + } + } else { + referencedTypes.push({ + name: type, + range: determineDefinitionRange( + type, + startLine, + startCharacter, + type.length + 1 + ), + }); + } } - } + }); } }, onObjectProperty( @@ -813,8 +886,8 @@ export const parseCedarAuthDoc = ( cachedItem = { version: cedarAuthDoc.version, tokens: tokensBuilder.build(), - entityTypes: entityTypes, - actions: actions, + referencedTypes: referencedTypes, + actionIds: actionIds, }; authCache[cedarAuthDoc.uri.toString()] = cachedItem; @@ -832,21 +905,36 @@ export const authTokensProvider: vscode.DocumentSemanticTokensProvider = { // Cedar schema +export const PRIMITIVE_TYPES = [ + 'String', + 'Long', + 'Boolean', + 'Record', + 'Set', + 'Entity', + 'Extension', +]; + export type SchemaRange = { collection: 'commonTypes' | 'entityTypes' | 'actions'; etype: string; - deftype: string; range: vscode.Range; etypeRange: vscode.Range; symbol: vscode.SymbolKind; }; +type SchemaCompletionData = { + description: string; + children?: SchemaCompletionRecord; +}; +export type SchemaCompletionRecord = Record; type SchemaCacheItem = { version: number; - entities: SchemaRange[]; + definitionRanges: SchemaRange[]; tokens: vscode.SemanticTokens; - entityTypes: vscode.Range[]; - actions: vscode.Range[]; + referencedTypes: ReferencedRange[]; + actionIds: ReferencedRange[]; + completions: Record; }; const schemaCache: Record = {}; @@ -866,17 +954,58 @@ export const parseCedarSchemaDoc = ( } } - const entities: SchemaRange[] = []; - const entityTypes: vscode.Range[] = []; - const actions: vscode.Range[] = []; + const definitionRanges: SchemaRange[] = []; + const referencedTypes: ReferencedRange[] = []; + const actionIds: ReferencedRange[] = []; + const completions: Record = {}; let namespace: string = ''; let collection: 'commonTypes' | 'entityTypes' | 'actions' = 'entityTypes'; let etype: string = ''; - let deftype: string = ''; let etypeStart: vscode.Position | null = null; let etypeEnd: vscode.Position | null = null; let etypeRange: vscode.Range | null = null; + let tmpMemberOf: { id: string; type: string; range: vscode.Range | null } = { + id: '', + type: '', + range: null, + }; + let tmpAttribute: { + key: string; + type: string; + element: string; + range: vscode.Range | null; + } = { + key: '', + type: '', + element: '', + range: null, + }; + + let tmpSchemaCompletionRecord: SchemaCompletionRecord = {}; + const tmpSchemaCompletionRecordStack: Array = []; + const tmpAttributeDepthStack: Array = []; + function captureAttribute(value: string) { + tmpSchemaCompletionRecord[tmpAttribute.key] = { description: value }; + tmpSchemaCompletionRecord[tmpAttribute.key] = { description: value }; + + if (value === 'Record') { + const children: SchemaCompletionRecord = {}; + tmpSchemaCompletionRecord[tmpAttribute.key].children = children; + tmpSchemaCompletionRecordStack.push(tmpSchemaCompletionRecord); + tmpSchemaCompletionRecord = children; + + tmpAttributeDepthStack.push(depth); + } + + tmpAttribute = { + key: '', + type: '', + element: '', + range: null, + }; + } + let depth = 0; let symbol = vscode.SymbolKind.Class; const tokensBuilder = new vscode.SemanticTokensBuilder(semanticTokensLegend); @@ -886,20 +1015,42 @@ export const parseCedarSchemaDoc = ( depth++; }, onObjectEnd(offset, length, startLine, startCharacter) { + if (tmpAttributeDepthStack.length) { + if (tmpAttributeDepthStack.at(-1) === depth) { + tmpAttributeDepthStack.pop(); + tmpSchemaCompletionRecord = + tmpSchemaCompletionRecordStack.pop() as SchemaCompletionRecord; + } + } depth--; if (depth === 3) { + completions[etype] = tmpSchemaCompletionRecord; + tmpSchemaCompletionRecord = {}; etypeEnd = new vscode.Position(startLine, startCharacter + length); if (etypeStart && etypeEnd && etypeRange) { const schemaRange: SchemaRange = { collection: collection, etype: etype, - deftype: deftype, range: new vscode.Range(etypeStart, etypeEnd), etypeRange: etypeRange, symbol: symbol, }; - entities.push(schemaRange); + definitionRanges.push(schemaRange); + } + } else if (depth === 4) { + if (tmpMemberOf.id && tmpMemberOf.range) { + actionIds.push({ + name: tmpMemberOf.type + ? `${tmpMemberOf.type}::"${tmpMemberOf.id}"` + : `${namespace}Action::"${tmpMemberOf.id}"`, + range: tmpMemberOf.range, + }); } + tmpMemberOf = { + id: '', + type: '', + range: null, + }; } }, onObjectProperty( @@ -918,6 +1069,8 @@ export const parseCedarSchemaDoc = ( if (jsonPathLen === 0) { if (property) { namespace = property + '::'; + } else { + namespace = ''; } tokensBuilder.push(range, 'namespace', ['declaration']); } else if (jsonPathLen === 2) { @@ -926,18 +1079,19 @@ export const parseCedarSchemaDoc = ( new vscode.Position(startLine, startCharacter + 1), new vscode.Position(startLine, startCharacter + length - 1) ); - deftype = property; - tokensBuilder.push(range, 'type', ['declaration']); if (pathSupplier()[1] === 'commonTypes') { + tokensBuilder.push(range, 'struct', ['declaration']); collection = 'commonTypes'; etype = namespace + property; symbol = vscode.SymbolKind.Struct; } else if (pathSupplier()[1] === 'entityTypes') { + tokensBuilder.push(range, 'type', ['declaration']); collection = 'entityTypes'; etype = namespace + property; symbol = vscode.SymbolKind.Class; } else if (pathSupplier()[1] === 'actions') { + tokensBuilder.push(range, 'type', ['declaration']); collection = 'actions'; etype = `${namespace}Action::"${property}"`; symbol = vscode.SymbolKind.Function; @@ -945,6 +1099,8 @@ export const parseCedarSchemaDoc = ( } else if (pathSupplier()[jsonPathLen - 1] === 'attributes') { // anything directly under "attributes" is an property declaration tokensBuilder.push(range, 'property', ['declaration']); + + tmpAttribute.key = property; } }, onLiteralValue( @@ -968,14 +1124,15 @@ export const parseCedarSchemaDoc = ( ) { tokensBuilder.push(range, 'type', []); if (pathSupplier()[3] === 'memberOfTypes') { - entityTypes.push( - determineDefinitionRange( + referencedTypes.push({ + name: ensureNamespace(value, namespace), + range: determineDefinitionRange( value, startLine, startCharacter, length - 1 - ) - ); + ), + }); } } else if ( // anything directly under "principalTypes" or "resourceTypes" under "actions" is a type @@ -985,9 +1142,15 @@ export const parseCedarSchemaDoc = ( pathSupplier()[4] === 'resourceTypes') ) { tokensBuilder.push(range, 'type', []); - entityTypes.push( - determineDefinitionRange(value, startLine, startCharacter, length - 1) - ); + referencedTypes.push({ + name: ensureNamespace(value, namespace), + range: determineDefinitionRange( + value, + startLine, + startCharacter, + length - 1 + ), + }); } else if ( // "id" or "type" under "memberOf" under "actions" is a type jsonPathLen === 6 && @@ -996,28 +1159,81 @@ export const parseCedarSchemaDoc = ( (pathSupplier()[5] === 'id' || pathSupplier()[5] === 'type') ) { tokensBuilder.push(range, 'type', []); - if (pathSupplier()[5] === 'id') { - actions.push( - new vscode.Range( - new vscode.Position(startLine, startCharacter + 1), - new vscode.Position(startLine, startCharacter + length - 1) - ) + + // save off id, range, and (optional) type + // actionIds is updated inside onObjectEnd + if (pathSupplier()[5] === 'type') { + tmpMemberOf.type = value; + } else if (pathSupplier()[5] === 'id') { + tmpMemberOf.id = value; + tmpMemberOf.range = new vscode.Range( + new vscode.Position(startLine, startCharacter + 1), + new vscode.Position(startLine, startCharacter + length - 1) ); } + } else if (pathSupplier()[jsonPathLen - 1] === 'type') { + // anything directly under "type" not matching a primitive type (probably) a common type + if (!PRIMITIVE_TYPES.includes(value)) { + tokensBuilder.push(range, 'struct', []); + referencedTypes.push({ + name: ensureNamespace(value, namespace), + range: determineDefinitionRange( + value, + startLine, + startCharacter, + length - 1 + ), + }); + } + if (pathSupplier()[jsonPathLen - 3] === 'attributes') { + tmpAttribute.type = value; + if (!['Set', 'Entity', 'Extension'].includes(value)) { + captureAttribute(value); + } + } else if (pathSupplier()[jsonPathLen - 2] === 'element') { + if (value !== 'Entity') { + tmpAttribute.element = value; + // 'type' under 'element' indicates parent is a Set + captureAttribute(`Set<${value}>`); + } + } else if ( + pathSupplier()[jsonPathLen - 2] === 'shape' || + pathSupplier()[jsonPathLen - 2] === 'context' + ) { + if (value !== 'Record') { + const attributes = completions[ensureNamespace(value, namespace)]; + Object.keys(attributes).forEach((key) => { + tmpAttribute.key = key; + captureAttribute(attributes[key].description); + }); + } + } } else if (pathSupplier()[jsonPathLen - 1] === 'name') { // anything directly under "name" is (probably) a type if (value === 'ipaddr' || value === 'decimal') { tokensBuilder.push(range, 'function', []); } else { tokensBuilder.push(range, 'type', []); - entityTypes.push( - determineDefinitionRange( + referencedTypes.push({ + name: ensureNamespace(value, namespace), + range: determineDefinitionRange( value, startLine, startCharacter, length - 1 - ) - ); + ), + }); + } + if (pathSupplier()[jsonPathLen - 2] === 'element') { + tmpAttribute.element = ensureNamespace(value, namespace); + // 'type' under 'element' indicates parent is a Set of Entity + captureAttribute(`Set<${tmpAttribute.element}>`); + } else if (pathSupplier()[jsonPathLen - 3] === 'attributes') { + if (value === 'ipaddr' || value === 'decimal') { + captureAttribute(value); + } else { + captureAttribute(ensureNamespace(value, namespace)); + } } } }, @@ -1025,16 +1241,43 @@ export const parseCedarSchemaDoc = ( const cachedItem = { version: schemaDoc.version, - entities: entities, + definitionRanges: definitionRanges, tokens: tokensBuilder.build(), - entityTypes: entityTypes, - actions: actions, + referencedTypes: referencedTypes, + actionIds: actionIds, + completions: completions, }; schemaCache[schemaDoc.uri.toString()] = cachedItem; return cachedItem; }; +export const traversePropertyChain = ( + completions: Record, + properties: string[], + entityType: string +): { lastType: string; completion: SchemaCompletionRecord | undefined } => { + let lastType = ''; + let completion: SchemaCompletionRecord | undefined = completions[entityType]; + for (let i = 1; i < properties.length; i++) { + if (completion && completion[properties[i]]) { + lastType = completion[properties[i]].description; + if (completion[properties[i]].children) { + // Record attributes + completion = completion[properties[i]].children; + } else { + // common type or entity type + completion = completions[completion[properties[i]].description]; + } + } else { + completion = undefined; + lastType = ''; + } + } + + return { lastType, completion }; +}; + export const schemaTokensProvider: vscode.DocumentSemanticTokensProvider = { provideDocumentSemanticTokens( cedarSchemaDoc: vscode.TextDocument diff --git a/src/regex.ts b/src/regex.ts index 20f036f..7e7b4ef 100644 --- a/src/regex.ts +++ b/src/regex.ts @@ -1,13 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export const ENTITY_REGEX = /(?.+)::"(?.+)"/; -export const EFFECT_ENTITY_REGEX = - /(principal|resource)\s+(==|in)\s+(?.+?)::"/g; -export const EFFECT_ACTION_REGEX = /\bAction::"(?.+?)"/g; +export const ENTITY_REGEXG = + /(?([_a-zA-Z][_a-zA-Z0-9]*::)*[_a-zA-Z][_a-zA-Z0-9]*)::"(?([^"]*))"/g; +export const PROPERTY_CHAIN_REGEX = + /\b(?(([_a-zA-Z][_a-zA-Z0-9]*::)*[_a-zA-Z][_a-zA-Z0-9]*::"(?([^"]*))"|principal|resource|context))((\.[_a-zA-Z][_a-zA-Z0-9]*|\["([^"]*)"\]))*(?.?)$/; // parses out start / end characters from Cedar validator -//export const FOUND_AT_REGEX = /found at (?(\d)+)(:)?(?(\d)+)?\n/; export const PARSE_ERROR_SCHEMA_REGEX = /(P|p)arse error in (?(entity type|common type|namespace))( identifier)?: /; export const AT_LINE_SCHEMA_REGEX = diff --git a/src/test/suite/competion.test.ts b/src/test/suite/competion.test.ts new file mode 100644 index 0000000..f9044bd --- /dev/null +++ b/src/test/suite/competion.test.ts @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as assert from 'assert'; +import { splitPropertyChain } from '../../completion'; + +suite('splitPropertyChain Suite', () => { + test('validate principal.p1', () => { + const parts = splitPropertyChain('principal.p1'); + assert.deepEqual(parts, ['principal', 'p1']); + }); + + test('validate principal.p1.p2', () => { + const parts = splitPropertyChain('principal.p1.p2'); + assert.deepEqual(parts, ['principal', 'p1', 'p2']); + }); + + test('validate principal["p1"]', () => { + const parts = splitPropertyChain('principal["p1"]'); + assert.deepEqual(parts, ['principal', 'p1']); + }); + + test('validate principal["p1"].p2', () => { + const parts = splitPropertyChain('principal["p1"].p2'); + assert.deepEqual(parts, ['principal', 'p1', 'p2']); + }); + + test('validate principal.p1', () => { + const parts = splitPropertyChain('principal.p1.'); + assert.deepEqual(parts, ['principal', 'p1']); + }); + + test('validate principal["p1"].', () => { + const parts = splitPropertyChain('principal["p1"].'); + assert.deepEqual(parts, ['principal', 'p1']); + }); + + test('validate principal["p1"].p2', () => { + const parts = splitPropertyChain('principal["p1"].p2.'); + assert.deepEqual(parts, ['principal', 'p1', 'p2']); + }); +}); diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index 977391b..8b2b2c8 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -3,13 +3,253 @@ import * as assert from 'assert'; // You can import and use all API from the 'vscode' module // as well as import your extension to test it import * as vscode from 'vscode'; -// import * as myExtension from '../../extension'; +import * as path from 'path'; +import * as cedar from '../../completion'; -suite('Extension Test Suite', () => { - //vscode.window.showInformationMessage('Start all tests.'); +suite('Cedar Completion Suite', () => { + vscode.window.showInformationMessage('Start Cedar Completion tests.'); - test('Sample test', () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); + const mockToken: vscode.CancellationToken = + new vscode.CancellationTokenSource().token; + + const invokeAt = async ( + position: vscode.Position, + lastCharacter: boolean = false + ) => { + const doc = await vscode.workspace.openTextDocument( + path.join(process.cwd(), 'testdata', 'triple', 'entities.cedar') + ); + await vscode.window.showTextDocument(doc); + const provider = new cedar.CedarCompletionItemProvider(); + const items = await provider.provideCompletionItems( + doc, + position, + mockToken, + lastCharacter + ? { + triggerKind: vscode.CompletionTriggerKind.TriggerCharacter, + triggerCharacter: doc + .lineAt(position.line) + .text.substring(position.character - 1, position.character), + } + : { + triggerKind: vscode.CompletionTriggerKind.Invoke, + triggerCharacter: undefined, + } + ); + + return items; + }; + + const triggerLastCharacter = async ( + content: string, + lastCharacter: boolean = true + ) => { + const doc = await vscode.workspace.openTextDocument({ + content: content, + language: 'cedar', + }); + await vscode.window.showTextDocument(doc); + + const provider = new cedar.CedarCompletionItemProvider(); + const items = await provider.provideCompletionItems( + doc, + new vscode.Position(0, content.length), + mockToken, + lastCharacter + ? { + triggerKind: vscode.CompletionTriggerKind.TriggerCharacter, + triggerCharacter: content.substring(content.length - 1), + } + : { + triggerKind: vscode.CompletionTriggerKind.Invoke, + triggerCharacter: undefined, + } + ); + + return items; + }; + + test('p at beginning of line', async () => { + const items = await invokeAt(new vscode.Position(3, 1)); + + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 2); + assert.strictEqual( + (items[0].label as vscode.CompletionItemLabel).description, + 'permit when' + ); + } + }); + + test('f at beginning of line', async () => { + const items = await invokeAt(new vscode.Position(4, 1)); + + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 2); + assert.strictEqual( + (items[0].label as vscode.CompletionItemLabel).description, + 'forbid when' + ); + } + }); + + test('w at beginning of line', async () => { + const items = await invokeAt(new vscode.Position(5, 1)); + + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 1); + assert.strictEqual( + (items[0].label as vscode.CompletionItemLabel).description, + 'when condition' + ); + } + }); + + test('u at beginning of line', async () => { + const items = await invokeAt(new vscode.Position(6, 1)); + + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 1); + assert.strictEqual( + (items[0].label as vscode.CompletionItemLabel).description, + 'unless condition' + ); + } + }); + + const assertEntities = (items: vscode.CompletionItem[] | undefined) => { + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 3); + assert.strictEqual(items[0].label, 'N1::E'); + assert.strictEqual(items[1].label, 'N2::E'); + assert.strictEqual(items[2].label, 'Y'); + } + }; + + const assertActionIds = (items: vscode.CompletionItem[] | undefined) => { + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 6); + assert.strictEqual(items[0].label, 'N1::Action::"a1"'); + assert.strictEqual(items[1].label, 'N1::Action::"a2"'); + assert.strictEqual(items[2].label, 'N2::Action::"a1"'); + assert.strictEqual(items[3].label, 'N2::Action::"a2"'); + assert.strictEqual(items[4].label, 'N2::Action::"astar"'); + assert.strictEqual(items[5].label, 'Action::"a"'); + } + }; + + test('principal entities', async () => { + let items = await invokeAt(new vscode.Position(9, 21)); + assertEntities(items); + + items = await invokeAt(new vscode.Position(12, 21)); + assertEntities(items); + }); + + test('principal properties', async () => { + let items = await invokeAt(new vscode.Position(19, 17), true); + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 2); + let itemLabel = items[0].label as vscode.CompletionItemLabel; + assert.strictEqual(itemLabel.label, 'l'); + assert.strictEqual(itemLabel.detail, ': Long'); + itemLabel = items[1].label as vscode.CompletionItemLabel; + assert.strictEqual(itemLabel.label, 's'); + assert.strictEqual(itemLabel.detail, ': String'); + } + }); + + test('action ids', async () => { + let items = await invokeAt(new vscode.Position(9, 33)); + assertActionIds(items); + + items = await invokeAt(new vscode.Position(13, 12)); + assertActionIds(items); + }); + + test('resource entities', async () => { + let items = await invokeAt(new vscode.Position(9, 47)); + assertEntities(items); + + items = await invokeAt(new vscode.Position(14, 14)); + assertEntities(items); + }); + + test('ip() functions', async () => { + const content = 'ip("127.0.0.1").'; + const items = await triggerLastCharacter(content); + + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 5); + assert.strictEqual( + (items[0].label as vscode.CompletionItemLabel).label, + 'isIpv4' + ); + } + }); + + test('ip() snippet', async () => { + const content = ' i'; + const items = await triggerLastCharacter(content, false); + + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 1); + assert.strictEqual( + (items[0].label as vscode.CompletionItemLabel).label, + 'ip' + ); + } + }); + + test('decimal() functions', async () => { + const content = 'decimal("0.1234").'; + const items = await triggerLastCharacter(content); + + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 4); + assert.strictEqual( + (items[0].label as vscode.CompletionItemLabel).label, + 'lessThan' + ); + } + }); + + test('decimal() snippet', async () => { + const content = ' d'; + const items = await triggerLastCharacter(content, false); + + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 1); + assert.strictEqual( + (items[0].label as vscode.CompletionItemLabel).label, + 'decimal' + ); + } + }); + + test('set functions', async () => { + const content = '[].'; + const items = await triggerLastCharacter(content); + + assert.ok(items); + if (items) { + assert.strictEqual(items.length, 3); + assert.strictEqual( + (items[0].label as vscode.CompletionItemLabel).label, + 'contains' + ); + } }); }); diff --git a/src/validate.ts b/src/validate.ts index bd95b84..8990403 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -19,10 +19,17 @@ type ValidationCacheItem = { version: number; valid: boolean; }; +type EntityTypesCacheItem = { + version: number; + principals: string[]; + resources: string[]; + actions: string[]; +}; class ValidationCache { docsBySchema: Record> = {}; cache: Record = {}; + entityTypes: Record = {}; constructor() {} check(doc: vscode.TextDocument): ValidationCacheItem | null { @@ -41,9 +48,54 @@ class ValidationCache { }; } + storeEntityTypes( + schemaDoc: vscode.TextDocument, + principals: string[], + resources: string[], + actions: string[] + ) { + this.entityTypes[schemaDoc.uri.toString()] = { + version: schemaDoc.version, + principals: principals, + resources: resources, + actions: actions, + }; + } + + checkEntityTypes( + schemaDoc: vscode.TextDocument + ): EntityTypesCacheItem | null { + const cachedItem = this.entityTypes[schemaDoc.uri.toString()]; + if (cachedItem && cachedItem.version === schemaDoc.version) { + return cachedItem; + } + + return null; + } + + fetchEntityTypes(schemaDoc: vscode.TextDocument): { + principals: string[]; + resources: string[]; + actions: string[]; + } { + const cachedItem = this.checkEntityTypes(schemaDoc); + return cachedItem + ? { + principals: cachedItem.principals, + resources: cachedItem.resources, + actions: cachedItem.actions, + } + : { + principals: [], + resources: [], + actions: [], + }; + } + clear() { this.cache = {}; this.docsBySchema = {}; + this.entityTypes = {}; } associateSchemaWithDoc( @@ -78,6 +130,25 @@ const validationCache = new ValidationCache(); export const clearValidationCache = () => { validationCache.clear(); }; +export const narrowEntityTypes = ( + schemaDoc: vscode.TextDocument, + scope: string +): string[] => { + const { principals, resources, actions } = + validationCache.fetchEntityTypes(schemaDoc); + let entities: string[] = []; + if (scope === 'principal') { + entities = principals; + } else if (scope === 'resource') { + entities = resources; + } else if (['context', 'action'].includes(scope)) { + entities = actions; + } else { + const pos = scope.lastIndexOf('::'); + entities = [scope.substring(0, pos)]; + } + return entities; +}; export const validateTextDocument = ( doc: vscode.TextDocument, @@ -178,6 +249,17 @@ export const validateSchemaDoc = ( // reset any errors for the schema from a previous validateSchema diagnosticCollection.delete(schemaDoc.uri); + // determine applicable principal and resource types + const principalTypes = determineEntityTypes(schemaDoc, 'principal'); + const resourceTypes = determineEntityTypes(schemaDoc, 'resource'); + const actionIds = determineEntityTypes(schemaDoc, 'action'); + validationCache.storeEntityTypes( + schemaDoc, + principalTypes, + resourceTypes, + actionIds + ); + // revalidate any Cedar files using this schema validationCache.revalidateSchema(schemaDoc, diagnosticCollection); } @@ -188,6 +270,38 @@ export const validateSchemaDoc = ( return success; }; +// TODO: find a real API to call for determineEntityTypes +// parsing errors from intentionally invalid policy is an ugly hack +const UNEXPECTED_REGEX = + /Unexpected type. Expected {"type":"Boolean"} but saw {"type":"Entity","name":"(?.+)"}/; +const ATTRIBUTE_REGEX = + /attribute `__vscode__` in context for (?.+) not found/; +export const determineEntityTypes = ( + schemaDoc: vscode.TextDocument, + scope: 'principal' | 'resource' | 'action' +): string[] => { + const types: string[] = []; + const expr = scope === 'action' ? 'context.__vscode__' : scope; + const policyResult: cedar.ValidatePolicyResult = cedar.validatePolicy( + schemaDoc.getText(), + `permit (principal, action, resource) when { ${expr} };` + ); + if (policyResult.success === false && policyResult.errors) { + policyResult.errors.forEach((e) => { + let found = + scope === 'action' + ? e.match(ATTRIBUTE_REGEX) + : e.match(UNEXPECTED_REGEX); + + if (found?.groups && found?.groups.suggestion) { + types.push(found.groups.suggestion); + } + }); + } + policyResult.free(); + return types; +}; + export const validateEntitiesDoc = async ( entitiesDoc: vscode.TextDocument, diagnosticCollection: vscode.DiagnosticCollection, diff --git a/syntaxes/cedar.tmLanguage.json b/syntaxes/cedar.tmLanguage.json index 975f03a..aadcd68 100644 --- a/syntaxes/cedar.tmLanguage.json +++ b/syntaxes/cedar.tmLanguage.json @@ -43,6 +43,9 @@ }, { "include": "#entities" + }, + { + "include": "#namespacedTypes" } ], "repository": { @@ -63,13 +66,14 @@ "sections": { "patterns": [{ "name": "keyword.cedar", - "match": "\\b(permit|forbid|when|unless)\\b" + "match": "\\b(? String { + std::env!("CEDAR_VERSION").to_string() } \ No newline at end of file