diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d88a42d..9b9539a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: run: | Xvfb -ac :99 -screen 0 1280x1024x16 & export DISPLAY=:99 - npm run test + npm run test:e2e - name: Package Extension run: npx vsce package diff --git a/package-lock.json b/package-lock.json index 683e8b4..0fdcc1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "esbuild": "^0.20.0", "eslint": "^8.55.0", "mocha": "^10.2.0", + "run-script-os": "^1.1.6", "ts-node": "^10.9.1", "typescript": "^5.3.3" }, @@ -2330,6 +2331,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/run-script-os": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/run-script-os/-/run-script-os-1.1.6.tgz", + "integrity": "sha512-ql6P2LzhBTTDfzKts+Qo4H94VUKpxKDFz6QxxwaUZN0mwvi7L3lpOI7BqPCq7lgDh3XLl0dpeXwfcVIitlrYrw==", + "dev": true, + "bin": { + "run-os": "index.js", + "run-script-os": "index.js" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", diff --git a/package.json b/package.json index ac83c30..4d9d769 100644 --- a/package.json +++ b/package.json @@ -50,9 +50,11 @@ "test-compile": "tsc -b ./", "lint": "eslint ./client/src ./server/src --ext .ts,.tsx", "postinstall": "cd client && npm install && cd ../server && npm install && cd ..", - "test": "npm run test-compile && sh ./scripts/e2e.sh", - "test:server": "cd server && npx mocha -r ts-node/register src/test/**/*.test.ts src/util/test/**/*.test.ts", - "all": "npm run postinstall && npm run esbuild && npm run lint && npm run test:server && npm run test && npm run vscode:prepublish" + "test:e2e": "run-script-os", + "test:e2e:win32": "npm run test-compile && powershell -File ./scripts/e2e.ps1", + "test:e2e:default": "npm run test-compile && sh ./scripts/e2e.sh", + "test:server": "cd server && npx mocha -r ts-node/register src/test/**/*.test.ts src/project/test/**/*.test.ts src/util/test/**/*.test.ts", + "all": "npm run postinstall && npm run esbuild && npm run lint && npm run test:server && npm run test:e2e && npm run vscode:prepublish" }, "devDependencies": { "@types/mocha": "^10.0.6", @@ -62,6 +64,7 @@ "esbuild": "^0.20.0", "eslint": "^8.55.0", "mocha": "^10.2.0", + "run-script-os": "^1.1.6", "ts-node": "^10.9.1", "typescript": "^5.3.3" } diff --git a/scripts/e2e.ps1 b/scripts/e2e.ps1 new file mode 100644 index 0000000..f6bc9ef --- /dev/null +++ b/scripts/e2e.ps1 @@ -0,0 +1,4 @@ +$env:CODE_TESTS_PATH = "$(Get-Location)\client\out\test" +$env:CODE_TESTS_WORKSPACE = "$(Get-Location)\client\testFixture" + +node "$(Get-Location)\client\out\test\runTest" diff --git a/server/package-lock.json b/server/package-lock.json index eee8282..81e6666 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "license": "OSMC-PL-1-8", "dependencies": { - "tree-sitter": "^0.20.6", + "tree-sitter": "^0.21.1", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.11", "web-tree-sitter": "^0.20.8" @@ -18,417 +18,37 @@ "node": "20" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "node_modules/node-addon-api": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.0.0.tgz", + "integrity": "sha512-ipO7rsHEBqa9STO5C5T10fj732ml+5kLN1cAG8/jdHd56ldQeGj3Q7+scUS+VHK/qy1zLEwC4wMK5+yM0btPvw==", + "license": "MIT", "engines": { - "node": ">=8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dependencies": { - "once": "^1.4.0" + "node": "^18 || ^20 || >= 21" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, - "node_modules/nan": { - "version": "2.18.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz", - "integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==" - }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" - }, - "node_modules/node-abi": { - "version": "3.52.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.52.0.tgz", - "integrity": "sha512-JJ98b02z16ILv7859irtXn4oUaFWADtvkzy2c0IAatNVX2Mc9Yoh8z6hZInn3QwvMEYhHuQloYi+TTQy67SIdQ==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, + "node_modules/node-gyp-build": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz", + "integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==", + "license": "MIT", "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" } }, "node_modules/tree-sitter": { - "version": "0.20.6", - "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.20.6.tgz", - "integrity": "sha512-GxJodajVpfgb3UREzzIbtA1hyRnTxVbWVXrbC6sk4xTMH5ERMBJk9HJNq4c8jOJeUaIOmLcwg+t6mez/PDvGqg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.21.1.tgz", + "integrity": "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ==", "hasInstallScript": true, + "license": "MIT", "dependencies": { - "nan": "^2.18.0", - "prebuild-install": "^7.1.1" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" + "node-addon-api": "^8.0.0", + "node-gyp-build": "^4.8.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -471,16 +91,6 @@ "version": "0.20.8", "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.20.8.tgz", "integrity": "sha512-weOVgZ3aAARgdnb220GqYuh7+rZU0Ka9k9yfKtGAzEYMa6GgiCzW9JjQRJyCJakvibQW+dfjJdihjInKuuCAUQ==" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } } diff --git a/server/package.json b/server/package.json index afe8908..7cca3dd 100644 --- a/server/package.json +++ b/server/package.json @@ -13,7 +13,7 @@ "url": "https://github.com/OpenModelica/modelica-language-server" }, "dependencies": { - "tree-sitter": "^0.20.6", + "tree-sitter": "^0.21.1", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.11", "web-tree-sitter": "^0.20.8" diff --git a/server/src/analyzer.ts b/server/src/analyzer.ts index a5d5ec2..4810a54 100644 --- a/server/src/analyzer.ts +++ b/server/src/analyzer.ts @@ -40,48 +40,88 @@ */ import * as LSP from 'vscode-languageserver/node'; -import { TextDocument } from 'vscode-languageserver-textdocument'; - -import Parser = require('web-tree-sitter'); - +import Parser from 'web-tree-sitter'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; +import * as url from 'node:url'; + +import { ModelicaDocument, ModelicaLibrary, ModelicaProject } from './project'; +import { uriToPath } from "./util"; import { getAllDeclarationsInTree } from './util/declarations'; import { logger } from './util/logger'; -type AnalyzedDocument = { - document: TextDocument; - declarations: LSP.SymbolInformation[]; - tree: Parser.Tree; -}; - export default class Analyzer { - #parser: Parser; - #uriToAnalyzedDocument: Record = {}; + #project: ModelicaProject; public constructor(parser: Parser) { - this.#parser = parser; + this.#project = new ModelicaProject(parser); } - public analyze(document: TextDocument): LSP.Diagnostic[] { - logger.debug('analyze:'); + /** + * Adds a library (and all of its documents) to the analyzer. + * + * @param uri uri to the library root + * @param isWorkspace `true` if this is a user workspace/project, `false` if + * this is a library. + */ + public async loadLibrary(uri: LSP.URI, isWorkspace: boolean): Promise { + const isLibrary = (folderPath: string) => + fsSync.existsSync(path.join(folderPath, 'package.mo')); + + const libraryPath = url.fileURLToPath(uri); + if (!isWorkspace || isLibrary(libraryPath)) { + const lib = await ModelicaLibrary.load(this.#project, libraryPath, isWorkspace); + this.#project.addLibrary(lib); + return; + } - const diagnostics: LSP.Diagnostic[] = []; - const fileContent = document.getText(); - const uri = document.uri; + // TODO: go deeper... something like `TreeSitterUtil.forEach` but for files + // would be good here + for (const nestedRelative of await fs.readdir(libraryPath)) { + const nested = path.resolve(nestedRelative); + if (!isLibrary(nested)) { + continue; + } - const tree = this.#parser.parse(fileContent); - logger.debug(tree.rootNode.toString()); + const library = await ModelicaLibrary.load(this.#project, nested, isWorkspace); + this.#project.addLibrary(library); + } + } - // Get declarations - const declarations = getAllDeclarationsInTree(tree, uri); + /** + * Adds a document to the analyzer. + * + * Note: {@link loadLibrary} already adds all discovered documents to the + * analyzer. It is only necessary to call this method on file creation. + * + * @param uri uri to document to add + * @throws if the document does not belong to a library + */ + public addDocument(uri: LSP.DocumentUri): void { + this.#project.addDocument(uriToPath(uri)); + } - // Update saved analysis for document uri - this.#uriToAnalyzedDocument[uri] = { - document, - declarations, - tree, - }; + /** + * Submits a modification to a document. Ignores documents that have not been + * added with {@link addDocument} or {@link loadLibrary}. + * + * @param uri uri to document to update + * @param text the modification + * @param range range to update, or `undefined` to replace the whole file + */ + public async updateDocument(uri: LSP.DocumentUri, text: string): Promise { + await this.#project.updateDocument(uriToPath(uri), text); + } - return diagnostics; + /** + * Removes a document from the analyzer. Ignores documents that have not been + * added or have already been removed. + * + * @param uri uri to document to remove + */ + public removeDocument(uri: LSP.DocumentUri): void { + this.#project.removeDocument(uriToPath(uri)); } /** @@ -89,8 +129,12 @@ export default class Analyzer { * * TODO: convert to DocumentSymbol[] which is a hierarchy of symbols found in a given text document. */ - public getDeclarationsForUri(uri: string): LSP.SymbolInformation[] { - const tree = this.#uriToAnalyzedDocument[uri]?.tree; + public async getDeclarationsForUri(uri: string): Promise { + // TODO: convert to DocumentSymbol[] which is a hierarchy of symbols found + // in a given text document. + const path = uriToPath(uri); + const document = await this.#project.getDocument(path); + const tree = document?.tree; if (!tree?.rootNode) { return []; diff --git a/server/src/project/document.ts b/server/src/project/document.ts new file mode 100644 index 0000000..ac17f42 --- /dev/null +++ b/server/src/project/document.ts @@ -0,0 +1,174 @@ +/* + * This file is part of OpenModelica. + * + * Copyright (c) 1998-2024, Open Source Modelica Consortium (OSMC), + * c/o Linköpings universitet, Department of Computer and Information Science, + * SE-58183 Linköping, Sweden. + * + * All rights reserved. + * + * THIS PROGRAM IS PROVIDED UNDER THE TERMS OF AGPL VERSION 3 LICENSE OR + * THIS OSMC PUBLIC LICENSE (OSMC-PL) VERSION 1.8. + * ANY USE, REPRODUCTION OR DISTRIBUTION OF THIS PROGRAM CONSTITUTES + * RECIPIENT'S ACCEPTANCE OF THE OSMC PUBLIC LICENSE OR THE GNU AGPL + * VERSION 3, ACCORDING TO RECIPIENTS CHOICE. + * + * The OpenModelica software and the OSMC (Open Source Modelica Consortium) + * Public License (OSMC-PL) are obtained from OSMC, either from the above + * address, from the URLs: + * http://www.openmodelica.org or + * https://github.com/OpenModelica/ or + * http://www.ida.liu.se/projects/OpenModelica, + * and in the OpenModelica distribution. + * + * GNU AGPL version 3 is obtained from: + * https://www.gnu.org/licenses/licenses.html#GPL + * + * This program is distributed WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE, EXCEPT AS EXPRESSLY SET FORTH + * IN THE BY RECIPIENT SELECTED SUBSIDIARY LICENSE CONDITIONS OF OSMC-PL. + * + * See the full OSMC Public License conditions for more details. + * + */ + +import { TextDocument } from 'vscode-languageserver-textdocument'; +import * as LSP from 'vscode-languageserver/node'; +import Parser from 'web-tree-sitter'; +import * as fs from 'node:fs/promises'; +import * as TreeSitterUtil from '../util/tree-sitter'; + +import { pathToUri, uriToPath } from '../util'; +import { logger } from '../util/logger'; +import { ModelicaLibrary } from './library'; +import { ModelicaProject } from './project'; + +export class ModelicaDocument implements TextDocument { + readonly #project: ModelicaProject; + readonly #library: ModelicaLibrary | null; + readonly #document: TextDocument; + #tree: Parser.Tree; + + public constructor(project: ModelicaProject, library: ModelicaLibrary | null, document: TextDocument, tree: Parser.Tree) { + this.#project = project; + this.#library = library; + this.#document = document; + this.#tree = tree; + } + + /** + * Loads a document. + * + * @param project the {@link ModelicaProject} + * @param library the containing {@link ModelicaLibrary} (or `null` if not a part of one) + * @param documentPath the path to the document + * @returns the document + */ + public static async load( + project: ModelicaProject, + library: ModelicaLibrary | null, + documentPath: string, + ): Promise { + logger.debug(`Loading document at '${documentPath}'...`); + + try { + const content = await fs.readFile(documentPath, 'utf-8'); + + const uri = pathToUri(documentPath); + const document = TextDocument.create(uri, 'modelica', 0, content); + + const tree = project.parser.parse(content); + + return new ModelicaDocument(project, library, document, tree); + } catch (err) { + throw new Error( + `Failed to load document at '${documentPath}': ${err instanceof Error ? err.message : err}`, + ); + } + } + + /** + * Updates a document. + * @param text the modification + */ + public async update(text: string): Promise { + TextDocument.update(this.#document, [{ text }], this.version + 1); + this.#tree = this.project.parser.parse(text); + return; + } + + public getText(range?: LSP.Range | undefined): string { + return this.#document.getText(range); + } + + public positionAt(offset: number): LSP.Position { + return this.#document.positionAt(offset); + } + + public offsetAt(position: LSP.Position): number { + return this.#document.offsetAt(position); + } + + public get uri(): LSP.DocumentUri { + return this.#document.uri; + } + + public get path(): string { + return uriToPath(this.#document.uri); + } + + public get languageId(): string { + return this.#document.languageId; + } + + public get version(): number { + return this.#document.version; + } + + public get lineCount(): number { + return this.#document.lineCount; + } + + /** + * The enclosing package of the class declared by this file. For instance, for + * a file named `MyLibrary/MyPackage/MyClass.mo`, this should be `["MyLibrary", + * "MyPackage"]`. + */ + public get within(): string[] { + const withinClause = this.#tree.rootNode.children + .find((node) => node.type === 'within_clause') + ?.childForFieldName("name"); + if (!withinClause) { + return []; + } + + // TODO: Use a helper function from TreeSitterUtil + const identifiers: string[] = []; + TreeSitterUtil.forEach(withinClause, (node) => { + if (node.type === "name") { + return true; + } + + if (node.type === "IDENT") { + identifiers.push(node.text); + } + + return false; + }); + + return identifiers; + } + + public get project(): ModelicaProject { + return this.#project; + } + + public get library(): ModelicaLibrary | null { + return this.#library; + } + + public get tree(): Parser.Tree { + return this.#tree; + } +} diff --git a/server/src/project/index.ts b/server/src/project/index.ts new file mode 100644 index 0000000..a80780b --- /dev/null +++ b/server/src/project/index.ts @@ -0,0 +1,3 @@ +export { ModelicaDocument } from "./document"; +export { ModelicaLibrary } from "./library"; +export { ModelicaProject } from "./project"; diff --git a/server/src/project/library.ts b/server/src/project/library.ts new file mode 100644 index 0000000..fb1175d --- /dev/null +++ b/server/src/project/library.ts @@ -0,0 +1,117 @@ +/* + * This file is part of OpenModelica. + * + * Copyright (c) 1998-2024, Open Source Modelica Consortium (OSMC), + * c/o Linköpings universitet, Department of Computer and Information Science, + * SE-58183 Linköping, Sweden. + * + * All rights reserved. + * + * THIS PROGRAM IS PROVIDED UNDER THE TERMS OF AGPL VERSION 3 LICENSE OR + * THIS OSMC PUBLIC LICENSE (OSMC-PL) VERSION 1.8. + * ANY USE, REPRODUCTION OR DISTRIBUTION OF THIS PROGRAM CONSTITUTES + * RECIPIENT'S ACCEPTANCE OF THE OSMC PUBLIC LICENSE OR THE GNU AGPL + * VERSION 3, ACCORDING TO RECIPIENTS CHOICE. + * + * The OpenModelica software and the OSMC (Open Source Modelica Consortium) + * Public License (OSMC-PL) are obtained from OSMC, either from the above + * address, from the URLs: + * http://www.openmodelica.org or + * https://github.com/OpenModelica/ or + * http://www.ida.liu.se/projects/OpenModelica, + * and in the OpenModelica distribution. + * + * GNU AGPL version 3 is obtained from: + * https://www.gnu.org/licenses/licenses.html#GPL + * + * This program is distributed WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE, EXCEPT AS EXPRESSLY SET FORTH + * IN THE BY RECIPIENT SELECTED SUBSIDIARY LICENSE CONDITIONS OF OSMC-PL. + * + * See the full OSMC Public License conditions for more details. + * + */ + +import * as LSP from "vscode-languageserver"; +import * as fsWalk from "@nodelib/fs.walk"; +import * as path from "node:path"; +import * as util from "node:util"; + +import { logger } from '../util/logger'; +import { ModelicaDocument } from "./document"; +import { ModelicaProject } from "./project"; + +export class ModelicaLibrary { + readonly #project: ModelicaProject; + readonly #documents: Map; + readonly #isWorkspace: boolean; + #path: string; + + public constructor(project: ModelicaProject, libraryPath: string, isWorkspace: boolean) { + this.#project = project; + this.#path = libraryPath, + this.#documents = new Map(); + this.#isWorkspace = isWorkspace; + } + + /** + * Loads a library and all of its {@link ModelicaDocument}s. + * + * @param project the containing project + * @param libraryPath the path to the library + * @param isWorkspace `true` if this is a user workspace + * @returns the loaded library + */ + public static async load( + project: ModelicaProject, + libraryPath: string, + isWorkspace: boolean, + ): Promise { + logger.info(`Loading ${isWorkspace ? 'workspace' : 'library'} at '${libraryPath}'...`); + + const library = new ModelicaLibrary(project, libraryPath, isWorkspace); + const workspaceRootDocument = await ModelicaDocument.load(project, library, path.join(libraryPath, 'package.mo')); + + // Find the root path of the library and update library.#path. + // It might have been set incorrectly if we opened a child folder. + for (let i = 0; i < workspaceRootDocument.within.length; i++) { + library.#path = path.dirname(library.#path); + } + + logger.debug(`Set library path to ${library.path}`); + + const walk = util.promisify(fsWalk.walk); + const entries = await walk(library.#path, { + entryFilter: (entry) => !!entry.name.match(/.*\.mo/) && !entry.dirent.isDirectory(), + }); + + for (const entry of entries) { + const document = await ModelicaDocument.load(project, library, entry.path); + library.#documents.set(entry.path, document); + } + + logger.debug(`Loaded ${library.#documents.size} documents`); + return library; + } + + public get name(): string { + return path.basename(this.path); + } + + public get path(): string { + return this.#path; + } + + public get project(): ModelicaProject { + return this.#project; + } + + public get documents(): Map { + return this.#documents; + } + + public get isWorkspace(): boolean { + return this.#isWorkspace; + } +} diff --git a/server/src/project/project.ts b/server/src/project/project.ts new file mode 100644 index 0000000..8c06622 --- /dev/null +++ b/server/src/project/project.ts @@ -0,0 +1,195 @@ +/* + * This file is part of OpenModelica. + * + * Copyright (c) 1998-2024, Open Source Modelica Consortium (OSMC), + * c/o Linköpings universitet, Department of Computer and Information Science, + * SE-58183 Linköping, Sweden. + * + * All rights reserved. + * + * THIS PROGRAM IS PROVIDED UNDER THE TERMS OF AGPL VERSION 3 LICENSE OR + * THIS OSMC PUBLIC LICENSE (OSMC-PL) VERSION 1.8. + * ANY USE, REPRODUCTION OR DISTRIBUTION OF THIS PROGRAM CONSTITUTES + * RECIPIENT'S ACCEPTANCE OF THE OSMC PUBLIC LICENSE OR THE GNU AGPL + * VERSION 3, ACCORDING TO RECIPIENTS CHOICE. + * + * The OpenModelica software and the OSMC (Open Source Modelica Consortium) + * Public License (OSMC-PL) are obtained from OSMC, either from the above + * address, from the URLs: + * http://www.openmodelica.org or + * https://github.com/OpenModelica/ or + * http://www.ida.liu.se/projects/OpenModelica, + * and in the OpenModelica distribution. + * + * GNU AGPL version 3 is obtained from: + * https://www.gnu.org/licenses/licenses.html#GPL + * + * This program is distributed WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE, EXCEPT AS EXPRESSLY SET FORTH + * IN THE BY RECIPIENT SELECTED SUBSIDIARY LICENSE CONDITIONS OF OSMC-PL. + * + * See the full OSMC Public License conditions for more details. + * + */ + +import Parser from "web-tree-sitter"; +import * as LSP from "vscode-languageserver"; +import url from "node:url"; +import path from "node:path"; + +import { ModelicaLibrary } from "./library"; +import { ModelicaDocument } from './document'; +import { logger } from "../util/logger"; + +/** Options for {@link ModelicaProject.getDocument} */ +export interface GetDocumentOptions { + /** + * `true` to try loading the document from disk if it is not already loaded. + * + * Default value: `true`. + */ + load?: boolean, +} + +export class ModelicaProject { + readonly #parser: Parser; + readonly #libraries: ModelicaLibrary[]; + + public constructor(parser: Parser) { + this.#parser = parser; + this.#libraries = []; + } + + public get libraries(): readonly ModelicaLibrary[] { + return this.#libraries; + } + + public addLibrary(library: ModelicaLibrary) { + this.#libraries.push(library); + } + + /** + * Finds the document identified by the given path. + * + * Will load the document from disk if unloaded and `options.load` is `true` or `undefined`. + * + * @param documentPath file path pointing to the document + * @param options + * @returns the document, or `undefined` if no such document exists + */ + public async getDocument(documentPath: string, options?: GetDocumentOptions): Promise { + let loadedDocument: ModelicaDocument | undefined = undefined; + for (const library of this.#libraries) { + loadedDocument = library.documents.get(documentPath); + if (loadedDocument) { + logger.debug(`Found document: ${documentPath}`); + break; + } + } + + if (loadedDocument) { + return loadedDocument; + } + + if (options?.load !== false) { + const newDocument = await this.addDocument(documentPath); + if (newDocument) { + return newDocument; + } + } + + logger.debug(`Couldn't find document: ${documentPath}`); + + return undefined; + } + + /** + * Adds a new document to the LSP. Calling this method multiple times for the + * same document has no effect. + * + * @param documentPath path to the document + * @returns the document, or undefined if it wasn't added + * @throws if the document does not belong to a library + */ + public async addDocument(documentPath: string): Promise { + logger.info(`Adding document at '${documentPath}'...`); + + for (const library of this.#libraries) { + const relative = path.relative(library.path, documentPath); + const isSubdirectory = relative && !relative.startsWith("..") && !path.isAbsolute(relative); + + // Assume that files can't be inside multiple libraries at the same time + if (!isSubdirectory) { + continue; + } + + if (library.documents.get(documentPath) !== undefined) { + logger.warn(`Document '${documentPath}' already in library '${library.name}'; ignoring...`); + return undefined; + } + + const document = await ModelicaDocument.load(this, library, documentPath); + library.documents.set(documentPath, document); + logger.debug(`Added document: ${documentPath}`); + return document; + } + + // If the document doesn't belong to a library, it could still be loaded + // as a standalone document if it has an empty or non-existent within clause + const document = await ModelicaDocument.load(this, null, documentPath); + if (document.within.length === 0) { + logger.debug(`Added document: ${documentPath}`); + return document; + } + + logger.debug(`Failed to add document '${documentPath}': not a part of any libraries.`); + return undefined; + } + + /** + * Updates the content and tree of the given document. Does nothing and + * returns `false` if the document was not found. + * + * @param documentPath path to the document + * @param text the modification + * @returns if the document was updated + */ + public async updateDocument(documentPath: string, text: string): Promise { + logger.debug(`Updating document at '${documentPath}'...`); + + const doc = await this.getDocument(documentPath, { load: true }); + if (doc) { + doc.update(text); + logger.debug(`Updated document '${documentPath}'`); + return true; + } else { + logger.warn(`Failed to update document '${documentPath}': not found`); + return false; + } + } + + /** + * Removes a document from the cache. + * + * @param documentPath path to the document + * @returns if the document was removed + */ + public async removeDocument(documentPath: string): Promise { + logger.info(`Removing document at '${documentPath}'...`); + + const doc = await this.getDocument(documentPath, { load: false }); + if (doc) { + doc.library?.documents.delete(documentPath); + return true; + } else { + logger.warn(`Failed to remove document '${documentPath}': not found`); + return false; + } + } + + public get parser(): Parser { + return this.#parser; + } + +} diff --git a/server/src/project/test/TestLibrary/HalfAdder.mo b/server/src/project/test/TestLibrary/HalfAdder.mo new file mode 100644 index 0000000..2bb82d1 --- /dev/null +++ b/server/src/project/test/TestLibrary/HalfAdder.mo @@ -0,0 +1,21 @@ +within TestLibrary; + +import Modelica.Electrical.Digital.Interfaces.{DigitalInput, DigitalOutput}; +import Modelica.Electrical.Digital.Gates.{AndGate, XorGate}; + +model HalfAdder + DigitalInput a; + DigitalInput b; + DigitalOutput s; + DigitalOutput c; +protected + AndGate andGate; + XorGate xorGate; +equation + connect(andGate,y, c); + connect(xorGate.y, s); + connect(b, andGate.x[1]); + connect(b, xorGate.x[1]); + connect(a, xorGate.x[2]); + connect(a, andGate.x[2]); +end HalfAdder; diff --git a/server/src/project/test/TestLibrary/package.mo b/server/src/project/test/TestLibrary/package.mo new file mode 100644 index 0000000..8493bc1 --- /dev/null +++ b/server/src/project/test/TestLibrary/package.mo @@ -0,0 +1,3 @@ +package TestLibrary + annotation(version="1.0.0"); +end TestLibrary; diff --git a/server/src/project/test/document.test.ts b/server/src/project/test/document.test.ts new file mode 100644 index 0000000..09f7431 --- /dev/null +++ b/server/src/project/test/document.test.ts @@ -0,0 +1,100 @@ +/* + * This file is part of OpenModelica. + * + * Copyright (c) 1998-2024, Open Source Modelica Consortium (OSMC), + * c/o Linköpings universitet, Department of Computer and Information Science, + * SE-58183 Linköping, Sweden. + * + * All rights reserved. + * + * THIS PROGRAM IS PROVIDED UNDER THE TERMS OF AGPL VERSION 3 LICENSE OR + * THIS OSMC PUBLIC LICENSE (OSMC-PL) VERSION 1.8. + * ANY USE, REPRODUCTION OR DISTRIBUTION OF THIS PROGRAM CONSTITUTES + * RECIPIENT'S ACCEPTANCE OF THE OSMC PUBLIC LICENSE OR THE GNU AGPL + * VERSION 3, ACCORDING TO RECIPIENTS CHOICE. + * + * The OpenModelica software and the OSMC (Open Source Modelica Consortium) + * Public License (OSMC-PL) are obtained from OSMC, either from the above + * address, from the URLs: + * http://www.openmodelica.org or + * https://github.com/OpenModelica/ or + * http://www.ida.liu.se/projects/OpenModelica, + * and in the OpenModelica distribution. + * + * GNU AGPL version 3 is obtained from: + * https://www.gnu.org/licenses/licenses.html#GPL + * + * This program is distributed WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE, EXCEPT AS EXPRESSLY SET FORTH + * IN THE BY RECIPIENT SELECTED SUBSIDIARY LICENSE CONDITIONS OF OSMC-PL. + * + * See the full OSMC Public License conditions for more details. + * + */ + +import * as assert from 'node:assert/strict'; +import * as path from 'node:path'; +import { ModelicaProject, ModelicaLibrary, ModelicaDocument } from '..'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { initializeParser } from '../../parser'; +import { pathToUri } from '../../util'; + +// Fake directory path +const TEST_PACKAGE_ROOT = path.join(__dirname, 'TestPackage'); +const TEST_PACKAGE_CONTENT = `package TestPackage + annotation(version="1.0.0"); +end Test; +`; +const UPDATED_TEST_PACKAGE_CONTENT = `package TestPackage + annotation(version="1.0.1"); +end Test; +`; +const TEST_CLASS_CONTENT = `within TestPackage.Foo.Bar; + +class Frobnicator +end Frobnicator; +`; + +function createTextDocument(filePath: string, content: string): TextDocument { + const absolutePath = path.join(TEST_PACKAGE_ROOT, filePath); + const uri = pathToUri(absolutePath); + return TextDocument.create(uri, 'modelica', 0, content); +} + +describe('ModelicaDocument', () => { + let project: ModelicaProject; + let library: ModelicaLibrary; + + beforeEach(async () => { + const parser = await initializeParser(); + project = new ModelicaProject(parser); + project.addLibrary(library); + library = new ModelicaLibrary(project, TEST_PACKAGE_ROOT, true); + }); + + it('can update the entire document', () => { + const textDocument = createTextDocument('.', TEST_PACKAGE_CONTENT); + const tree = project.parser.parse(TEST_PACKAGE_CONTENT); + const document = new ModelicaDocument(project, library, textDocument, tree); + document.update(UPDATED_TEST_PACKAGE_CONTENT); + + assert.equal(document.getText().trim(), UPDATED_TEST_PACKAGE_CONTENT.trim()); + }); + + it('a file with no `within` clause has the correct package path', () => { + const textDocument = createTextDocument('./package.mo', TEST_PACKAGE_CONTENT); + const tree = project.parser.parse(TEST_PACKAGE_CONTENT); + const document = new ModelicaDocument(project, library, textDocument, tree); + + assert.deepEqual(document.within, []); + }); + + it('a file with a `within` clause has the correct package path', () => { + const textDocument = createTextDocument('./Foo/Bar/Frobnicator.mo', TEST_CLASS_CONTENT); + const tree = project.parser.parse(TEST_CLASS_CONTENT); + const document = new ModelicaDocument(project, library, textDocument, tree); + + assert.deepEqual(document.within, ['TestPackage', 'Foo', 'Bar']); + }); +}); diff --git a/server/src/project/test/project.test.ts b/server/src/project/test/project.test.ts new file mode 100644 index 0000000..9a75fd3 --- /dev/null +++ b/server/src/project/test/project.test.ts @@ -0,0 +1,134 @@ +/* + * This file is part of OpenModelica. + * + * Copyright (c) 1998-2024, Open Source Modelica Consortium (OSMC), + * c/o Linköpings universitet, Department of Computer and Information Science, + * SE-58183 Linköping, Sweden. + * + * All rights reserved. + * + * THIS PROGRAM IS PROVIDED UNDER THE TERMS OF AGPL VERSION 3 LICENSE OR + * THIS OSMC PUBLIC LICENSE (OSMC-PL) VERSION 1.8. + * ANY USE, REPRODUCTION OR DISTRIBUTION OF THIS PROGRAM CONSTITUTES + * RECIPIENT'S ACCEPTANCE OF THE OSMC PUBLIC LICENSE OR THE GNU AGPL + * VERSION 3, ACCORDING TO RECIPIENTS CHOICE. + * + * The OpenModelica software and the OSMC (Open Source Modelica Consortium) + * Public License (OSMC-PL) are obtained from OSMC, either from the above + * address, from the URLs: + * http://www.openmodelica.org or + * https://github.com/OpenModelica/ or + * http://www.ida.liu.se/projects/OpenModelica, + * and in the OpenModelica distribution. + * + * GNU AGPL version 3 is obtained from: + * https://www.gnu.org/licenses/licenses.html#GPL + * + * This program is distributed WITHOUT ANY WARRANTY; without + * even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE, EXCEPT AS EXPRESSLY SET FORTH + * IN THE BY RECIPIENT SELECTED SUBSIDIARY LICENSE CONDITIONS OF OSMC-PL. + * + * See the full OSMC Public License conditions for more details. + * + */ + +import Parser from 'web-tree-sitter'; +import { ModelicaProject, ModelicaLibrary } from '..'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { initializeParser } from '../../parser'; + +const TEST_LIBRARY_PATH = path.join(__dirname, 'TestLibrary'); +const TEST_PACKAGE_PATH = path.join(TEST_LIBRARY_PATH, 'package.mo'); +const TEST_CLASS_PATH = path.join(TEST_LIBRARY_PATH, 'HalfAdder.mo'); + +const TEST_PACKAGE_CONTENT = `package TestLibrary + annotation(version="1.0.0"); +end TestLibrary; +`; + +describe('ModelicaProject', () => { + describe('an empty project', () => { + let project: ModelicaProject; + + beforeEach(async () => { + const parser = await initializeParser(); + project = new ModelicaProject(parser); + }); + + it('should have no libraries', () => { + assert.equal(project.libraries.length, 0); + }); + + it('updating and deleting documents does nothing', async () => { + assert(!await project.updateDocument(TEST_CLASS_PATH, 'file content')); + assert(!await project.removeDocument(TEST_CLASS_PATH)); + }); + }); + + describe('when adding a library', async () => { + let project: ModelicaProject; + let library: ModelicaLibrary; + + beforeEach(async () => { + const parser = await initializeParser(); + project = new ModelicaProject(parser); + library = await ModelicaLibrary.load(project, TEST_LIBRARY_PATH, false); + project.addLibrary(library); + }); + + it('should add the library', () => { + assert.equal(project.libraries.length, 1); + assert.equal(project.libraries[0], library); + }); + + it('should add all the documents in the library', async () => { + assert.notEqual(await project.getDocument(TEST_PACKAGE_PATH), undefined); + assert.notEqual(await project.getDocument(TEST_CLASS_PATH), undefined); + + assert.equal( + library.documents.get(TEST_PACKAGE_PATH), + await project.getDocument(TEST_PACKAGE_PATH), + ); + assert.equal(library.documents.get(TEST_CLASS_PATH), await project.getDocument(TEST_CLASS_PATH)); + }); + + it('repeatedly adding documents has no effect', async () => { + for (let i = 0; i < 5; i++) { + assert(!(await project.addDocument(TEST_PACKAGE_PATH))); + assert(!(await project.addDocument(TEST_CLASS_PATH))); + } + }); + + it('documents can be updated', async () => { + const document = (await project.getDocument(TEST_PACKAGE_PATH))!; + assert.equal( + document.getText().replace(/\r\n/g, '\n'), + TEST_PACKAGE_CONTENT.replace(/\r\n/g, '\n'), + ); + + const newContent = `within; + +package TestLibrary + annotation(version="1.0.1"); +end TestLibrary; +`; + assert(await project.updateDocument(document.path, newContent)); + assert.equal(document.getText(), newContent); + }); + + it('documents can be removed (and re-added)', async () => { + assert.notEqual(await project.getDocument(TEST_CLASS_PATH), undefined); + + assert(await project.removeDocument(TEST_CLASS_PATH)); + + // no effect -- already removed + assert(!await project.removeDocument(TEST_CLASS_PATH)); + + // can re-add document without issues + assert(await project.addDocument(TEST_CLASS_PATH)); + assert.notEqual(await project.getDocument(TEST_CLASS_PATH), undefined); + }); + }); +}); diff --git a/server/src/server.ts b/server/src/server.ts index f16d085..a4ec4ea 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -41,10 +41,12 @@ import * as LSP from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; +import url from 'node:url'; +import fs from 'node:fs/promises'; import { initializeParser } from './parser'; import Analyzer from './analyzer'; -import { logger, setLogConnection, setLogLevel } from './util/logger'; +import { logger, setLoggerOptions } from './util/logger'; /** * ModelicaServer collection all the important bits and bobs. @@ -67,20 +69,26 @@ export class ModelicaServer { public static async initialize( connection: LSP.Connection, - { capabilities }: LSP.InitializeParams, + { capabilities, workspaceFolders }: LSP.InitializeParams, ): Promise { // Initialize logger - setLogConnection(connection); - setLogLevel('debug'); + setLoggerOptions({ + connection, + logLevel: 'debug', + }); logger.debug('Initializing...'); const parser = await initializeParser(); const analyzer = new Analyzer(parser); - - const server = new ModelicaServer(analyzer, capabilities, connection); + if (workspaceFolders != null) { + for (const workspace of workspaceFolders) { + await analyzer.loadLibrary(workspace.uri, true); + } + } + // TODO: add libraries as well logger.debug('Initialized'); - return server; + return new ModelicaServer(analyzer, capabilities, connection); } /** @@ -88,52 +96,85 @@ export class ModelicaServer { */ public capabilities(): LSP.ServerCapabilities { return { - textDocumentSync: LSP.TextDocumentSyncKind.Full, completionProvider: undefined, hoverProvider: false, signatureHelpProvider: undefined, documentSymbolProvider: true, colorProvider: false, semanticTokensProvider: undefined, + textDocumentSync: LSP.TextDocumentSyncKind.Full, + workspace: { + workspaceFolders: { + supported: true, + changeNotifications: true, + }, + }, }; } public register(connection: LSP.Connection): void { - let currentDocument: TextDocument | null = null; - let initialized = false; - // Make the text document manager listen on the connection // for open, change and close text document events this.#documents.listen(this.#connection); + connection.onInitialized(this.onInitialized.bind(this)); + connection.onShutdown(this.onShutdown.bind(this)); + connection.onDidChangeTextDocument(this.onDidChangeTextDocument.bind(this)); + connection.onDidChangeWatchedFiles(this.onDidChangeWatchedFiles.bind(this)); connection.onDocumentSymbol(this.onDocumentSymbol.bind(this)); + } - connection.onInitialized(async () => { - initialized = true; - if (currentDocument) { - // If we already have a document, analyze it now that we're initialized - // and the linter is ready. - this.analyzeDocument(currentDocument); - } - }); - - // The content of a text document has changed. This event is emitted - // when the text document first opened or when its content has changed. - this.#documents.onDidChangeContent(({ document }) => { - logger.debug('onDidChangeContent'); + private async onInitialized(): Promise { + logger.debug('onInitialized'); + await connection.client.register( + new LSP.ProtocolNotificationType('workspace/didChangeWatchedFiles'), + { + watchers: [ + { + globPattern: '**/*.{mo,mos}', + }, + ], + }, + ); + + // If we opened a project, analyze it now that we're initialized + // and the linter is ready. + + // TODO: analysis + } - // We need to define some timing to wait some time or until whitespace is typed - // to update the tree or we are doing this on every key stroke + private async onShutdown(): Promise { + logger.debug('onShutdown'); + } - currentDocument = document; - if (initialized) { - this.analyzeDocument(document); - } - }); + private async onDidChangeTextDocument(params: LSP.DidChangeTextDocumentParams): Promise { + logger.debug('onDidChangeTextDocument'); + for (const change of params.contentChanges) { + await this.#analyzer.updateDocument(params.textDocument.uri, change.text); + } } - private async analyzeDocument(document: TextDocument) { - const diagnostics = this.#analyzer.analyze(document); + private async onDidChangeWatchedFiles(params: LSP.DidChangeWatchedFilesParams): Promise { + logger.debug('onDidChangeWatchedFiles: ' + JSON.stringify(params, undefined, 4)); + + for (const change of params.changes) { + switch (change.type) { + case LSP.FileChangeType.Created: + this.#analyzer.addDocument(change.uri); + break; + case LSP.FileChangeType.Changed: { + // TODO: incremental? + const path = url.fileURLToPath(change.uri); + const content = await fs.readFile(path, 'utf-8'); + await this.#analyzer.updateDocument(change.uri, content); + break; + } + case LSP.FileChangeType.Deleted: { + this.#analyzer.removeDocument(change.uri); + break; + } + } + } } /** @@ -142,7 +183,7 @@ export class ModelicaServer { * @param params Unused. * @returns Symbol information. */ - private onDocumentSymbol(params: LSP.DocumentSymbolParams): LSP.SymbolInformation[] { + private async onDocumentSymbol(params: LSP.DocumentSymbolParams): Promise { // TODO: ideally this should return LSP.DocumentSymbol[] instead of LSP.SymbolInformation[] // which is a hierarchy of symbols. // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol diff --git a/server/src/util/index.ts b/server/src/util/index.ts new file mode 100644 index 0000000..0017b94 --- /dev/null +++ b/server/src/util/index.ts @@ -0,0 +1,14 @@ +import * as url from "node:url"; +import * as LSP from "vscode-languageserver"; + + +export const uriToPath = url.fileURLToPath; + +export function pathToUri(filePath: string): LSP.URI { + const uri = url.pathToFileURL(filePath).href; + + // Note: LSP sends us file uris containing '%3A' instead of ':', but the + // node pathToFileURL uses ':' anyways. We manually fix this here. This is a + // bit hacky but it works. + return uri.slice(0, 5) + uri.slice(5).replace(":", "%3A"); +} diff --git a/server/src/util/logger.ts b/server/src/util/logger.ts index 2e33c09..4eeeb2d 100644 --- a/server/src/util/logger.ts +++ b/server/src/util/logger.ts @@ -45,7 +45,7 @@ export const LOG_LEVEL_ENV_VAR = 'MODELICA_IDE_LOG_LEVEL'; export const LOG_LEVELS = ['debug', 'log', 'info', 'warning', 'error'] as const; export const DEFAULT_LOG_LEVEL: LogLevel = 'info'; -type LogLevel = (typeof LOG_LEVELS)[number]; +export type LogLevel = (typeof LOG_LEVELS)[number]; const LOG_LEVELS_TO_MESSAGE_TYPES: { [logLevel in LogLevel]: LSP.MessageType; @@ -57,22 +57,35 @@ const LOG_LEVELS_TO_MESSAGE_TYPES: { error: LSP.MessageType.Error, } as const; -// Singleton madness to allow for logging from anywhere in the codebase -let _connection: LSP.Connection | null = null; -let _logLevel: LSP.MessageType = getLogLevelFromEnvironment(); - -/** - * Set the log connection. Should be done at startup. - */ -export function setLogConnection(connection: LSP.Connection) { - _connection = connection; +export interface LoggerOptions { + /** + * The connection to the LSP client. If unset, will not log to the client. + * + * Default: `null` + */ + connection?: LSP.Connection | null; + /** + * The minimum log level. + * + * Default: use the environment variable {@link LOG_LEVEL_ENV_VAR}, or {@link DEFAULT_LOG_LEVEL} if unset. + */ + logLevel?: LogLevel; + /** + * `true` to log locally as well as to the LSP client. + * + * Default: `false` + */ + useLocalLogging?: boolean; } +// Singleton madness to allow for logging from anywhere in the codebase +let _options: LoggerOptions = {}; + /** - * Set the minimum log level. + * Sets the logger options. Should be done at startup. */ -export function setLogLevel(logLevel: LogLevel) { - _logLevel = LOG_LEVELS_TO_MESSAGE_TYPES[logLevel]; +export function setLoggerOptions(options: LoggerOptions) { + _options = options; } export class Logger { @@ -90,14 +103,18 @@ export class Logger { [LSP.MessageType.Debug]: 'DEBUG', }; - public log(severity: LSP.MessageType, messageObjects: any[]) { - if (_logLevel < severity) { - return; - } + static MESSAGE_TYPE_TO_LOG_FUNCTION: Record void> = { + [LSP.MessageType.Error]: console.error, + [LSP.MessageType.Warning]: console.warn, + [LSP.MessageType.Info]: console.info, + [LSP.MessageType.Log]: console.log, + [LSP.MessageType.Debug]: console.debug, + }; - if (!_connection) { - // eslint-disable-next-line no-console - console.warn(`The logger's LSP Connection is not set. Dropping messages`); + public log(severity: LSP.MessageType, messageObjects: any[]) { + const logLevelString = _options.logLevel ?? getLogLevelFromEnvironment(); + const logLevel = LOG_LEVELS_TO_MESSAGE_TYPES[logLevelString]; + if (logLevel < severity) { return; } @@ -120,10 +137,17 @@ export class Logger { const time = new Date().toISOString().substring(11, 23); const message = `${time} ${level} ${prefix}${formattedMessage}`; - _connection.sendNotification(LSP.LogMessageNotification.type, { - type: severity, - message, - }); + if (_options.connection) { + _options.connection.sendNotification(LSP.LogMessageNotification.type, { + type: severity, + message, + }); + } + + if (_options.useLocalLogging) { + const log = Logger.MESSAGE_TYPE_TO_LOG_FUNCTION[logLevel]; + log(message); + } } public debug(message: string, ...additionalArgs: any[]) { @@ -149,20 +173,19 @@ export const logger = new Logger(); * Get the log level from the environment, before the server initializes. * Should only be used internally. */ -export function getLogLevelFromEnvironment(): LSP.MessageType { - const logLevelFromEnvironment = process.env[LOG_LEVEL_ENV_VAR] as LogLevel | undefined; - if (logLevelFromEnvironment) { - const logLevel = LOG_LEVELS_TO_MESSAGE_TYPES[logLevelFromEnvironment]; - if (logLevel) { - return logLevel; +function getLogLevelFromEnvironment(): LogLevel { + const logLevel = process.env[LOG_LEVEL_ENV_VAR]; + if (logLevel) { + if (logLevel in LOG_LEVELS_TO_MESSAGE_TYPES) { + return logLevel as LogLevel; } // eslint-disable-next-line no-console console.warn( - `Invalid ${LOG_LEVEL_ENV_VAR} "${logLevelFromEnvironment}", expected one of: ${Object.keys( + `Invalid ${LOG_LEVEL_ENV_VAR} "${logLevel}", expected one of: ${Object.keys( LOG_LEVELS_TO_MESSAGE_TYPES, ).join(', ')}`, ); } - return LOG_LEVELS_TO_MESSAGE_TYPES[DEFAULT_LOG_LEVEL]; + return DEFAULT_LOG_LEVEL; }