From 0a6f06637d5a6890eb3e8453efbc3eb7a5ed439e Mon Sep 17 00:00:00 2001 From: Daniel Jeffery Date: Tue, 24 Oct 2023 16:37:13 -0700 Subject: [PATCH] feat: YAML type validation --- README.md | 1 + client/src/extension.browser.ts | 2 +- client/src/extension.node.ts | 2 +- package.json | 21 +- server/package-lock.json | 118 +++++- server/package.json | 8 +- server/src/openfga-yaml-schema.ts | 163 ++++++++ server/src/server.common.ts | 124 ++++-- server/src/yaml-schema.ts | 12 - server/src/yaml-utils.ts | 61 +++ syntaxes/openfga.injection.json | 2 +- syntaxes/yaml.tmLanguage.json | 621 ++++++++++++++++++++++++++++++ 12 files changed, 1071 insertions(+), 64 deletions(-) create mode 100644 server/src/openfga-yaml-schema.ts delete mode 100644 server/src/yaml-schema.ts create mode 100644 server/src/yaml-utils.ts create mode 100644 syntaxes/yaml.tmLanguage.json diff --git a/README.md b/README.md index 6720438..9848c90 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ See the [DEVELOPMENT](./docs/DEVELOPMENT.md) and [CONTRIBUTING](https://github.c ## Acknowledgments - CEL Textmate Grammar was taken from [vscode-cel](https://github.com/hmarr/vscode-cel) +- Range conversion from `yaml` to `vscode` from [actions/languageservices](https://github.com/actions/languageservices/blob/4280a967a8aa058dd3c8825349b90bc932d82283/workflow-parser/src/workflows/yaml-object-reader.ts#L220) ## License diff --git a/client/src/extension.browser.ts b/client/src/extension.browser.ts index 2cde4e1..627ed03 100644 --- a/client/src/extension.browser.ts +++ b/client/src/extension.browser.ts @@ -11,7 +11,7 @@ let client: LanguageClient; export function activate(context: ExtensionContext) { // Register the server for all document types - const documentSelector = [{ language: "openfga" }, { language: "yaml" }]; + const documentSelector = [{ language: "openfga" }, { language: "yaml-store-openfga" }]; // Options to control the language client const clientOptions: LanguageClientOptions = { diff --git a/client/src/extension.node.ts b/client/src/extension.node.ts index 8065198..d83a3a6 100644 --- a/client/src/extension.node.ts +++ b/client/src/extension.node.ts @@ -24,7 +24,7 @@ export function activate(context: ExtensionContext) { // Options to control the language client const clientOptions: LanguageClientOptions = { // Register the server for all document types - documentSelector: [{ language: "openfga" }, { language: "yaml" }], + documentSelector: [{ language: "openfga" }, { language: "yaml-store-openfga" }], synchronize: { // Notify the server about file changes to '.clientrc files contained in the workspace fileEvents: workspace.createFileSystemWatcher("**/.clientrc") diff --git a/package.json b/package.json index 14c3138..4a6bad9 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,16 @@ "CEL", "cel" ] + }, + { + "id": "yaml-store-openfga", + "aliases": [ + "YAML (OpenFGA)" + ], + "extensions": [ + ".fga.yaml", + ".openfga.yaml" + ] } ], "snippets": [ @@ -101,12 +111,17 @@ "path": "./syntaxes/openfga.injection.json", "scopeName": "openfga-yaml.injection", "injectTo": [ - "source.yaml" + "source.yaml-store-openfga" ], "embeddedLanguages": { "meta.embedded.inline.openfga": "openfga" } - } + }, + { + "language": "yaml-store-openfga", + "scopeName": "source.yaml-store-openfga", + "path": "./syntaxes/yaml.tmLanguage.json" + } ], "themes": [ { @@ -132,7 +147,7 @@ }, "activationEvents": [ "onLanguage:openfga", - "onLanguage:yaml" + "onLanguage:yaml-store-openfga" ], "scripts": { "vscode:prepublish": "npm run compile", diff --git a/server/package-lock.json b/server/package-lock.json index 5ecaa0b..825fbea 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -10,9 +10,13 @@ "license": "Apache-2.0", "dependencies": { "@openfga/syntax-transformer": "^0.2.0-beta.4", + "ajv": "^8.12.0", "vscode-languageserver": "^8.1.0", "vscode-languageserver-textdocument": "^1.0.11", - "yaml": "^2.3.2" + "yaml": "^2.3.3" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.8" }, "engines": { "node": "*" @@ -39,6 +43,27 @@ "antlr4": "^4.13.1" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.8.tgz", + "integrity": "sha512-m6jnPk1VhlYRiLFm3f8X9Uep761f+CK8mHyS65LutH2OhmBF0BeMEjHgg05usH8PLZMWWc/BUR9RPmkvpWnyRA==", + "dev": true + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/antlr4": { "version": "4.13.1", "resolved": "https://registry.npmjs.org/antlr4/-/antlr4-4.13.1.tgz", @@ -80,6 +105,11 @@ "node": ">=0.4.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, "node_modules/follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", @@ -112,6 +142,11 @@ "node": ">= 6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -131,11 +166,35 @@ "node": ">= 0.6" } }, + "node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tiny-async-pool": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-2.1.0.tgz", "integrity": "sha512-ltAHPh/9k0STRQqaoUX52NH4ZQYAJz24ZAEwf1Zm+HYg3l9OXTWeqWKyYsHu40wF/F0rxd2N2bk5sLvX2qlSvg==" }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vscode-jsonrpc": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.1.0.tgz", @@ -175,9 +234,9 @@ "integrity": "sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==" }, "node_modules/yaml": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", - "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.3.tgz", + "integrity": "sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==", "engines": { "node": ">= 14" } @@ -202,6 +261,23 @@ "antlr4": "^4.13.1" } }, + "@types/js-yaml": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.8.tgz", + "integrity": "sha512-m6jnPk1VhlYRiLFm3f8X9Uep761f+CK8mHyS65LutH2OhmBF0BeMEjHgg05usH8PLZMWWc/BUR9RPmkvpWnyRA==", + "dev": true + }, + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, "antlr4": { "version": "4.13.1", "resolved": "https://registry.npmjs.org/antlr4/-/antlr4-4.13.1.tgz", @@ -234,6 +310,11 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, "follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", @@ -249,6 +330,11 @@ "mime-types": "^2.1.12" } }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -262,11 +348,29 @@ "mime-db": "1.52.0" } }, + "punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, "tiny-async-pool": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-2.1.0.tgz", "integrity": "sha512-ltAHPh/9k0STRQqaoUX52NH4ZQYAJz24ZAEwf1Zm+HYg3l9OXTWeqWKyYsHu40wF/F0rxd2N2bk5sLvX2qlSvg==" }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "requires": { + "punycode": "^2.1.0" + } + }, "vscode-jsonrpc": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.1.0.tgz", @@ -300,9 +404,9 @@ "integrity": "sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==" }, "yaml": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.2.tgz", - "integrity": "sha512-N/lyzTPaJasoDmfV7YTrYCI0G/3ivm/9wdG0aHuheKowWQwGTsK0Eoiw6utmzAnI6pkJa0DUVygvp3spqqEKXg==" + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.3.tgz", + "integrity": "sha512-zw0VAJxgeZ6+++/su5AFoqBbZbrEakwu+X0M5HmcwUiBL7AzcuPKjj5we4xfQLp78LkEMpD0cOnUhmgOVy3KdQ==" } } } diff --git a/server/package.json b/server/package.json index 5a4a119..a60a4cc 100644 --- a/server/package.json +++ b/server/package.json @@ -13,9 +13,13 @@ }, "dependencies": { "@openfga/syntax-transformer": "^0.2.0-beta.4", + "ajv": "^8.12.0", "vscode-languageserver": "^8.1.0", "vscode-languageserver-textdocument": "^1.0.11", - "yaml": "^2.3.2" + "yaml": "^2.3.3" }, - "scripts": {} + "scripts": {}, + "devDependencies": { + "@types/js-yaml": "^4.0.8" + } } diff --git a/server/src/openfga-yaml-schema.ts b/server/src/openfga-yaml-schema.ts new file mode 100644 index 0000000..21756c7 --- /dev/null +++ b/server/src/openfga-yaml-schema.ts @@ -0,0 +1,163 @@ + +export const OPENFGA_YAML_SCHEMA = { + type: "object", + properties: { + name: { + type: "string", + description: "The store name" + }, + model_file: { + type: "string", + description: "The Authorization Model" + }, + model: { + type: "string", + description: "The Authorization Model" + }, + tuples_file: { + type: "string" + }, + tuples: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + user: { + type: "string", + description: "The user" + }, + relation: { + type: "string", + description: "The relation" + }, + object: { + type: "string", + description: "The object" + }, + condition: { + type: "object", + additionalProperties: false, + properties: { + name: { + type: "string", + }, + context: { + type: "object" + } + } + } + } + } + }, + tests: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + name: { + type: "string", + description: "The test name" + }, + description: { + type: "string", + description: "The test description" + }, + tuples_file: { + type: "string" + }, + tuples: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + user: { + type: "string", + description: "The user" + }, + relation: { + type: "string", + description: "The relation" + }, + object: { + type: "string", + description: "The object" + }, + context: { + type: "object" + } + } + } + }, + check: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + user: { + type: "string", + description: "The user" + }, + object: { + type: "string", + description: "The object" + }, + assertions: { + type: "object", + patternProperties: { + ".*": { + type: "boolean" + } + } + }, + context: { + type: "object" + } + } + } + }, + list_objects: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + user: { + type: "string", + description: "The user" + }, + relation: { + type: "string", + description: "The relation" + }, + type: { + type: "string", + description: "The object type" + }, + assertions: { + type: "object", + patternProperties: { + ".*": { + type: "array", + items: { + type: "string" + } + } + } + }, + context: { + type: "object" + } + } + } + } + } + } + } + }, + required: ["tests"], + additionalProperties: false, +}; \ No newline at end of file diff --git a/server/src/server.common.ts b/server/src/server.common.ts index 293510e..52bf7ed 100644 --- a/server/src/server.common.ts +++ b/server/src/server.common.ts @@ -4,7 +4,6 @@ import { Diagnostic, DiagnosticSeverity, InitializeParams, - DidChangeConfigurationNotification, CompletionItem, TextDocumentPositionParams, TextDocumentSyncKind, @@ -30,8 +29,10 @@ import { validator, errors } from "@openfga/syntax-transformer"; import { defaultDocumentationMap } from "./documentation"; import { getDuplicationFix, getMissingDefinitionFix, getReservedTypeNameFix } from "./code-action"; import { LineCounter, parseDocument } from "yaml"; -import { rangeFromLinePos } from "./yaml-schema"; import { BlockMap, SourceToken } from "yaml/dist/parse/cst"; +import { YAMLSourceMap, rangeFromLinePos } from "./yaml-utils"; +import Ajv, { ErrorObject, ValidateFunction } from "ajv"; +import { OPENFGA_YAML_SCHEMA } from "./openfga-yaml-schema"; export function startServer(connection: _Connection) { @@ -45,6 +46,7 @@ export function startServer(connection: _Connection) { let hasWorkspaceFolderCapability = false; let hasDiagnosticRelatedInformationCapability = false; + let schemaValidator: ValidateFunction; connection.onInitialize((params: InitializeParams) => { @@ -94,10 +96,10 @@ export function startServer(connection: _Connection) { connection.onInitialized(() => { - if (hasConfigurationCapability) { - // Register for all configuration changes. - connection.client.register(DidChangeConfigurationNotification.type, undefined); - } + + // Once initialized, setup validator + schemaValidator = new Ajv().compile(OPENFGA_YAML_SCHEMA); + if (hasWorkspaceFolderCapability) { connection.workspace.onDidChangeWorkspaceFolders(_event => { connection.console.log("Workspace folder change event received."); @@ -181,58 +183,106 @@ export function startServer(connection: _Connection) { connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); } - function validateYAML(textDocument: TextDocument): void { - connection.sendDiagnostics({ - uri: textDocument.uri, - diagnostics: [], - }); - + function validateYamlSyntaxAndModel(textDocument: TextDocument): Diagnostic[] { const diagnostics: Diagnostic[] = []; const lineCounter = new LineCounter(); - const doc = parseDocument(textDocument.getText(), { + const yamlDoc = parseDocument(textDocument.getText(), { lineCounter, keepSourceTokens: true, - uniqueKeys: false }); + const map = new YAMLSourceMap(); + map.doMap(yamlDoc.contents); + // Basic syntax errors - for (const err of doc.errors) { + for (const err of yamlDoc.errors) { diagnostics.push({ message: err.message, range: rangeFromLinePos(err.linePos) }); } - // Get location of model in CST - if (doc.has("model")) { - let position: {line: number, col: number}; - - // Get the model token and find its position - (doc.contents?.srcToken as BlockMap).items.forEach(i => { - if (i.key?.offset !== undefined && (i.key as SourceToken).source === "model") { - position = lineCounter.linePos(i.key?.offset); + // If no diagnostics, continue parsing. + if (!diagnostics.length && !schemaValidator(yamlDoc.toJSON())) { + schemaValidator.errors?.forEach((e: ErrorObject) => { + console.error(JSON.stringify(e as ErrorObject, null, 2)); + + let start = { line: 0, character: 0 }; + let end = { line: 0, character: 0 }; + let message; + + if (e.keyword === "additionalProperties") { + // If we've got invalid keys, mark them + let key = e.params["additionalProperty"]; + if (e.instancePath) { + const path = e.instancePath.substring(1).replace(/\//g, "."); + key = path.concat(".", key); + } + const range = map.nodes.get(key); + if (range) { + start = textDocument.positionAt(range?.[0]); + end = textDocument.positionAt(range?.[1]); + } + message = key + " is not a recognized key."; + } else { + // All other schema errors + const key = e.instancePath.substring(1).replace(/\//g, "."); + const range = map.nodes.get(key); + if (range) { + start = textDocument.positionAt(range?.[0]); + end = textDocument.positionAt(range?.[1]); + } + message = key + " " + e.message; } + diagnostics.push({ message: message, range: { start, end } }); }); + } - // Shift generated diagnostics by line of model, and indent of 2 - let dslDiagnostics = getDiagnosticsForDsl(doc.get("model") as string); - dslDiagnostics = dslDiagnostics.map(d => { - const r = d.range; - r.start.line += position.line; - r.start.character += 2; - r.end.line += position.line; - r.end.character += 2; - return d; - }); - diagnostics.push(...dslDiagnostics); + // Finally validate openfga model + if (!diagnostics.length) { + // Get location of model in CST + if (yamlDoc.has("model")) { + let position: { line: number, col: number }; + + // Get the model token and find its position + (yamlDoc.contents?.srcToken as BlockMap).items.forEach(i => { + if (i.key?.offset !== undefined && (i.key as SourceToken).source === "model") { + position = lineCounter.linePos(i.key?.offset); + } + }); + + // Shift generated diagnostics by line of model, and indent of 2 + let dslDiagnostics = getDiagnosticsForDsl(yamlDoc.get("model") as string); + dslDiagnostics = dslDiagnostics.map(d => { + const r = d.range; + r.start.line += position.line; + r.start.character += 2; + r.end.line += position.line; + r.end.character += 2; + return d; + }); + diagnostics.push(...dslDiagnostics); + } } - connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); + return diagnostics; } + function validateYAML(textDocument: TextDocument): void { + connection.sendDiagnostics({ + uri: textDocument.uri, + diagnostics: [], + }); + + const diagnostics: Diagnostic[] = []; + + diagnostics.push(...validateYamlSyntaxAndModel(textDocument)); + + connection.sendDiagnostics({ uri: textDocument.uri, diagnostics }); + } function validateTextDocument(textDocument: TextDocument): void { if (textDocument.languageId === "openfga") { validateDSL(textDocument); - } else if (textDocument.languageId === "yaml") { + } else if (textDocument.languageId === "yaml-store-openfga") { validateYAML(textDocument); } } @@ -356,4 +406,4 @@ export function startServer(connection: _Connection) { // Listen on the connection connection.listen(); -} \ No newline at end of file +} diff --git a/server/src/yaml-schema.ts b/server/src/yaml-schema.ts deleted file mode 100644 index 57ac311..0000000 --- a/server/src/yaml-schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Range, Position } from "vscode-languageserver"; -import { LinePos } from "yaml/dist/errors"; - -export function rangeFromLinePos(linePos: [LinePos] | [LinePos, LinePos] | undefined): Range { - if (linePos === undefined) { - return { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; - } - // TokenRange and linePos are both 1-based - const start: Position = { line: linePos[0].line - 1, character: linePos[0].col - 1 }; - const end: Position = linePos.length == 2 ? { line: linePos[1].line - 1, character: linePos[1].col - 1 } : start; - return { start, end }; -} \ No newline at end of file diff --git a/server/src/yaml-utils.ts b/server/src/yaml-utils.ts new file mode 100644 index 0000000..0a557b4 --- /dev/null +++ b/server/src/yaml-utils.ts @@ -0,0 +1,61 @@ +import { Range, Position } from "vscode-languageserver"; + +import { Range as TokenRange, isCollection, isDocument, isMap, isNode, isPair, isScalar, isSeq } from "yaml"; +import { LinePos } from "yaml/dist/errors"; + +export function rangeFromLinePos(linePos: [LinePos] | [LinePos, LinePos] | undefined): Range { + if (linePos === undefined) { + return { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }; + } + // TokenRange and linePos are both 1-based + const start: Position = { line: linePos[0].line - 1, character: linePos[0].col - 1 }; + const end: Position = linePos.length == 2 ? { line: linePos[1].line - 1, character: linePos[1].col - 1 } : start; + return { start, end }; +} + +export class YAMLSourceMap { + public nodes; + + constructor() { + this.nodes = new Map(); + } + + public doMap(node: any | null, path: string[] = []) { + + const localPath = [...path]; + + if (node === null) { + return; + } + + if (isMap(node)) { + for (const n of node.items) { + this.doMap(n, localPath); + } + return; + } + + if (isPair(node) && isScalar(node.key) && node.key.source) { + localPath.push(node.key.source); + this.doMap(node.key, localPath); + + if (isSeq(node.value)) { + for (const n in node.value.items) { + localPath.push(n); + this.doMap(node.value.items[n], localPath); + localPath.pop(); + } + } else if (isMap(node.value)) { + for (const n of node.value.items) { + this.doMap(n, localPath); + } + } + return; + } + + if (isScalar(node) && node.source && node.range) { + this.nodes.set(localPath.join("."), node.range); + return; + } + } +} diff --git a/syntaxes/openfga.injection.json b/syntaxes/openfga.injection.json index 1e85d33..cd01314 100644 --- a/syntaxes/openfga.injection.json +++ b/syntaxes/openfga.injection.json @@ -1,6 +1,6 @@ { "scopeName": "openfga-yaml.injection", - "injectionSelector": "L:source.yaml", + "injectionSelector": "L:source.yaml-store-openfga", "patterns": [ { "include": "#openfga-block-scalar" diff --git a/syntaxes/yaml.tmLanguage.json b/syntaxes/yaml.tmLanguage.json new file mode 100644 index 0000000..d707b57 --- /dev/null +++ b/syntaxes/yaml.tmLanguage.json @@ -0,0 +1,621 @@ +{ + "information_for_contributors": [ + "This file has been converted from https://github.com/textmate/yaml.tmbundle/blob/master/Syntaxes/YAML.tmLanguage", + "If you want to provide a fix or improvement, please create a pull request against the original repository.", + "Once accepted there, we are happy to receive an update request." + ], + "version": "https://github.com/textmate/yaml.tmbundle/commit/e54ceae3b719506dba7e481a77cea4a8b576ae46", + "name": "YAML (OpenFGA)", + "scopeName": "source.yaml-store-openfga", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#property" + }, + { + "include": "#directive" + }, + { + "match": "^---", + "name": "entity.other.document.begin.yaml" + }, + { + "match": "^\\.{3}", + "name": "entity.other.document.end.yaml" + }, + { + "include": "#node" + } + ], + "repository": { + "block-collection": { + "patterns": [ + { + "include": "#block-sequence" + }, + { + "include": "#block-mapping" + } + ] + }, + "block-mapping": { + "patterns": [ + { + "include": "#block-pair" + } + ] + }, + "block-node": { + "patterns": [ + { + "include": "#prototype" + }, + { + "include": "#block-scalar" + }, + { + "include": "#block-collection" + }, + { + "include": "#flow-scalar-plain-out" + }, + { + "include": "#flow-node" + } + ] + }, + "block-pair": { + "patterns": [ + { + "begin": "\\?", + "beginCaptures": { + "1": { + "name": "punctuation.definition.key-value.begin.yaml" + } + }, + "end": "(?=\\?)|^ *(:)|(:)", + "endCaptures": { + "1": { + "name": "punctuation.separator.key-value.mapping.yaml" + }, + "2": { + "name": "invalid.illegal.expected-newline.yaml" + } + }, + "name": "meta.block-mapping.yaml", + "patterns": [ + { + "include": "#block-node" + } + ] + }, + { + "begin": "(?x)\n (?=\n (?x:\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] \\S\n )\n (\n [^\\s:]\n | : \\S\n | \\s+ (?![#\\s])\n )*\n \\s*\n :\n\t\t\t\t\t\t\t(\\s|$)\n )\n ", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n )\n ", + "patterns": [ + { + "include": "#flow-scalar-plain-out-implicit-type" + }, + { + "begin": "(?x)\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] \\S\n ", + "beginCaptures": { + "0": { + "name": "entity.name.tag.yaml" + } + }, + "contentName": "entity.name.tag.yaml", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n )\n ", + "name": "string.unquoted.plain.out.yaml" + } + ] + }, + { + "match": ":(?=\\s|$)", + "name": "punctuation.separator.key-value.mapping.yaml" + } + ] + }, + "block-scalar": { + "begin": "(?:(\\|)|(>))([1-9])?([-+])?(.*\\n?)", + "beginCaptures": { + "1": { + "name": "keyword.control.flow.block-scalar.literal.yaml" + }, + "2": { + "name": "keyword.control.flow.block-scalar.folded.yaml" + }, + "3": { + "name": "constant.numeric.indentation-indicator.yaml" + }, + "4": { + "name": "storage.modifier.chomping-indicator.yaml" + }, + "5": { + "patterns": [ + { + "include": "#comment" + }, + { + "match": ".+", + "name": "invalid.illegal.expected-comment-or-newline.yaml" + } + ] + } + }, + "end": "^(?=\\S)|(?!\\G)", + "patterns": [ + { + "begin": "^([ ]+)(?! )", + "end": "^(?!\\1|\\s*$)", + "name": "string.unquoted.block.yaml" + } + ] + }, + "block-sequence": { + "match": "(-)(?!\\S)", + "name": "punctuation.definition.block.sequence.item.yaml" + }, + "comment": { + "begin": "(?:(^[ \\t]*)|[ \\t]+)(?=#\\p{Print}*$)", + "beginCaptures": { + "1": { + "name": "punctuation.whitespace.comment.leading.yaml" + } + }, + "end": "(?!\\G)", + "patterns": [ + { + "begin": "#", + "beginCaptures": { + "0": { + "name": "punctuation.definition.comment.yaml" + } + }, + "end": "\\n", + "name": "comment.line.number-sign.yaml" + } + ] + }, + "directive": { + "begin": "^%", + "beginCaptures": { + "0": { + "name": "punctuation.definition.directive.begin.yaml" + } + }, + "end": "(?=$|[ \\t]+($|#))", + "name": "meta.directive.yaml", + "patterns": [ + { + "captures": { + "1": { + "name": "keyword.other.directive.yaml.yaml" + }, + "2": { + "name": "constant.numeric.yaml-version.yaml" + } + }, + "match": "\\G(YAML)[ \\t]+(\\d+\\.\\d+)" + }, + { + "captures": { + "1": { + "name": "keyword.other.directive.tag.yaml" + }, + "2": { + "name": "storage.type.tag-handle.yaml" + }, + "3": { + "name": "support.type.tag-prefix.yaml" + } + }, + "match": "(?x)\n \\G\n (TAG)\n (?:[ \\t]+\n ((?:!(?:[0-9A-Za-z\\-]*!)?))\n (?:[ \\t]+ (\n ! (?x: %[0-9A-Fa-f]{2} | [0-9A-Za-z\\-#;/?:@&=+$,_.!~*'()\\[\\]] )*\n | (?![,!\\[\\]{}]) (?x: %[0-9A-Fa-f]{2} | [0-9A-Za-z\\-#;/?:@&=+$,_.!~*'()\\[\\]] )+\n )\n )?\n )?\n " + }, + { + "captures": { + "1": { + "name": "support.other.directive.reserved.yaml" + }, + "2": { + "name": "string.unquoted.directive-name.yaml" + }, + "3": { + "name": "string.unquoted.directive-parameter.yaml" + } + }, + "match": "(?x) \\G (\\w+) (?:[ \\t]+ (\\w+) (?:[ \\t]+ (\\w+))? )?" + }, + { + "match": "\\S+", + "name": "invalid.illegal.unrecognized.yaml" + } + ] + }, + "flow-alias": { + "captures": { + "1": { + "name": "keyword.control.flow.alias.yaml" + }, + "2": { + "name": "punctuation.definition.alias.yaml" + }, + "3": { + "name": "variable.other.alias.yaml" + }, + "4": { + "name": "invalid.illegal.character.anchor.yaml" + } + }, + "match": "((\\*))([^\\s\\[\\]/{/},]+)([^\\s\\]},]\\S*)?" + }, + "flow-collection": { + "patterns": [ + { + "include": "#flow-sequence" + }, + { + "include": "#flow-mapping" + } + ] + }, + "flow-mapping": { + "begin": "\\{", + "beginCaptures": { + "0": { + "name": "punctuation.definition.mapping.begin.yaml" + } + }, + "end": "\\}", + "endCaptures": { + "0": { + "name": "punctuation.definition.mapping.end.yaml" + } + }, + "name": "meta.flow-mapping.yaml", + "patterns": [ + { + "include": "#prototype" + }, + { + "match": ",", + "name": "punctuation.separator.mapping.yaml" + }, + { + "include": "#flow-pair" + } + ] + }, + "flow-node": { + "patterns": [ + { + "include": "#prototype" + }, + { + "include": "#flow-alias" + }, + { + "include": "#flow-collection" + }, + { + "include": "#flow-scalar" + } + ] + }, + "flow-pair": { + "patterns": [ + { + "begin": "\\?", + "beginCaptures": { + "0": { + "name": "punctuation.definition.key-value.begin.yaml" + } + }, + "end": "(?=[},\\]])", + "name": "meta.flow-pair.explicit.yaml", + "patterns": [ + { + "include": "#prototype" + }, + { + "include": "#flow-pair" + }, + { + "include": "#flow-node" + }, + { + "begin": ":(?=\\s|$|[\\[\\]{},])", + "beginCaptures": { + "0": { + "name": "punctuation.separator.key-value.mapping.yaml" + } + }, + "end": "(?=[},\\]])", + "patterns": [ + { + "include": "#flow-value" + } + ] + } + ] + }, + { + "begin": "(?x)\n (?=\n (?:\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] [^\\s[\\[\\]{},]]\n )\n (\n [^\\s:[\\[\\]{},]]\n | : [^\\s[\\[\\]{},]]\n | \\s+ (?![#\\s])\n )*\n \\s*\n :\n\t\t\t\t\t\t\t(\\s|$)\n )\n ", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n | \\s* : [\\[\\]{},]\n | \\s* [\\[\\]{},]\n )\n ", + "name": "meta.flow-pair.key.yaml", + "patterns": [ + { + "include": "#flow-scalar-plain-in-implicit-type" + }, + { + "begin": "(?x)\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] [^\\s[\\[\\]{},]]\n ", + "beginCaptures": { + "0": { + "name": "entity.name.tag.yaml" + } + }, + "contentName": "entity.name.tag.yaml", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n | \\s* : [\\[\\]{},]\n | \\s* [\\[\\]{},]\n )\n ", + "name": "string.unquoted.plain.in.yaml" + } + ] + }, + { + "include": "#flow-node" + }, + { + "begin": ":(?=\\s|$|[\\[\\]{},])", + "captures": { + "0": { + "name": "punctuation.separator.key-value.mapping.yaml" + } + }, + "end": "(?=[},\\]])", + "name": "meta.flow-pair.yaml", + "patterns": [ + { + "include": "#flow-value" + } + ] + } + ] + }, + "flow-scalar": { + "patterns": [ + { + "include": "#flow-scalar-double-quoted" + }, + { + "include": "#flow-scalar-single-quoted" + }, + { + "include": "#flow-scalar-plain-in" + } + ] + }, + "flow-scalar-double-quoted": { + "begin": "\"", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.yaml" + } + }, + "end": "\"", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.yaml" + } + }, + "name": "string.quoted.double.yaml", + "patterns": [ + { + "match": "\\\\([0abtnvfre \"/\\\\N_Lp]|x\\d\\d|u\\d{4}|U\\d{8})", + "name": "constant.character.escape.yaml" + }, + { + "match": "\\\\\\n", + "name": "constant.character.escape.double-quoted.newline.yaml" + } + ] + }, + "flow-scalar-plain-in": { + "patterns": [ + { + "include": "#flow-scalar-plain-in-implicit-type" + }, + { + "begin": "(?x)\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] [^\\s[\\[\\]{},]]\n ", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n | \\s* : [\\[\\]{},]\n | \\s* [\\[\\]{},]\n )\n ", + "name": "string.unquoted.plain.in.yaml" + } + ] + }, + "flow-scalar-plain-in-implicit-type": { + "patterns": [ + { + "captures": { + "1": { + "name": "constant.language.null.yaml" + }, + "2": { + "name": "constant.language.boolean.yaml" + }, + "3": { + "name": "constant.numeric.integer.yaml" + }, + "4": { + "name": "constant.numeric.float.yaml" + }, + "5": { + "name": "constant.other.timestamp.yaml" + }, + "6": { + "name": "constant.language.value.yaml" + }, + "7": { + "name": "constant.language.merge.yaml" + } + }, + "match": "(?x)\n (?x:\n (null|Null|NULL|~)\n | (y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF)\n | (\n (?:\n [-+]? 0b [0-1_]+ # (base 2)\n | [-+]? 0 [0-7_]+ # (base 8)\n | [-+]? (?: 0|[1-9][0-9_]*) # (base 10)\n | [-+]? 0x [0-9a-fA-F_]+ # (base 16)\n | [-+]? [1-9] [0-9_]* (?: :[0-5]?[0-9])+ # (base 60)\n )\n )\n | (\n (?x:\n [-+]? (?: [0-9] [0-9_]*)? \\. [0-9.]* (?: [eE] [-+] [0-9]+)? # (base 10)\n | [-+]? [0-9] [0-9_]* (?: :[0-5]?[0-9])+ \\. [0-9_]* # (base 60)\n | [-+]? \\. (?: inf|Inf|INF) # (infinity)\n | \\. (?: nan|NaN|NAN) # (not a number)\n )\n )\n | (\n (?x:\n \\d{4} - \\d{2} - \\d{2} # (y-m-d)\n | \\d{4} # (year)\n - \\d{1,2} # (month)\n - \\d{1,2} # (day)\n (?: [Tt] | [ \\t]+) \\d{1,2} # (hour)\n : \\d{2} # (minute)\n : \\d{2} # (second)\n (?: \\.\\d*)? # (fraction)\n (?:\n (?:[ \\t]*) Z\n | [-+] \\d{1,2} (?: :\\d{1,2})?\n )? # (time zone)\n )\n )\n | (=)\n | (<<)\n )\n (?:\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n | \\s* : [\\[\\]{},]\n | \\s* [\\[\\]{},]\n )\n )\n " + } + ] + }, + "flow-scalar-plain-out": { + "patterns": [ + { + "include": "#flow-scalar-plain-out-implicit-type" + }, + { + "begin": "(?x)\n [^\\s[-?:,\\[\\]{}#&*!|>'\"%@`]]\n | [?:-] \\S\n ", + "end": "(?x)\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n )\n ", + "name": "string.unquoted.plain.out.yaml" + } + ] + }, + "flow-scalar-plain-out-implicit-type": { + "patterns": [ + { + "captures": { + "1": { + "name": "constant.language.null.yaml" + }, + "2": { + "name": "constant.language.boolean.yaml" + }, + "3": { + "name": "constant.numeric.integer.yaml" + }, + "4": { + "name": "constant.numeric.float.yaml" + }, + "5": { + "name": "constant.other.timestamp.yaml" + }, + "6": { + "name": "constant.language.value.yaml" + }, + "7": { + "name": "constant.language.merge.yaml" + } + }, + "match": "(?x)\n (?x:\n (null|Null|NULL|~)\n | (y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|on|On|ON|off|Off|OFF)\n | (\n (?:\n [-+]? 0b [0-1_]+ # (base 2)\n | [-+]? 0 [0-7_]+ # (base 8)\n | [-+]? (?: 0|[1-9][0-9_]*) # (base 10)\n | [-+]? 0x [0-9a-fA-F_]+ # (base 16)\n | [-+]? [1-9] [0-9_]* (?: :[0-5]?[0-9])+ # (base 60)\n )\n )\n | (\n (?x:\n [-+]? (?: [0-9] [0-9_]*)? \\. [0-9.]* (?: [eE] [-+] [0-9]+)? # (base 10)\n | [-+]? [0-9] [0-9_]* (?: :[0-5]?[0-9])+ \\. [0-9_]* # (base 60)\n | [-+]? \\. (?: inf|Inf|INF) # (infinity)\n | \\. (?: nan|NaN|NAN) # (not a number)\n )\n )\n | (\n (?x:\n \\d{4} - \\d{2} - \\d{2} # (y-m-d)\n | \\d{4} # (year)\n - \\d{1,2} # (month)\n - \\d{1,2} # (day)\n (?: [Tt] | [ \\t]+) \\d{1,2} # (hour)\n : \\d{2} # (minute)\n : \\d{2} # (second)\n (?: \\.\\d*)? # (fraction)\n (?:\n (?:[ \\t]*) Z\n | [-+] \\d{1,2} (?: :\\d{1,2})?\n )? # (time zone)\n )\n )\n | (=)\n | (<<)\n )\n (?x:\n (?=\n \\s* $\n | \\s+ \\#\n | \\s* : (\\s|$)\n )\n )\n " + } + ] + }, + "flow-scalar-single-quoted": { + "begin": "'", + "beginCaptures": { + "0": { + "name": "punctuation.definition.string.begin.yaml" + } + }, + "end": "'(?!')", + "endCaptures": { + "0": { + "name": "punctuation.definition.string.end.yaml" + } + }, + "name": "string.quoted.single.yaml", + "patterns": [ + { + "match": "''", + "name": "constant.character.escape.single-quoted.yaml" + } + ] + }, + "flow-sequence": { + "begin": "\\[", + "beginCaptures": { + "0": { + "name": "punctuation.definition.sequence.begin.yaml" + } + }, + "end": "\\]", + "endCaptures": { + "0": { + "name": "punctuation.definition.sequence.end.yaml" + } + }, + "name": "meta.flow-sequence.yaml", + "patterns": [ + { + "include": "#prototype" + }, + { + "match": ",", + "name": "punctuation.separator.sequence.yaml" + }, + { + "include": "#flow-pair" + }, + { + "include": "#flow-node" + } + ] + }, + "flow-value": { + "patterns": [ + { + "begin": "\\G(?![},\\]])", + "end": "(?=[},\\]])", + "name": "meta.flow-pair.value.yaml", + "patterns": [ + { + "include": "#flow-node" + } + ] + } + ] + }, + "node": { + "patterns": [ + { + "include": "#block-node" + } + ] + }, + "property": { + "begin": "(?=!|&)", + "end": "(?!\\G)", + "name": "meta.property.yaml", + "patterns": [ + { + "captures": { + "1": { + "name": "keyword.control.property.anchor.yaml" + }, + "2": { + "name": "punctuation.definition.anchor.yaml" + }, + "3": { + "name": "entity.name.type.anchor.yaml" + }, + "4": { + "name": "invalid.illegal.character.anchor.yaml" + } + }, + "match": "\\G((&))([^\\s\\[\\]/{/},]+)(\\S+)?" + }, + { + "match": "(?x)\n \\G\n (?:\n ! < (?: %[0-9A-Fa-f]{2} | [0-9A-Za-z\\-#;/?:@&=+$,_.!~*'()\\[\\]] )+ >\n | (?:!(?:[0-9A-Za-z\\-]*!)?) (?: %[0-9A-Fa-f]{2} | [0-9A-Za-z\\-#;/?:@&=+$_.~*'()] )+\n | !\n )\n (?=\\ |\\t|$)\n ", + "name": "storage.type.tag-handle.yaml" + }, + { + "match": "\\S+", + "name": "invalid.illegal.tag-handle.yaml" + } + ] + }, + "prototype": { + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#property" + } + ] + } + } +} \ No newline at end of file