From a83f4e916d8e6924730575a32b20569094f3fc21 Mon Sep 17 00:00:00 2001 From: Jason Siefken Date: Tue, 12 Dec 2023 15:26:26 -0500 Subject: [PATCH] Switch CodeMirror to get completions via the language server (#66) --- package-lock.json | 518 +++++++++++++++++- package.json | 6 +- packages/codemirror/src/CodeMirror.tsx | 495 ++--------------- .../codemirror/src/extensions/lsp/index.ts | 1 + .../codemirror/src/extensions/lsp/plugin.ts | 335 +++++++++++ .../codemirror/src/extensions/lsp/tooltip.css | 29 + .../lsp}/utils/init-message-connection.ts | 2 +- .../codemirror/src/extensions/lsp/worker.ts | 127 +++++ .../src/extensions/syntax-highlighting.ts | 60 ++ packages/codemirror/src/extensions/tab.ts | 24 + packages/codemirror/src/extensions/theme.ts | 39 ++ packages/codemirror/src/index.ts | 1 + packages/codemirror/src/test-main.tsx | 40 +- packages/codemirror/tsconfig.json | 2 +- packages/codemirror/vite.config.ts | 7 +- packages/doenetml-worker/tsconfig.json | 2 +- .../src/Viewer/renderers/codeEditor-ts.tsx | 5 +- .../methods/get-completion-items.ts | 8 + packages/lsp-tools/src/dev-site.tsx | 4 +- .../methods/element-at-offset.ts | 8 +- .../test/doenet-auto-complete.test.ts | 131 +++-- packages/lsp-tools/tsconfig.json | 2 +- packages/lsp/package.json | 27 + .../src}/features/completions.ts | 32 +- .../src}/features/document-symbols.ts | 10 +- .../src}/features/folding-ranges.ts | 20 +- .../src}/features/formatting.ts | 0 .../src}/features/hover.ts | 0 .../src}/features/validate.ts | 0 .../language-server => lsp/src}/globals.ts | 0 packages/lsp/src/index.ts | 124 +++++ packages/lsp/test/language-server.test.ts | 123 +++++ packages/lsp/test/tsconfig.json | 12 + .../lsp/test/utils/init-message-connection.ts | 101 ++++ packages/lsp/tsconfig.json | 18 + packages/lsp/vite.config.ts | 25 + packages/parser/src/dev-site.tsx | 4 +- packages/parser/src/extract-dast-errors.ts | 6 +- packages/parser/tsconfig.json | 2 +- packages/static-assets/tsconfig.json | 2 +- packages/ui-components/package.json | 1 + packages/ui-components/tsconfig.json | 2 +- packages/utils/package.json | 1 + packages/utils/tsconfig.json | 2 +- packages/virtual-keyboard/package.json | 1 + packages/virtual-keyboard/tsconfig.json | 2 +- packages/vscode-extension/package.json | 7 +- .../src/language-server/index.ts | 114 +--- .../test/language-server.test.ts | 44 +- tsconfig.build.json | 2 +- 50 files changed, 1835 insertions(+), 693 deletions(-) create mode 100644 packages/codemirror/src/extensions/lsp/index.ts create mode 100644 packages/codemirror/src/extensions/lsp/plugin.ts create mode 100644 packages/codemirror/src/extensions/lsp/tooltip.css rename packages/{vscode-extension/test => codemirror/src/extensions/lsp}/utils/init-message-connection.ts (98%) create mode 100644 packages/codemirror/src/extensions/lsp/worker.ts create mode 100644 packages/codemirror/src/extensions/syntax-highlighting.ts create mode 100644 packages/codemirror/src/extensions/tab.ts create mode 100644 packages/codemirror/src/extensions/theme.ts create mode 100644 packages/codemirror/src/index.ts create mode 100644 packages/lsp/package.json rename packages/{vscode-extension/src/language-server => lsp/src}/features/completions.ts (59%) rename packages/{vscode-extension/src/language-server => lsp/src}/features/document-symbols.ts (93%) rename packages/{vscode-extension/src/language-server => lsp/src}/features/folding-ranges.ts (68%) rename packages/{vscode-extension/src/language-server => lsp/src}/features/formatting.ts (100%) rename packages/{vscode-extension/src/language-server => lsp/src}/features/hover.ts (100%) rename packages/{vscode-extension/src/language-server => lsp/src}/features/validate.ts (100%) rename packages/{vscode-extension/src/language-server => lsp/src}/globals.ts (100%) create mode 100644 packages/lsp/src/index.ts create mode 100644 packages/lsp/test/language-server.test.ts create mode 100644 packages/lsp/test/tsconfig.json create mode 100644 packages/lsp/test/utils/init-message-connection.ts create mode 100644 packages/lsp/tsconfig.json create mode 100644 packages/lsp/vite.config.ts diff --git a/package-lock.json b/package-lock.json index faa25e037..d3f14995f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,10 @@ "./packages/doenetml", "./packages/*" ], + "dependencies": { + "@uiw/react-codemirror": "^4.21.21", + "micromark": "^4.0.0" + }, "devDependencies": { "@qualified/lsp-connection": "^0.3.0", "@qualified/vscode-jsonrpc-ww": "^0.3.0", @@ -1727,6 +1731,17 @@ "version": "6.3.1", "license": "MIT" }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", + "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, "node_modules/@codemirror/view": { "version": "6.21.3", "license": "MIT", @@ -1828,6 +1843,10 @@ "resolved": "packages/doenetml-worker", "link": true }, + "node_modules/@doenet/lsp": { + "resolved": "packages/lsp", + "link": true + }, "node_modules/@doenet/lsp-tools": { "resolved": "packages/lsp-tools", "link": true @@ -1864,6 +1883,10 @@ "resolved": "packages/virtual-keyboard", "link": true }, + "node_modules/@doenet/vscode-extension": { + "resolved": "packages/vscode-extension", + "link": true + }, "node_modules/@emotion/babel-plugin": { "version": "11.10.6", "license": "MIT", @@ -2883,6 +2906,14 @@ "@types/chai": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.1", "dev": true, @@ -2911,6 +2942,11 @@ "version": "5.1.2", "license": "MIT" }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" + }, "node_modules/@types/node": { "version": "20.8.7", "license": "MIT", @@ -2996,6 +3032,57 @@ "@types/unist": "*" } }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.21.21", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.21.21.tgz", + "integrity": "sha512-+0i9dPrRSa8Mf0CvyrMvnAhajnqwsP3IMRRlaHDRgsSGL8igc4z7MhvUPn+7cWFAAqWzQRhMdMSWzo6/TEa3EA==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.21.21", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.21.21.tgz", + "integrity": "sha512-PaxBMarufMWoR0qc5zuvBSt76rJ9POm9qoOaJbqRmnNL2viaF+d+Paf2blPSlm1JSnqn7hlRjio+40nZJ9TKzw==", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.21.21", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@ungap/promise-all-settled": { "version": "1.1.2", "dev": true, @@ -4642,6 +4729,15 @@ "node": ">=4" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/character-entities-html4": { "version": "2.1.0", "license": "MIT", @@ -5850,6 +5946,18 @@ "version": "10.4.3", "license": "MIT" }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/decode-uri-component": { "version": "0.2.0", "license": "MIT", @@ -9462,6 +9570,406 @@ "version": "4.1.1", "license": "Apache-2.0" }, + "node_modules/micromark": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", + "integrity": "sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.0.tgz", + "integrity": "sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", + "integrity": "sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz", + "integrity": "sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", + "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz", + "integrity": "sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz", + "integrity": "sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.0.1.tgz", + "integrity": "sha512-3wgnrmEAJ4T+mGXAUfMvMAbxU9RDG43XmGce4j6CwPtVxB3vfwXSZ6KhFwDzZ3mZHhmPimMAXg71veiBGzeAZw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz", + "integrity": "sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz", + "integrity": "sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz", + "integrity": "sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz", + "integrity": "sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", + "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz", + "integrity": "sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz", + "integrity": "sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz", + "integrity": "sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", + "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.0.tgz", + "integrity": "sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", + "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, "node_modules/micromatch": { "version": "4.0.5", "dev": true, @@ -13652,10 +14160,6 @@ "dev": true, "license": "MIT" }, - "node_modules/vscode-extension": { - "resolved": "packages/vscode-extension", - "link": true - }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "dev": true, @@ -14529,6 +15033,11 @@ "node": ">=8.0.0" } }, + "packages/lsp": { + "name": "@doenet/lsp", + "version": "1.0.0", + "license": "AGPL-3.0-or-later" + }, "packages/lsp-tools": { "name": "@doenet/lsp-tools", "version": "1.0.0", @@ -14735,6 +15244,7 @@ } }, "packages/vscode-extension": { + "name": "@doenet/vscode-extension", "version": "0.7.3", "license": "AGPL", "devDependencies": { diff --git a/package.json b/package.json index c66a872d0..fdb4d3627 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "./packages/static-assets", "./packages/parser", "./packages/lsp-tools", + "./packages/lsp", "./packages/doenetml-worker", "./packages/virtual-keyboard", "./packages/codemirror", @@ -46,5 +47,8 @@ "prettier": { "tabWidth": 4 }, - "dependencies": {} + "dependencies": { + "@uiw/react-codemirror": "^4.21.21", + "micromark": "^4.0.0" + } } diff --git a/packages/codemirror/src/CodeMirror.tsx b/packages/codemirror/src/CodeMirror.tsx index fd17abdc6..73e7b27a5 100644 --- a/packages/codemirror/src/CodeMirror.tsx +++ b/packages/codemirror/src/CodeMirror.tsx @@ -1,456 +1,81 @@ -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; +import React from "react"; +import { EditorSelection, Extension } from "@codemirror/state"; +import ReactCodeMirror, { EditorView } from "@uiw/react-codemirror"; +import { syntaxHighlightingExtension } from "./extensions/syntax-highlighting"; +import { tabExtension } from "./extensions/tab"; import { - EditorState, - Transaction, - StateEffect, - EditorSelection, -} from "@codemirror/state"; -import { selectLine, deleteLine, cursorLineUp } from "@codemirror/commands"; -import { EditorView, keymap, Command } from "@codemirror/view"; -import { basicSetup } from "codemirror"; -import { styleTags, tags as t } from "@lezer/highlight"; -import { lineNumbers } from "@codemirror/view"; -import { - LRLanguage, - LanguageSupport, - syntaxTree, - indentNodeProp, - foldNodeProp, -} from "@codemirror/language"; -import { completeFromSchema } from "@codemirror/lang-xml"; -import { parser } from "@doenet/parser"; -import { doenetSchema } from "@doenet/static-assets"; + lspPlugin, + uniqueLanguageServerInstance, +} from "./extensions/lsp/plugin"; +import { colorTheme } from "./extensions/theme"; export function CodeMirror({ - setInternalValueTo, - onBeforeChange, + value, + onChange, onCursorChange, readOnly, onBlur, onFocus, - paddingBottom, }: { - setInternalValueTo: string; - onBeforeChange: (str: string) => void; + value: string; + onChange?: (str: string) => void; onCursorChange?: (selection: EditorSelection) => any; readOnly?: boolean; onBlur?: () => void; onFocus?: () => void; - paddingBottom?: string; }) { - if (readOnly === undefined) { - readOnly = false; - } - - let colorTheme = EditorView.theme({ - "&": { - color: "var(--canvastext)", - //backgroundColor: "var(--canvas)", - }, - ".cm-content": { - caretColor: "#0e9", - borderDownColor: "var(--canvastext)", - }, - ".cm-editor": { - caretColor: "#0e9", - backgroundColor: "var(--canvas)", - }, - "&.cm-focused .cm-cursor": { - backgroundColor: "var(--lightBlue)", - borderLeftColor: "var(--canvastext)", - }, - "&.cm-focused .cm-selectionBackground, ::selection": { - backgroundColor: "var(--mainGray)", - }, - "&.cm-focused": { - color: "var(--canvastext)", - }, - "cm-selectionLayer": { - backgroundColor: "var(--mainGreen)", - }, - ".cm-gutters": { - backgroundColor: "var(--mainGray)", - color: "black", - border: "none", - }, - ".cm-activeLine": { - backgroundColor: "var(--mainGray)", - color: "black", - }, - }); - - let [editorConfig, setEditorConfig] = useState({ - matchTag: false, - }); - let view = useRef(null); - let parent = useRef(null); - const [count, setCount] = useState(0); - - const changeFunc = useCallback((tr: Transaction) => { - if (tr.selection && onCursorChange) { - onCursorChange(tr.selection); - } - if (tr.docChanged) { - let strOfDoc = tr.state.sliceDoc(); - onBeforeChange(strOfDoc); - return true; - } - return false; - //trust in the system - //eslint-disable-next-line - }, []); - - //Make sure readOnly takes affect - //TODO: Do this is a smarter way - async await? - if (readOnly && view.current?.state?.facet(EditorView.editable)) { - const disabledExtensions = [ - EditorView.editable.of(false), - lineNumbers(), - ]; - view.current.dispatch({ - effects: StateEffect.reconfigure.of(disabledExtensions), - }); - } - - //Fires when the editor losses focus - const onBlurExtension = EditorView.domEventHandlers({ - blur() { - if (onBlur) { - onBlur(); - } - }, - }); - - //Fires when the editor receives focus - const onFocusExtension = EditorView.domEventHandlers({ - focus() { - if (onFocus) { - onFocus(); - } - }, - }); - - //tabs = 2 spaces - const tab = " "; - const tabCommand: Command = ({ state, dispatch }) => { - dispatch( - state.update(state.replaceSelection(tab), { - scrollIntoView: true, - annotations: Transaction.userEvent.of("input"), - }), - ); - return true; - }; - - const tabExtension = keymap.of([ - { - key: "Tab", - run: tabCommand, - }, - ]); - - const copyCommand: Command = ({ state, dispatch }) => { - if (state.selection.main.empty) { - selectLine({ state: state, dispatch: dispatch }); - document.execCommand("copy"); - } else { - document.execCommand("copy"); - } - return true; - }; - - const copyExtension = keymap.of([ - { - key: "Mod-x", - run: copyCommand, - }, - ]); - - const cutCommand: Command = ({ state, dispatch }) => { - //if the selection is empty - if (state.selection.main.empty && view.current) { - selectLine({ state: state, dispatch: dispatch }); - document.execCommand("copy"); - if ( - state.doc.lineAt(state.selection.main.from).number !== - state.doc.lines - ) { - deleteLine(view.current); - cursorLineUp(view.current); - } else { - deleteLine(view.current); - } - } else { - document.execCommand("copy"); - dispatch( - state.update(state.replaceSelection(""), { - scrollIntoView: true, - annotations: Transaction.userEvent.of("input"), - }), - ); - } - return true; - }; - const cutExtension = keymap.of([ - { - key: "Mod-x", - run: cutCommand, - }, - ]); - - const doenetExtensions = useMemo( - () => [ - basicSetup, - doenet(doenetSchema), - EditorView.lineWrapping, - colorTheme, - tabExtension, - cutExtension, - copyExtension, - onBlurExtension, - onFocusExtension, - EditorState.changeFilter.of(changeFunc), - - // XXX This type appears to be incorrect, but I am not sure what this function is doing... - // @ts-ignore - EditorView.updateListener.of(changeFunc), - ], - [changeFunc], - ); - - const matchTag = useCallback( - (tr: Transaction) => { - const cursorPos = tr.newSelection.main.from; - //if we may be closing an OpenTag - if ( - tr.annotation(Transaction.userEvent) == "input" && - tr.newDoc.sliceString(cursorPos - 1, cursorPos) === ">" - ) { - //check to see if we are actually closing an OpenTag - let node = syntaxTree(tr.state).resolve(cursorPos, -1); - if (node.name !== "OpenTag") { - return tr; - } - //first node is the StartTag - let tagNameNode = node.firstChild?.nextSibling; - let tagName = tr.newDoc.sliceString( - tagNameNode?.from || 0, - tagNameNode?.to, - ); - - //an inefficient hack to make it so the modified document is saved directly after tagMatch - let tra = tr.state.update({ - changes: { - from: cursorPos, - insert: ""), - }, - sequential: true, - }); - changeFunc(tra); - - return [ - tr, - { - changes: { - from: cursorPos, - insert: ""), - }, - sequential: true, - }, - ]; - } else { - return tr; - } - }, - [changeFunc], + // Only one language server runs for all documents, so we specify a document id to keep different instances different. + const [documentId, _] = React.useState(() => + Math.floor(Math.random() * 100000).toString(), ); - const state = EditorState.create({ - doc: setInternalValueTo, - extensions: doenetExtensions, - }); - - useEffect(() => { - if (view.current !== null && parent.current !== null) { - // console.log(">>>changing setInternalValueTo to", setInternalValueTo); - let tr = view.current.state.update({ - changes: { - from: 0, - to: view.current.state.doc.length, - insert: setInternalValueTo, - }, - }); - view.current.dispatch(tr); - } - }, [setInternalValueTo]); - - useEffect(() => { - if (view.current === null && parent.current !== null) { - view.current = new EditorView({ state, parent: parent.current }); - - if (readOnly && view.current.state.facet(EditorView.editable)) { - //Force a refresh - setCount((old) => { - return old + 1; - }); + React.useEffect(() => { + return () => { + // We need to clean up the document on the language server. If the document + // was read-only, the language server wasn't loaded so there is nothing to do. + if (readOnly) { + return; } - } - }); - - useEffect(() => { - if (view.current !== null && parent.current !== null) { - if (readOnly && view.current.state.facet(EditorView.editable)) { - // console.log(">>>read only has been set, changing"); - //NOTE: WHY DOESN'T THIS WORK? - const disabledExtensions = [ - EditorView.editable.of(false), - lineNumbers(), - ]; - view.current.dispatch({ - effects: StateEffect.reconfigure.of(disabledExtensions), - }); - } else if ( - !readOnly && - !view.current.state.facet(EditorView.editable) - ) { - // console.log(">>>read only has been turned off, changing"); - view.current.dispatch({ - effects: StateEffect.reconfigure.of(doenetExtensions), - }); - if (editorConfig.matchTag) { - view.current.dispatch({ - effects: StateEffect.appendConfig.of( - EditorState.transactionFilter.of(matchTag), - ), - }); - } - } - } - //annoying that editorConfig is a dependency, but no real way around it - }, [ - doenetExtensions, - setInternalValueTo, - matchTag, - readOnly, - editorConfig.matchTag, - ]); - - //TODO any updates would force an update of each part of the config. - //Doesn't matter since there's only one toggle at the moment, but could cause unneccesary work later - useEffect(() => { - // console.log(">>>config update") - if (editorConfig.matchTag) { - view.current?.dispatch({ - effects: StateEffect.appendConfig.of( - EditorState.transactionFilter.of(matchTag), - ), - }); - } else { - view.current?.dispatch({ - //this will also need to change when more options are added, as this paves all of the added extensions. - effects: StateEffect.reconfigure.of(doenetExtensions), - }); - } - }, [editorConfig, matchTag, doenetExtensions]); - - let divStyle: React.CSSProperties = {}; - - if (paddingBottom) { - divStyle.paddingBottom = paddingBottom; + const uri = `file:///${documentId}.doenet`; + uniqueLanguageServerInstance.closeDocument(uri); + }; + }, [documentId, readOnly]); + + const extensions: Extension[] = [ + syntaxHighlightingExtension, + colorTheme, + EditorView.lineWrapping, + ]; + if (!readOnly) { + extensions.push(tabExtension); + extensions.push(lspPlugin(documentId)); } - //should rewrite using compartments once a more formal config component is established return ( - <> -
- - ); -} - -let parserWithMetadata = parser.configure({ - props: [ - indentNodeProp.add({ - //fun (unfixable?) glitch: If you modify the document and then create a newline before enough time has passed for a new parse (which is often < 50ms) - //the indent wont have time to update and you're going right back to the left side of the screen. - Element(context) { - let closed = /^\s*<\//.test(context.textAfter); - // console.log("youuuhj",context.state.doc.lineAt(context.node.from)) - return ( - context.lineIndent(context.node.from) + - (closed ? 0 : context.unit) - ); - }, - "OpenTag CloseTag SelfClosingTag"(context) { - if (context.node.firstChild?.name == "TagName") { - return context.column(context.node.from); + { + if (onChange) { + onChange(update.state.doc.toString()); } - return context.column(context.node.from) + context.unit; - }, - }), - foldNodeProp.add({ - Element(subtree) { - let first = subtree.firstChild; - let last = subtree.lastChild; - if (!first || first.name != "OpenTag") return null; - return { - from: first.to, - to: last?.name == "CloseTag" ? last.from : subtree.to, - }; - }, - }), - styleTags({ - AttributeValue: t.string, - Text: t.content, - TagName: t.tagName, - MismatchedCloseTag: t.invalid, - "StartTag StartCloseTag EndTag SelfCloseEndTag": t.angleBracket, - "MismatchedCloseTag/TagName": [t.tagName, t.invalid], - "MismatchedCloseTag/StartCloseTag": t.invalid, - AttributeName: t.propertyName, - Is: t.definitionOperator, - "EntityReference CharacterReference": t.character, - Comment: t.blockComment, - Macro: t.macroName, - }), - ], -}); - -const doenetLanguage = LRLanguage.define({ - parser: parserWithMetadata, - languageData: { - commentTokens: { block: { open: "" } }, - indentOnInput: /^\s*<\/$/, - }, -}); - -// TODO: not sure what this is for, but it isn't referenced anywhere. -// If we need this functionality, we have to rework it -// given that view is no longer a global variable. -// export function codeMirrorFocusAndGoToEnd() { -// view.current.focus(); -// view.current.dispatch( -// view.current.state.update({ -// selection: { anchor: view.current.state.doc.length }, -// }), -// { scrollIntoView: true }, -// ); -// } - -const doenet = ( - conf: Partial & { attributes?: [] } = {}, -) => - new LanguageSupport( - doenetLanguage, - doenetLanguage.data.of({ - autocomplete: completeFromSchema( - conf.elements || [], - conf.attributes || [], - ), - }), + }} + onUpdate={(viewUpdate) => { + for (const tr of viewUpdate.transactions) { + if (tr.selection && onCursorChange) { + onCursorChange(tr.selection); + } + } + }} + onBlur={() => onBlur && onBlur()} + onFocus={() => onFocus && onFocus()} + height="100%" + extensions={extensions} + /> ); +} diff --git a/packages/codemirror/src/extensions/lsp/index.ts b/packages/codemirror/src/extensions/lsp/index.ts new file mode 100644 index 000000000..19608712a --- /dev/null +++ b/packages/codemirror/src/extensions/lsp/index.ts @@ -0,0 +1 @@ +export { lspPlugin } from "./plugin"; diff --git a/packages/codemirror/src/extensions/lsp/plugin.ts b/packages/codemirror/src/extensions/lsp/plugin.ts new file mode 100644 index 000000000..649783736 --- /dev/null +++ b/packages/codemirror/src/extensions/lsp/plugin.ts @@ -0,0 +1,335 @@ +// Code based off of https://github.com/FurqanSoftware/codemirror-languageserver +// BSD 3-Clause License +// Copyright (c) 2021, Mahmud Ridwan +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// * Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// * Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// * Neither the name of the library nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import { micromark } from "micromark"; +import { + EditorView, + PluginValue, + ViewPlugin, + ViewUpdate, + hoverTooltip, +} from "@codemirror/view"; +import { LSP } from "./worker"; +import { + Diagnostic as LSPDiagnostic, + DiagnosticSeverity as LSPDiagnosticSeverity, + CompletionTriggerKind as LSPCompletionTriggerKind, +} from "vscode-languageserver-protocol/browser"; +import { Text } from "@codemirror/state"; +import { + setDiagnostics, + Diagnostic as CodeMirrorDiagnostic, +} from "@codemirror/lint"; +import { + autocompletion, + CompletionContext, + Completion, +} from "@codemirror/autocomplete"; +import type { + MarkupContent, + MarkedString, +} from "vscode-languageserver-protocol"; +import { CompletionItemKind } from "vscode-languageserver-protocol"; +import "./tooltip.css"; + +const completionItemKindMap = Object.fromEntries( + Object.entries(CompletionItemKind).map(([key, value]) => [value, key]), +) as Record; + +const lspDiagnosticToName = { + [LSPDiagnosticSeverity.Error]: "Error", + [LSPDiagnosticSeverity.Warning]: "Warning", + [LSPDiagnosticSeverity.Information]: "Info", + [LSPDiagnosticSeverity.Hint]: "Hint", +}; +const lspSeverityToCmSeverity = { + [LSPDiagnosticSeverity.Error]: "error", + [LSPDiagnosticSeverity.Warning]: "warning", + [LSPDiagnosticSeverity.Information]: "info", + [LSPDiagnosticSeverity.Hint]: "info", +} as const; + +// One language server is shared across all plugin instances +export const uniqueLanguageServerInstance = new LSP(); + +export class LSPPlugin implements PluginValue { + documentId: string; + uri: string = ""; + value: string = ""; + diagnostics: LSPDiagnostic[] = []; + diagnosticsPromise?: Promise; + view?: EditorView; + + constructor(documentId: string) { + this.documentId = documentId; + this.uri = `file:///${documentId}.doenet`; + } + + update(update: ViewUpdate): void { + const value = update.state.doc.toString(); + if (update.docChanged) { + this.setValue(value); + } + } + + async setValue(value: string) { + if (value === this.value) { + return; + } + await uniqueLanguageServerInstance.updateDocument(this.uri, value); + this.pollForDiagnostics(); + this.value = value; + } + + async pollForDiagnostics() { + this.diagnosticsPromise = uniqueLanguageServerInstance.getDiagnostics( + this.uri, + ); + this.diagnostics = await this.diagnosticsPromise; + this.processDiagnostics(); + } + + processDiagnostics() { + if (!this.view) { + return; + } + const diagnostics: CodeMirrorDiagnostic[] = this.diagnostics + .map(({ range, message, severity }) => { + const cmSeverity = lspSeverityToCmSeverity[severity!]; + return { + from: posToOffset(this.view!.state.doc, range.start)!, + to: posToOffset(this.view!.state.doc, range.end)!, + severity: cmSeverity, + message, + renderMessage: () => { + const div = document.createElement("div"); + // We use renderToString so that we don't have to clean up any + // react listeners, etc. when the dom element is deleted by codemirror. + div.innerHTML = `

${ + lspDiagnosticToName[severity!] + }

${micromark( + message, + )}
+
`; + return div.firstChild as HTMLElement; + }, + }; + }) + .filter(({ from, to }) => from != null && to != null) + .sort((a, b) => { + switch (true) { + case a.from < b.from: + return -1; + case a.from > b.from: + return 1; + } + return 0; + }); + + const diagnosticTransaction = setDiagnostics( + this.view.state, + diagnostics, + ); + this.view.dispatch(diagnosticTransaction); + } + async getCompletions(context: CompletionContext) { + let { state, pos, explicit } = context; + const line = state.doc.lineAt(pos); + let triggerKind: LSPCompletionTriggerKind = + LSPCompletionTriggerKind.Invoked; + let triggerCharacter: string | undefined; + const precedingTriggerCharacter = + uniqueLanguageServerInstance.completionTriggers.includes( + line.text[pos - line.from - 1], + ); + if (!explicit && precedingTriggerCharacter) { + triggerKind = LSPCompletionTriggerKind.TriggerCharacter; + triggerCharacter = line.text[pos - line.from - 1]; + } + if ( + triggerKind === LSPCompletionTriggerKind.Invoked && + !context.matchBefore(/\w+$/) && + !precedingTriggerCharacter && + !explicit + ) { + return null; + } + const position = offsetToPos(state.doc, pos); + const result = await uniqueLanguageServerInstance.getCompletionItems( + this.uri, + { line: position.line, character: position.character }, + { + triggerKind, + triggerCharacter, + }, + ); + if (!result) { + return null; + } + + const items = "items" in result ? result.items : result; + + let options = items.map( + ({ + detail, + label, + kind, + textEdit, + documentation, + sortText, + filterText, + }) => { + const completion: Completion & { + filterText: string; + sortText?: string; + apply: string; + } = { + label, + detail, + apply: textEdit?.newText ?? label, + type: kind && completionItemKindMap[kind].toLowerCase(), + sortText: sortText ?? label, + filterText: filterText ?? label, + }; + if (documentation) { + completion.info = formatContents(documentation); + } + return completion; + }, + ); + + const [span, match] = prefixMatch(options); + const token = context.matchBefore(match); + + if (token) { + pos = token.from; + const word = token.text.toLowerCase(); + if (/^\w+$/.test(word)) { + options = options + .filter(({ filterText }) => + filterText.toLowerCase().startsWith(word), + ) + .sort(({ apply: a }, { apply: b }) => { + switch (true) { + case a.startsWith(token.text) && + !b.startsWith(token.text): + return -1; + case !a.startsWith(token.text) && + b.startsWith(token.text): + return 1; + } + return 0; + }); + } + } + return { + from: pos, + options, + }; + } +} + +export const lspPlugin = (documentId: string) => { + const plugin = new LSPPlugin(documentId); + return [ + ViewPlugin.define((view) => { + plugin.view = view; + plugin.setValue(view.state.doc.toString()); + return plugin; + }), + hoverTooltip((view, pos) => { + // XXX: To be implemented. Currently the LSP doesn't provide hover tooltips. + return null; + }), + autocompletion({ + override: [plugin.getCompletions.bind(plugin)], + }), + ]; +}; + +function posToOffset(doc: Text, pos: { line: number; character: number }) { + if (pos.line >= doc.lines) { + return; + } + const offset = doc.line(pos.line + 1).from + pos.character; + if (offset > doc.length) { + return; + } + return offset; +} + +function offsetToPos(doc: Text, offset: number) { + const line = doc.lineAt(offset); + return { + line: line.number - 1, + character: offset - line.from, + }; +} + +function toSet(chars: Set) { + let preamble = ""; + let flat = Array.from(chars).join(""); + const words = /\w/.test(flat); + if (words) { + preamble += "\\w"; + flat = flat.replace(/\w/g, ""); + } + return `[${preamble}${flat.replace(/[^\w\s]/g, "\\$&")}]`; +} + +function prefixMatch(options: Completion[]) { + const first = new Set(); + const rest = new Set(); + + for (const { apply } of options) { + const [initial, ...restStr] = apply as string; + first.add(initial); + for (const char of restStr) { + rest.add(char); + } + } + + const source = toSet(first) + toSet(rest) + "*$"; + return [new RegExp("^" + source), new RegExp(source)]; +} + +function formatContents( + contents: MarkupContent | MarkedString | MarkedString[], +): string { + if (Array.isArray(contents)) { + return contents.map((c) => formatContents(c) + "\n\n").join(""); + } else if (typeof contents === "string") { + return contents; + } else { + return contents.value; + } +} diff --git a/packages/codemirror/src/extensions/lsp/tooltip.css b/packages/codemirror/src/extensions/lsp/tooltip.css new file mode 100644 index 000000000..edd327a1d --- /dev/null +++ b/packages/codemirror/src/extensions/lsp/tooltip.css @@ -0,0 +1,29 @@ +.cm-lint-tooltip .heading { + margin-top: 0; + margin-bottom: 0.25em; + font-size: smaller; +} +.cm-lint-tooltip .heading.warning { + color: #a60; +} +.cm-lint-tooltip .heading.error { + color: #900; +} +.cm-diagnostic { + white-space: unset !important; +} +.cm-lint-body code { + font-size: larger; + white-space: pre-wrap; + color: #f2a; + padding: 0px 2px; +} +.cm-lint-body p { + margin: .25em 0; +} +.cm-lint-body p:last-child { + margin-bottom: 0; +} +.cm-lint-body p:first-child { + margin-top: 0; +} \ No newline at end of file diff --git a/packages/vscode-extension/test/utils/init-message-connection.ts b/packages/codemirror/src/extensions/lsp/utils/init-message-connection.ts similarity index 98% rename from packages/vscode-extension/test/utils/init-message-connection.ts rename to packages/codemirror/src/extensions/lsp/utils/init-message-connection.ts index e013a49f1..718d72e45 100644 --- a/packages/vscode-extension/test/utils/init-message-connection.ts +++ b/packages/codemirror/src/extensions/lsp/utils/init-message-connection.ts @@ -2,7 +2,7 @@ import { createLspConnection } from "@qualified/lsp-connection"; import { createMessageConnection } from "@qualified/vscode-jsonrpc-ww"; /** - * Initialize a WebWorker that runs a langauge server. The worker is initialized with + * Initialize a WebWorker that runs a language server. The worker is initialized with * `rootUri` set to `file:///` and `workspaceFolders` set to `null`. */ export async function initWorker(worker: Worker) { diff --git a/packages/codemirror/src/extensions/lsp/worker.ts b/packages/codemirror/src/extensions/lsp/worker.ts new file mode 100644 index 000000000..2fb7352e8 --- /dev/null +++ b/packages/codemirror/src/extensions/lsp/worker.ts @@ -0,0 +1,127 @@ +// @ts-ignore +import LSPWorker from "@doenet/lsp/language-server.js?worker"; +import { initWorker } from "./utils/init-message-connection"; +import { + Diagnostic, + Position, + CompletionContext, +} from "vscode-languageserver-protocol/browser"; + +/** + * Create a promise with its resolver. + */ +function withResolver() { + let resolve: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { resolve: resolve!, promise }; +} + +export class LSP { + worker?: Worker; + lspConn?: Awaited>; + versionCounter: Record = {}; + initPromise = withResolver(); + initStatus: "uninitialized" | "initializing" | "initialized" = + "uninitialized"; + completionTriggers: string[] = []; + + async init() { + if (this.lspConn) { + return; + } + if (this.initStatus === "uninitialized") { + this.initStatus = "initializing"; + this.worker = new LSPWorker(); + this.lspConn = await initWorker(this.worker!); + this.completionTriggers = this.lspConn.completionTriggers; + this.initPromise.resolve(); + this.initStatus = "initialized"; + } + if (this.initStatus === "initializing") { + await this.initPromise.promise; + } + } + + async initDocument(uri: string, text: string) { + if (!this.lspConn) { + await this.init(); + await this.initDocument(uri, text); + return; + } + this.versionCounter[uri] = 1; + await this.lspConn.textDocumentOpened({ + textDocument: { + uri, + languageId: "doenet", + version: 1, + text, + }, + }); + } + + async closeDocument(uri: string) { + if (!this.lspConn) { + await this.init(); + await this.closeDocument(uri); + return; + } + await this.lspConn.textDocumentClosed({ + textDocument: { + uri, + }, + }); + } + + async updateDocument(uri: string, text: string) { + if (!this.lspConn || !this.versionCounter[uri]) { + await this.initDocument(uri, text); + return; + } + this.versionCounter[uri] += 1; + await this.lspConn.textDocumentChanged({ + textDocument: { + uri, + version: this.versionCounter[uri], + }, + contentChanges: [ + { + text, + }, + ], + }); + } + + async getDiagnostics(uri: string): Promise { + await this.initPromise.promise; + return new Promise((resolve) => { + if (!this.lspConn) { + console.warn("Cannot get diagnostics without lspConn"); + return []; + } + this.lspConn.onDiagnostics((params) => { + if (params.uri === uri) { + resolve(params.diagnostics); + } + }); + }); + } + + async getCompletionItems( + uri: string, + position: Position, + context: CompletionContext, + ) { + await this.initPromise.promise; + if (!this.lspConn) { + console.warn("Cannot get completion items without lspConn"); + return []; + } + return await this.lspConn.getCompletion({ + textDocument: { uri }, + position, + context, + }); + } +} diff --git a/packages/codemirror/src/extensions/syntax-highlighting.ts b/packages/codemirror/src/extensions/syntax-highlighting.ts new file mode 100644 index 000000000..010272db8 --- /dev/null +++ b/packages/codemirror/src/extensions/syntax-highlighting.ts @@ -0,0 +1,60 @@ +import { LRLanguage, LanguageSupport, foldNodeProp, indentNodeProp } from "@codemirror/language"; +import { parser } from "@doenet/parser"; +import { styleTags, tags as t } from "@lezer/highlight"; + +const parserWithMetadata = parser.configure({ + props: [ + indentNodeProp.add({ + //fun (unfixable?) glitch: If you modify the document and then create a newline before enough time has passed for a new parse (which is often < 50ms) + //the indent wont have time to update and you're going right back to the left side of the screen. + Element(context) { + let closed = /^\s*<\//.test(context.textAfter); + return ( + context.lineIndent(context.node.from) + + (closed ? 0 : context.unit) + ); + }, + "OpenTag CloseTag SelfClosingTag"(context) { + if (context.node.firstChild?.name == "TagName") { + return context.column(context.node.from); + } + return context.column(context.node.from) + context.unit; + }, + }), + foldNodeProp.add({ + Element(subtree) { + let first = subtree.firstChild; + let last = subtree.lastChild; + if (!first || first.name != "OpenTag") return null; + return { + from: first.to, + to: last?.name == "CloseTag" ? last.from : subtree.to, + }; + }, + }), + styleTags({ + AttributeValue: t.string, + Text: t.content, + TagName: t.tagName, + MismatchedCloseTag: t.invalid, + "StartTag StartCloseTag EndTag SelfCloseEndTag": t.angleBracket, + "MismatchedCloseTag/TagName": [t.tagName, t.invalid], + "MismatchedCloseTag/StartCloseTag": t.invalid, + AttributeName: t.propertyName, + Is: t.definitionOperator, + "EntityReference CharacterReference": t.character, + Comment: t.blockComment, + Macro: t.macroName, + }), + ], +}); + +const doenetLanguage = LRLanguage.define({ + parser: parserWithMetadata, + languageData: { + commentTokens: { block: { open: "" } }, + indentOnInput: /^\s*<\/$/, + }, +}); + +export const syntaxHighlightingExtension = new LanguageSupport(doenetLanguage); \ No newline at end of file diff --git a/packages/codemirror/src/extensions/tab.ts b/packages/codemirror/src/extensions/tab.ts new file mode 100644 index 000000000..9c85842ac --- /dev/null +++ b/packages/codemirror/src/extensions/tab.ts @@ -0,0 +1,24 @@ +import { Transaction } from "@codemirror/state"; +import { Command, keymap } from "@codemirror/view"; + +// XXX: this extension appears to do nothing! + +//tabs = 2 spaces +const tab = " "; +const tabCommand: Command = ({ state, dispatch }) => { + console.log("running") + dispatch( + state.update(state.replaceSelection(tab), { + scrollIntoView: true, + annotations: Transaction.userEvent.of("input"), + }), + ); + return true; +}; + +export const tabExtension = keymap.of([ + { + key: "Tab", + run: tabCommand, + }, +]); diff --git a/packages/codemirror/src/extensions/theme.ts b/packages/codemirror/src/extensions/theme.ts new file mode 100644 index 000000000..796271db4 --- /dev/null +++ b/packages/codemirror/src/extensions/theme.ts @@ -0,0 +1,39 @@ +import { EditorView } from "@codemirror/view"; + +export const colorTheme = EditorView.theme({ + "&": { + color: "var(--canvastext)", + height: "100%", + //backgroundColor: "var(--canvas)", + }, + ".cm-content": { + caretColor: "#0e9", + borderDownColor: "var(--canvastext)", + }, + ".cm-editor": { + caretColor: "#0e9", + backgroundColor: "var(--canvas)", + }, + "&.cm-focused .cm-cursor": { + backgroundColor: "var(--lightBlue)", + borderLeftColor: "var(--canvastext)", + }, + "&.cm-focused .cm-selectionBackground, ::selection": { + backgroundColor: "var(--mainGray)", + }, + "&.cm-focused": { + color: "var(--canvastext)", + }, + "cm-selectionLayer": { + backgroundColor: "var(--mainGreen)", + }, + ".cm-gutters": { + backgroundColor: "var(--mainGray)", + color: "black", + border: "none", + }, + ".cm-activeLine": { + backgroundColor: "var(--mainGray)", + color: "black", + }, +}); diff --git a/packages/codemirror/src/index.ts b/packages/codemirror/src/index.ts new file mode 100644 index 000000000..baf36ad17 --- /dev/null +++ b/packages/codemirror/src/index.ts @@ -0,0 +1 @@ +export * from "./CodeMirror"; diff --git a/packages/codemirror/src/test-main.tsx b/packages/codemirror/src/test-main.tsx index cb14432fa..82fe6042a 100644 --- a/packages/codemirror/src/test-main.tsx +++ b/packages/codemirror/src/test-main.tsx @@ -7,13 +7,25 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { CodeMirror } from "./CodeMirror"; -ReactDOM.createRoot(document.getElementById("root")!).render( - {}} - setInternalValueTo={` -

Use this to test DoenetML

- +ReactDOM.createRoot(document.getElementById("root")!).render(); + +function App() { + const [viewVisible, setViewVisible] = React.useState(true); + return ( + + + {viewVisible && ( + {}} + value={` +

+

Use this to test DoenetML. + Some text & +

+ @@ -22,5 +34,17 @@ ReactDOM.createRoot(document.getElementById("root")!).render( `} - />, -); + onBlur={() => console.log("blur")} + onFocus={() => console.log("focus")} + onCursorChange={(e) => console.log("cursor change", e)} + /> + )} +
Read only view below
+ {}} + value={`

foo

`} + readOnly={true} + /> +
+ ); +} diff --git a/packages/codemirror/tsconfig.json b/packages/codemirror/tsconfig.json index 22a330066..d57fd17bf 100644 --- a/packages/codemirror/tsconfig.json +++ b/packages/codemirror/tsconfig.json @@ -9,7 +9,7 @@ "./**/*.test.ts", "./**/*.stub.ts", "node_modules", - "**/tests/", + "**/test/", "**/dist/**/*", "./vite.config.ts" ], diff --git a/packages/codemirror/vite.config.ts b/packages/codemirror/vite.config.ts index 537453eb5..226b5c4fc 100644 --- a/packages/codemirror/vite.config.ts +++ b/packages/codemirror/vite.config.ts @@ -1,10 +1,11 @@ -import { defineConfig } from "vite"; +import { visualizer } from "rollup-plugin-visualizer"; +import { PluginOption, defineConfig } from "vite"; import dts from "vite-plugin-dts"; // https://vitejs.dev/config/ export default defineConfig({ base: "./", - plugins: [dts({ rollupTypes: true })], + plugins: [dts({ rollupTypes: true }), visualizer() as PluginOption], build: { minify: false, sourcemap: true, @@ -14,7 +15,7 @@ export default defineConfig({ formats: ["es"], }, rollupOptions: { - external: ["react", "react-dom", "styled-components"], + external: ["react", "react-dom", "react-dom/server"], }, }, }); diff --git a/packages/doenetml-worker/tsconfig.json b/packages/doenetml-worker/tsconfig.json index cbdf9d0e4..2e093fbf8 100644 --- a/packages/doenetml-worker/tsconfig.json +++ b/packages/doenetml-worker/tsconfig.json @@ -9,7 +9,7 @@ "./**/*.test.ts", "./**/*.stub.ts", "node_modules", - "**/tests/", + "**/test/", "**/dist/**/*", "./vite.config.ts" ], diff --git a/packages/doenetml/src/Viewer/renderers/codeEditor-ts.tsx b/packages/doenetml/src/Viewer/renderers/codeEditor-ts.tsx index b3adcb897..cc7e088f1 100644 --- a/packages/doenetml/src/Viewer/renderers/codeEditor-ts.tsx +++ b/packages/doenetml/src/Viewer/renderers/codeEditor-ts.tsx @@ -281,7 +281,7 @@ export default React.memo(function CodeEditor(props) {
readOnly={SVs.disabled} onBlur={() => { @@ -297,8 +297,7 @@ export default React.memo(function CodeEditor(props) { onFocus={() => { // console.log(">>codeEditor FOCUS!!!!!") }} - onBeforeChange={onEditorChange} - paddingBottom={paddingBottom} + onChange={onEditorChange} /> {errorsAndWarnings} diff --git a/packages/lsp-tools/src/auto-completer/methods/get-completion-items.ts b/packages/lsp-tools/src/auto-completer/methods/get-completion-items.ts index ee3b5245d..63cb833fc 100644 --- a/packages/lsp-tools/src/auto-completer/methods/get-completion-items.ts +++ b/packages/lsp-tools/src/auto-completer/methods/get-completion-items.ts @@ -33,6 +33,14 @@ export function getCompletionItems( let containingElement = this.sourceObj.elementAtOffsetWithContext(offset); const element = containingElement.node; let cursorPosition = containingElement.cursorPosition; + + if (!containingNode && cursorPosition === "unknown" && prevChar === "<") { + return this.schemaTopAllowedElements.map((name) => ({ + label: name, + kind: CompletionItemKind.Property, + })); + } + if (!element && containingNode && containingNode.type === "text") { // We're in the root of the document and not inside any special XML tags (like `` or ``) // Find out what items we can complete. diff --git a/packages/lsp-tools/src/dev-site.tsx b/packages/lsp-tools/src/dev-site.tsx index 66eed0964..59090fd7c 100644 --- a/packages/lsp-tools/src/dev-site.tsx +++ b/packages/lsp-tools/src/dev-site.tsx @@ -141,10 +141,10 @@ function App() { >
{ + onChange={(val) => { setDoenetSource(val); }} - setInternalValueTo={INITIAL_DOENET_SOURCE} + value={INITIAL_DOENET_SOURCE} onCursorChange={(selection) => { const range = selection.ranges[0]; if (!range) { diff --git a/packages/lsp-tools/src/doenet-source-object/methods/element-at-offset.ts b/packages/lsp-tools/src/doenet-source-object/methods/element-at-offset.ts index 3d4d69677..f22470d55 100644 --- a/packages/lsp-tools/src/doenet-source-object/methods/element-at-offset.ts +++ b/packages/lsp-tools/src/doenet-source-object/methods/element-at-offset.ts @@ -22,11 +22,17 @@ export function elementAtOffsetWithContext( let cursorPosition: CursorPosition = "unknown"; const prevChar = this.source.charAt(offset - 1); const exactNodeAtOffset = this.nodeAtOffset(offset); + const parent = exactNodeAtOffset ? this.getParent(exactNodeAtOffset) : null; let node = this.nodeAtOffset(offset, { type: "element", }); - if (exactNodeAtOffset && exactNodeAtOffset !== node) { + if ( + (exactNodeAtOffset && exactNodeAtOffset !== node) || + !exactNodeAtOffset || + !parent || + (node?.type === "element" && node.name === "" && parent.type === "root") + ) { // If our exact node is not the same as our containing element, then we're a child of the containing // element and so we're in the body. cursorPosition = "body"; diff --git a/packages/lsp-tools/test/doenet-auto-complete.test.ts b/packages/lsp-tools/test/doenet-auto-complete.test.ts index 322d9586f..b5256a676 100644 --- a/packages/lsp-tools/test/doenet-auto-complete.test.ts +++ b/packages/lsp-tools/test/doenet-auto-complete.test.ts @@ -14,7 +14,7 @@ console.log = (...args) => { const schema = { elements: [ { - name: "a", + name: "aa", children: ["b", "c", "d"], attributes: [{ name: "x" }, { name: "y" }, { name: "xyx" }], top: true, @@ -51,42 +51,12 @@ describe("AutoCompleter", () => { { let offset = source.indexOf(" { `); } }); + it("Can suggest completions after a `<`", () => { + let source: string; + let autoCompleter: AutoCompleter; + + source = `< `; + autoCompleter = new AutoCompleter(source, schema.elements); + { + let offset = source.indexOf("<") + 1; + let elm = autoCompleter.getCompletionItems(offset); + expect(elm).toMatchInlineSnapshot(` + [ + { + "kind": 10, + "label": "aa", + }, + ] + `); + } + }); + it("Can suggest completions after a `<` when it comes at the end of the string", () => { + let source: string; + let autoCompleter: AutoCompleter; + + source = ` <`; + autoCompleter = new AutoCompleter(source, schema.elements); + { + let offset = source.indexOf("<") + 1; + let elm = autoCompleter.getCompletionItems(offset); + expect(elm).toMatchInlineSnapshot(` + [ + { + "kind": 10, + "label": "aa", + }, + ] + `); + } + }); + it("Can suggest completions after a ` { + let source: string; + let autoCompleter: AutoCompleter; + + source = ` { + let source: string; + let autoCompleter: AutoCompleter; + + source = ` <`; + autoCompleter = new AutoCompleter(source, schema.elements); + { + let offset = source.indexOf("><") + 2; + let elm = autoCompleter.getCompletionItems(offset); + expect(elm).toMatchInlineSnapshot(` + [ + { + "kind": 10, + "label": "/aa>", + }, + ] + `); + } + }); + it.skip("Closing tag suggestions are offered if there is whitespace after the `/` even if there is text", () => { + let source: string; + let autoCompleter: AutoCompleter; + + source = ` <") + 3; + let elm = autoCompleter.getCompletionItems(offset); + expect(elm).toMatchInlineSnapshot(` + [ + { + "kind": 10, + "label": "/aa>", + }, + ] + `); + } + }); it("Can get completion context", () => { let source: string; let autoCompleter: AutoCompleter; diff --git a/packages/lsp-tools/tsconfig.json b/packages/lsp-tools/tsconfig.json index 22a330066..d57fd17bf 100644 --- a/packages/lsp-tools/tsconfig.json +++ b/packages/lsp-tools/tsconfig.json @@ -9,7 +9,7 @@ "./**/*.test.ts", "./**/*.stub.ts", "node_modules", - "**/tests/", + "**/test/", "**/dist/**/*", "./vite.config.ts" ], diff --git a/packages/lsp/package.json b/packages/lsp/package.json new file mode 100644 index 000000000..7e52c37f3 --- /dev/null +++ b/packages/lsp/package.json @@ -0,0 +1,27 @@ +{ + "name": "@doenet/lsp", + "type": "module", + "description": "DoenetML language server", + "version": "1.0.0", + "license": "AGPL-3.0-or-later", + "homepage": "https://github.com/Doenet/DoenetML#readme", + "private": false, + "repository": "github:Doenet/DoenetML", + "files": [ + "/dist" + ], + "exports": { + ".": { + "import": "./dist/index.js" + }, + "./language-server.js": { + "import": "./dist/index.js" + } + }, + "scripts": { + "watch": "vite build --watch", + "build": "vite build", + "test": "vitest", + "compile_grammar": "npx lezer-generator --output src/generated-assets/lezer-doenet.ts src/doenet.grammar" + } +} diff --git a/packages/vscode-extension/src/language-server/features/completions.ts b/packages/lsp/src/features/completions.ts similarity index 59% rename from packages/vscode-extension/src/language-server/features/completions.ts rename to packages/lsp/src/features/completions.ts index bbfd9992f..559804c91 100644 --- a/packages/vscode-extension/src/language-server/features/completions.ts +++ b/packages/lsp/src/features/completions.ts @@ -1,8 +1,4 @@ -import { - Connection, - CompletionItem, - TextDocumentPositionParams, -} from "vscode-languageserver/browser"; +import { Connection, CompletionItem } from "vscode-languageserver/browser"; import { DocumentInfo } from "../globals"; export function addDocumentCompletionSupport( @@ -10,22 +6,16 @@ export function addDocumentCompletionSupport( documentInfo: DocumentInfo, ) { // This handler provides the initial list of the completion items. - connection.onCompletion( - ( - textDocumentPosition: TextDocumentPositionParams, - ): CompletionItem[] => { - const info = documentInfo.get( - textDocumentPosition.textDocument.uri, - ); - if (!info) { - return []; - } - const completions = info.autoCompleter.getCompletionItems( - textDocumentPosition.position, - ); - return completions; - }, - ); + connection.onCompletion((params): CompletionItem[] => { + const info = documentInfo.get(params.textDocument.uri); + if (!info) { + return []; + } + const completions = info.autoCompleter.getCompletionItems( + params.position, + ); + return completions; + }); // XXX Give more meaningful information when we have more advanced documentation connection.onCompletionResolve((item: CompletionItem): CompletionItem => { diff --git a/packages/vscode-extension/src/language-server/features/document-symbols.ts b/packages/lsp/src/features/document-symbols.ts similarity index 93% rename from packages/vscode-extension/src/language-server/features/document-symbols.ts rename to packages/lsp/src/features/document-symbols.ts index faf0ac635..0e0993d16 100644 --- a/packages/vscode-extension/src/language-server/features/document-symbols.ts +++ b/packages/lsp/src/features/document-symbols.ts @@ -3,9 +3,9 @@ import { DocumentSymbol, SymbolKind, } from "vscode-languageserver/browser"; -import { DastNodes, toXml, visit } from "@doenet/parser"; +import { DastNodesV6, toXml } from "@doenet/parser"; import { DocumentInfo } from "../globals"; -import { DoenetSourceObject } from "../../../../lsp-tools/dist"; +import { DoenetSourceObject } from "@doenet/lsp-tools"; export function addDocumentSymbolsSupport( connection: Connection, @@ -28,14 +28,14 @@ export function addDocumentSymbolsSupport( * Get a document symbol for the given node. */ function nodeToSymbol( - node: DastNodes, + node: DastNodesV6, sourceObj: DoenetSourceObject, ): DocumentSymbol | undefined { switch (node.type) { case "element": { const attrs = node.attributes; const elmName = node.name; - for (const attr of attrs) { + for (const attr of Object.values(attrs)) { if (attr.name === "name") { // A name attribute defines a new symbol. const name = toXml(attr.children); @@ -73,7 +73,7 @@ function nodeToSymbol( * Recursively get all symbols for the given node's children. */ function getChildrenSymbols( - node: DastNodes, + node: DastNodesV6, sourceObj: DoenetSourceObject, ): DocumentSymbol[] { const ret: DocumentSymbol[] = []; diff --git a/packages/vscode-extension/src/language-server/features/folding-ranges.ts b/packages/lsp/src/features/folding-ranges.ts similarity index 68% rename from packages/vscode-extension/src/language-server/features/folding-ranges.ts rename to packages/lsp/src/features/folding-ranges.ts index ed64012a6..d6bd68dd9 100644 --- a/packages/vscode-extension/src/language-server/features/folding-ranges.ts +++ b/packages/lsp/src/features/folding-ranges.ts @@ -18,13 +18,17 @@ export function addFoldingRangeSupport( const ret: FoldingRange[] = []; visit(info.autoCompleter.sourceObj.dast, (node) => { + const position = node.position ?? { + start: { line: 1, column: 1 }, + end: { line: 1, column: 1 }, + }; switch (node.type) { case "comment": { ret.push({ - startLine: node.position.start.line - 1, - endLine: node.position.end.line - 1, - startCharacter: node.position.start.column - 1, - endCharacter: node.position.end.column - 1, + startLine: position.start.line - 1, + endLine: position.end.line - 1, + startCharacter: position.start.column - 1, + endCharacter: position.end.column - 1, kind: FoldingRangeKind.Comment, }); break; @@ -34,16 +38,16 @@ export function addFoldingRangeSupport( if (node.children.length === 0) { return; } - const start = node.position.start.line; - const end = node.position.end.line; + const start = position.start.line; + const end = position.end.line; if (start === end) { return; } ret.push({ startLine: start - 1, endLine: end - 1, - startCharacter: node.position.start.column - 1, - endCharacter: node.position.end.column - 1, + startCharacter: position.start.column - 1, + endCharacter: position.end.column - 1, kind: FoldingRangeKind.Region, }); break; diff --git a/packages/vscode-extension/src/language-server/features/formatting.ts b/packages/lsp/src/features/formatting.ts similarity index 100% rename from packages/vscode-extension/src/language-server/features/formatting.ts rename to packages/lsp/src/features/formatting.ts diff --git a/packages/vscode-extension/src/language-server/features/hover.ts b/packages/lsp/src/features/hover.ts similarity index 100% rename from packages/vscode-extension/src/language-server/features/hover.ts rename to packages/lsp/src/features/hover.ts diff --git a/packages/vscode-extension/src/language-server/features/validate.ts b/packages/lsp/src/features/validate.ts similarity index 100% rename from packages/vscode-extension/src/language-server/features/validate.ts rename to packages/lsp/src/features/validate.ts diff --git a/packages/vscode-extension/src/language-server/globals.ts b/packages/lsp/src/globals.ts similarity index 100% rename from packages/vscode-extension/src/language-server/globals.ts rename to packages/lsp/src/globals.ts diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts new file mode 100644 index 000000000..a36630705 --- /dev/null +++ b/packages/lsp/src/index.ts @@ -0,0 +1,124 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + * ------------------------------------------------------------------------------------------ */ +import { + createConnection, + InitializeParams, + DidChangeConfigurationNotification, + TextDocumentSyncKind, + InitializeResult, + BrowserMessageWriter, + BrowserMessageReader, +} from "vscode-languageserver/browser"; + +import { addFoldingRangeSupport } from "./features/folding-ranges"; +import { addDocumentSymbolsSupport } from "./features/document-symbols"; +import { config, documentInfo, documentSettings, documents } from "./globals"; +import { addDocumentFormattingSupport } from "./features/formatting"; +import { addValidationSupport } from "./features/validate"; +import { addDocumentCompletionSupport } from "./features/completions"; +import { addDocumentHoverSupport } from "./features/hover"; + +try { + // @ts-ignore + globalThis.LSP_GLOBALS = { + config, + documentInfo, + documentSettings, + documents, + }; +} catch (e) { + console.log(e); +} + +/* browser specific setup code */ +const messageReader = new BrowserMessageReader(self); +const messageWriter = new BrowserMessageWriter(self); +const connection = createConnection(messageReader, messageWriter); + +// +// Initialize the Language Server +// +connection.onInitialize((params: InitializeParams) => { + const capabilities = params.capabilities; + + // Does the client support the `workspace/configuration` request? + // If not, we fall back using global settings. + config.hasConfigurationCapability = !!( + capabilities.workspace && !!capabilities.workspace.configuration + ); + config.hasWorkspaceFolderCapability = !!( + capabilities.workspace && !!capabilities.workspace.workspaceFolders + ); + config.hasDiagnosticRelatedInformationCapability = !!( + capabilities.textDocument && + capabilities.textDocument.publishDiagnostics && + capabilities.textDocument.publishDiagnostics.relatedInformation + ); + + const result: InitializeResult = { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Incremental, + // Tell the client that this server supports code completion. + completionProvider: { + triggerCharacters: ["<", ".", "$", "/", '"', "'"], + resolveProvider: true, + }, + documentFormattingProvider: true, + foldingRangeProvider: true, + documentSymbolProvider: true, + hoverProvider: true, + }, + }; + if (config.hasWorkspaceFolderCapability) { + result.capabilities.workspace = { + workspaceFolders: { + supported: true, + }, + }; + } + return result; +}); + +connection.onInitialized(() => { + if (config.hasConfigurationCapability) { + // Register for all configuration changes. + connection.client.register( + DidChangeConfigurationNotification.type, + undefined, + ); + } + if (config.hasWorkspaceFolderCapability) { + connection.workspace.onDidChangeWorkspaceFolders((_event) => { + connection.console.log("Workspace folder change event received."); + }); + } +}); + +// Only keep settings for open documents +documents.onDidClose((e) => { + documentSettings.delete(e.document.uri); +}); + +connection.onDidChangeWatchedFiles((_change) => { + // Monitored files have change in VSCode + connection.console.log("We received an file change event"); +}); + +// +// Add language features +// +addValidationSupport(connection, documentInfo); +addFoldingRangeSupport(connection, documentInfo); +addDocumentSymbolsSupport(connection, documentInfo); +addDocumentFormattingSupport(connection, documentInfo); +addDocumentCompletionSupport(connection, documentInfo); +addDocumentHoverSupport(connection, documentInfo); + +// Make the text document manager listen on the connection +// for open, change and close text document events +documents.listen(connection); + +// Listen on the connection +connection.listen(); diff --git a/packages/lsp/test/language-server.test.ts b/packages/lsp/test/language-server.test.ts new file mode 100644 index 000000000..ce33b8d48 --- /dev/null +++ b/packages/lsp/test/language-server.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +// Required to use a worker inside a test +import "@vitest/web-worker"; +// @ts-ignore +import LSPWorker from "../src/index?worker"; +import util from "util"; +import { initWorker } from "./utils/init-message-connection"; + +const origLog = console.log; +console.log = (...args) => { + origLog(...args.map((x) => util.inspect(x, false, 10, true))); +}; + +describe("Doenet Language Server", async () => { + it("can initialize language server as a webworker", async () => { + const worker: Worker = new LSPWorker(); + const lspConn = await initWorker(worker); + await lspConn.textDocumentOpened({ + textDocument: { + uri: "file:///test.doenet", + languageId: "doenet", + version: 1, + text: "", + }, + }); + const diags = await new Promise((resolve) => { + lspConn.onDiagnostics((params) => { + resolve(params); + }); + }); + expect(diags).toMatchInlineSnapshot(` + { + "diagnostics": [ + { + "message": "Element \`\` doesn't have an attribute called \`xxx\`.", + "range": { + "end": { + "character": 10, + "line": 0, + }, + "start": { + "character": 7, + "line": 0, + }, + }, + "severity": 2, + }, + ], + "uri": "file:///test.doenet", + } + `); + }); + it("can get completions", async () => { + const worker: Worker = new LSPWorker(); + const lspConn = await initWorker(worker); + await lspConn.textDocumentOpened({ + textDocument: { + uri: "file:///test.doenet", + languageId: "doenet", + version: 1, + text: " { + const worker: Worker = new LSPWorker(); + const lspConn = await initWorker(worker); + await lspConn.textDocumentOpened({ + textDocument: { + uri: "file:///test.doenet", + languageId: "doenet", + version: 1, + text: "", + }, + }); + await lspConn.textDocumentChanged({ + textDocument: { uri: "file:///test.doenet", version: 2 }, + contentChanges: [ + { + text: "
{ + onChange={(val) => { setDoenetSource(val); }} - setInternalValueTo={INITIAL_DOENET_SOURCE} + value={INITIAL_DOENET_SOURCE} />
{ - const capabilities = params.capabilities; - - // Does the client support the `workspace/configuration` request? - // If not, we fall back using global settings. - config.hasConfigurationCapability = !!( - capabilities.workspace && !!capabilities.workspace.configuration - ); - config.hasWorkspaceFolderCapability = !!( - capabilities.workspace && !!capabilities.workspace.workspaceFolders - ); - config.hasDiagnosticRelatedInformationCapability = !!( - capabilities.textDocument && - capabilities.textDocument.publishDiagnostics && - capabilities.textDocument.publishDiagnostics.relatedInformation - ); - - const result: InitializeResult = { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Incremental, - // Tell the client that this server supports code completion. - completionProvider: { - resolveProvider: true, - }, - documentFormattingProvider: true, - foldingRangeProvider: true, - documentSymbolProvider: true, - hoverProvider: true, - }, - }; - if (config.hasWorkspaceFolderCapability) { - result.capabilities.workspace = { - workspaceFolders: { - supported: true, - }, - }; - } - return result; -}); - -connection.onInitialized(() => { - if (config.hasConfigurationCapability) { - // Register for all configuration changes. - connection.client.register( - DidChangeConfigurationNotification.type, - undefined, - ); - } - if (config.hasWorkspaceFolderCapability) { - connection.workspace.onDidChangeWorkspaceFolders((_event) => { - connection.console.log("Workspace folder change event received."); - }); - } -}); - -// Only keep settings for open documents -documents.onDidClose((e) => { - documentSettings.delete(e.document.uri); -}); - -connection.onDidChangeWatchedFiles((_change) => { - // Monitored files have change in VSCode - connection.console.log("We received an file change event"); -}); - -// -// Add language features -// -addFoldingRangeSupport(connection, documentInfo); -addDocumentSymbolsSupport(connection, documentInfo); -addDocumentFormattingSupport(connection, documentInfo); -addValidationSupport(connection, documentInfo); -addDocumentCompletionSupport(connection, documentInfo); -addDocumentHoverSupport(connection, documentInfo); - -// Make the text document manager listen on the connection -// for open, change and close text document events -documents.listen(connection); - -// Listen on the connection -connection.listen(); +export {}; diff --git a/packages/vscode-extension/test/language-server.test.ts b/packages/vscode-extension/test/language-server.test.ts index 732ca828c..300c3dd5f 100644 --- a/packages/vscode-extension/test/language-server.test.ts +++ b/packages/vscode-extension/test/language-server.test.ts @@ -1,53 +1,13 @@ import { describe, expect, it } from "vitest"; // Required to use a worker inside a test import "@vitest/web-worker"; -// @ts-ignore -import LSPWorker from "../src/language-server?worker"; import util from "util"; -import { initWorker } from "./utils/init-message-connection"; const origLog = console.log; console.log = (...args) => { origLog(...args.map((x) => util.inspect(x, false, 10, true))); }; -describe("Doenet Language Server", async () => { - it("can initialize language server as a webworker", async () => { - const worker: Worker = new LSPWorker(); - const lspConn = await initWorker(worker); - await lspConn.textDocumentOpened({ - textDocument: { - uri: "file:///test.doenet", - languageId: "doenet", - version: 1, - text: "", - }, - }); - const diags = await new Promise((resolve) => { - lspConn.onDiagnostics((params) => { - resolve(params); - }); - }); - expect(diags).toMatchInlineSnapshot(` - { - "diagnostics": [ - { - "message": "Element \`\` doesn't have an attribute called \`xxx\`.", - "range": { - "end": { - "character": 10, - "line": 0, - }, - "start": { - "character": 7, - "line": 0, - }, - }, - "severity": 2, - }, - ], - "uri": "file:///test.doenet", - } - `); - }); +describe("Doenet vscode extension", async () => { + it("empty test", async () => {}); }); diff --git a/tsconfig.build.json b/tsconfig.build.json index 4566cb702..a7dc98ce1 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -3,7 +3,7 @@ "**/*.test.ts", "**/*.stub.ts", "node_modules", - "**/tests/", + "**/test/", "**/dist/**/*", "vite.config.ts" ],