diff --git a/README.md b/README.md index 3c8115e..183d017 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ MATLAB language server implements several Language Server Protocol features and * Go to definition — [definitionProvider](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_definition) * Go to references — [referencesProvider](https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references) * Document symbols — [documentSymbol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_documentSymbol) -* Code folding - [foldingRangeProvider](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_foldingRange) * Symbol rename - [renameProvider](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_rename) ## Clients @@ -29,8 +28,12 @@ MATLAB language server supports these editors by installing the corresponding ex ### Unreleased +### 1.2.6 +Release date: 2024-09-20 + Fixed: * Patches CVE-2024-43788 +* Resolves issue preventing code navigation and variable renaming for variables followed by a matrix operation (e.g. `x.^2`) ### 1.2.5 Release date: 2024-08-16 diff --git a/package-lock.json b/package-lock.json index d87f9eb..b1e3a96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "matlab-language-server", - "version": "1.2.5", + "version": "1.2.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "matlab-language-server", - "version": "1.2.5", + "version": "1.2.6", "license": "MIT", "dependencies": { "chokidar": "^3.5.3", @@ -23,6 +23,7 @@ "devDependencies": { "@types/mocha": "^10.0.6", "@types/node": "^18.7.18", + "@types/sinon": "^17.0.3", "@types/which": "^2.0.1", "@types/yargs": "^17.0.12", "@typescript-eslint/eslint-plugin": "^5.36.1", @@ -34,6 +35,7 @@ "eslint-plugin-promise": "^6.0.1", "mocha": "^10.4.0", "node-loader": "^2.0.0", + "sinon": "^18.0.0", "ts-loader": "^9.4.1", "ts-node": "^10.9.2", "typescript": "^4.8.3", @@ -263,6 +265,50 @@ "node": ">= 8" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -323,6 +369,21 @@ "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", "dev": true }, + "node_modules/@types/sinon": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", + "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, "node_modules/@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -2951,6 +3012,12 @@ "json5": "lib/cli.js" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -3023,6 +3090,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3292,6 +3365,19 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/nise": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz", + "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, "node_modules/node-loader": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/node-loader/-/node-loader-2.0.0.tgz", @@ -3488,6 +3574,12 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -3924,6 +4016,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sinon": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz", + "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.2.0", + "nise": "^6.0.0", + "supports-color": "^7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -4285,6 +4395,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -4932,6 +5051,52 @@ "fastq": "^1.6.0" } }, + "@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0" + } + }, + "@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "requires": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + } + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -4992,6 +5157,21 @@ "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", "dev": true }, + "@types/sinon": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", + "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, + "requires": { + "@types/sinonjs__fake-timers": "*" + } + }, + "@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, "@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -6879,6 +7059,12 @@ "minimist": "^1.2.0" } }, + "just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -6929,6 +7115,12 @@ "p-locate": "^5.0.0" } }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7144,6 +7336,19 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "nise": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.0.0.tgz", + "integrity": "sha512-K8ePqo9BFvN31HXwEtTNGzgrPpmvgciDsFz8aztFjt4LqKO/JeFD8tBOeuDiCMXrIl/m1YvfH8auSpxfaD09wg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/text-encoding": "^0.7.2", + "just-extend": "^6.2.0", + "path-to-regexp": "^6.2.1" + } + }, "node-loader": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/node-loader/-/node-loader-2.0.0.tgz", @@ -7279,6 +7484,12 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true + }, "path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -7567,6 +7778,20 @@ "object-inspect": "^1.9.0" } }, + "sinon": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.0.tgz", + "integrity": "sha512-+dXDXzD1sBO6HlmZDd7mXZCR/y5ECiEiGCBSGuFD/kZ0bDTofPYc6JaeGmPSF+1j1MejGUWkORbYOLDyvqCWpA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.2.0", + "nise": "^6.0.0", + "supports-color": "^7" + } + }, "slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7808,6 +8033,12 @@ "prelude-ls": "^1.2.1" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", diff --git a/package.json b/package.json index 89ac902..42b5051 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matlab-language-server", - "version": "1.2.5", + "version": "1.2.6", "description": "Language Server for MATLAB code", "main": "./src/index.ts", "bin": "./out/index.js", @@ -23,6 +23,7 @@ "devDependencies": { "@types/mocha": "^10.0.6", "@types/node": "^18.7.18", + "@types/sinon": "^17.0.3", "@types/which": "^2.0.1", "@types/yargs": "^17.0.12", "@typescript-eslint/eslint-plugin": "^5.36.1", @@ -34,6 +35,7 @@ "eslint-plugin-promise": "^6.0.1", "mocha": "^10.4.0", "node-loader": "^2.0.0", + "sinon": "^18.0.0", "ts-loader": "^9.4.1", "ts-node": "^10.9.2", "typescript": "^4.8.3", diff --git a/src/ClientConnection.ts b/src/ClientConnection.ts index 9f8c29e..041b212 100644 --- a/src/ClientConnection.ts +++ b/src/ClientConnection.ts @@ -4,7 +4,7 @@ import { _Connection, createConnection, ProposedFeatures } from "vscode-language export type Connection = _Connection export default class ClientConnection { - private static connection: Connection + private static connection: Connection | undefined /** * Retrieves the connection to the client. If no connection currently exists, @@ -26,7 +26,15 @@ export default class ClientConnection { * * @param connection The connection object to set */ - public static setConnection (connection: Connection): void { + public static _setConnection (connection: Connection): void { ClientConnection.connection = connection } + + /** + * Clears the current connection. + * This API is primarily meant for testing purposes. + */ + public static _clearConnection (): void { + ClientConnection.connection = undefined + } } diff --git a/src/logging/Logger.ts b/src/logging/Logger.ts index 1a675db..9e23e0c 100644 --- a/src/logging/Logger.ts +++ b/src/logging/Logger.ts @@ -16,7 +16,7 @@ class Logger { private readonly languageServerLogFile: string private readonly matlabLogFile: string - private readonly console: RemoteConsole + private console?: RemoteConsole constructor () { // Create Log Directory @@ -32,9 +32,10 @@ class Logger { // Get name of log file this.languageServerLogFile = path.join(this.logDir, SERVER_LOG) this.matlabLogFile = path.join(this.logDir, MATLAB_LOG) + } - // Get log console - this.console = ClientConnection.getConnection().console + public initialize (console: RemoteConsole): void { + this.console = console this.log(`Log Directory: ${this.logDir}`) } diff --git a/src/providers/formatting/FormatSupportProvider.ts b/src/providers/formatting/FormatSupportProvider.ts index a2e9101..8e2d1a3 100644 --- a/src/providers/formatting/FormatSupportProvider.ts +++ b/src/providers/formatting/FormatSupportProvider.ts @@ -17,8 +17,8 @@ interface FormatDocumentResponse { * include formatting a range witin the documemt. */ class FormatSupportProvider { - private readonly REQUEST_CHANNEL = '/matlabls/formatDocument/request' - private readonly RESPONSE_CHANNEL = '/matlabls/formatDocument/response' + readonly REQUEST_CHANNEL = '/matlabls/formatDocument/request' + readonly RESPONSE_CHANNEL = '/matlabls/formatDocument/response' constructor (private matlabLifecycleManager: MatlabLifecycleManager) {} diff --git a/src/server.ts b/src/server.ts index 6cfdde9..ba50a9e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -30,6 +30,9 @@ export async function startServer () { // Create a connection for the server const connection = ClientConnection.getConnection() + // Initialize Logger + Logger.initialize(connection.console) + // Instantiate services const pathResolver = new PathResolver() const matlabLifecycleManager = new MatlabLifecycleManager() diff --git a/src/utils/ExpressionUtils.ts b/src/utils/ExpressionUtils.ts index ac4009a..4e395cc 100644 --- a/src/utils/ExpressionUtils.ts +++ b/src/utils/ExpressionUtils.ts @@ -4,6 +4,8 @@ import { TextDocument } from 'vscode-languageserver-textdocument' import { Position } from 'vscode-languageserver' import { getTextOnLine } from './TextDocumentUtils' +const DOTTED_IDENTIFIER_REGEX = /\b(?:[a-zA-Z][\w]*)(?:\.[a-zA-Z][\w]*)*\b/ // Matches a word followed by optional dotted words + /** * Represents a code expression, either a single identifier or a dotted expression. * For example, "plot" or "pkg.Class.func". @@ -82,7 +84,6 @@ export function getExpressionAtPosition (textDocument: TextDocument, position: P * @returns An object containing the string identifier at the position, as well as the column number at which the identifier starts. */ function getIdentifierAtPosition (textDocument: TextDocument, position: Position): { identifier: string, start: number } { - const DOTTED_IDENTIFIER_REGEX = /[\w.]+/ let lineText = getTextOnLine(textDocument, position.line) const result = { @@ -94,12 +95,19 @@ function getIdentifierAtPosition (textDocument: TextDocument, position: Position let offset = 0 while (matchResults != null) { - if (matchResults.index == null || matchResults.index > position.character) { - // Already passed the cursor - no match found + if (matchResults.index == null) { + // No result found break } const startChar = offset + matchResults.index + + if (startChar > position.character) { + // Passed the cursor - no match found + break + } + + if (startChar + matchResults[0].length >= position.character) { // Found overlapping identifier result.identifier = matchResults[0] diff --git a/tests/mocks/Connection.mock.ts b/tests/mocks/Connection.mock.ts new file mode 100644 index 0000000..68f9ac4 --- /dev/null +++ b/tests/mocks/Connection.mock.ts @@ -0,0 +1,16 @@ +import sinon from 'sinon' + +export default function getMockConnection (): any { + const mockConnection = { + console: { + connection: {}, + error: sinon.stub(), + warn: sinon.stub(), + info: sinon.stub(), + log: sinon.stub() + }, + sendNotification: sinon.stub() + } + + return mockConnection +} diff --git a/tests/providers/formatting/FormatSupportProvider.test.ts b/tests/providers/formatting/FormatSupportProvider.test.ts new file mode 100644 index 0000000..c8721bd --- /dev/null +++ b/tests/providers/formatting/FormatSupportProvider.test.ts @@ -0,0 +1,99 @@ +import assert from 'assert' +import sinon from 'sinon' + +import FormatSupportProvider from '../../../src/providers/formatting/FormatSupportProvider' +import MatlabLifecycleManager from '../../../src/lifecycle/MatlabLifecycleManager' +import ClientConnection from '../../../src/ClientConnection' + +import { TextDocument } from 'vscode-languageserver-textdocument' +import { _Connection, DocumentFormattingParams, Position, Range, TextDocuments, TextEdit } from 'vscode-languageserver' +import getMockConnection from '../../mocks/Connection.mock' + +describe('FormatSupportProvider', () => { + let formatSupportProvider: FormatSupportProvider + let matlabLifecycleManager: MatlabLifecycleManager + let documentManager: TextDocuments + let mockMatlabConnection: any + let mockTextDocument: TextDocument + + before(() => { + ClientConnection._setConnection(getMockConnection()) + }) + + after(() => { + ClientConnection._clearConnection() + }) + + describe('#handleDocumentFormatRequest', () => { + // Because the actual formatting logic occurs in MATLAB, the actual value of these + // params are not tested in this file. So, define static params for each test. + const mockParams = { + textDocument: { uri: 'file:///test.m' }, + options: { insertSpaces: true, tabSize: 4 } + } as DocumentFormattingParams + + beforeEach(() => { + matlabLifecycleManager = new MatlabLifecycleManager() + formatSupportProvider = new FormatSupportProvider(matlabLifecycleManager) + documentManager = new TextDocuments(TextDocument) + mockMatlabConnection = { + getChannelId: sinon.stub().returns('test-channel'), + subscribe: sinon.stub(), + unsubscribe: sinon.stub(), + publish: sinon.stub() + } + mockTextDocument = TextDocument.create('file:///test.m', 'matlab', 1, 'function y = test(x)\ny = x + 1;\nend') + + sinon.stub(matlabLifecycleManager, 'getMatlabConnection').returns(mockMatlabConnection) + sinon.stub(documentManager, 'get').returns(mockTextDocument) + }) + + afterEach(() => { + sinon.restore() + }) + + it('should return null if no document to format', async () => { + // Return undefined text document + (documentManager.get as sinon.SinonStub).returns(undefined) + + const res = await formatSupportProvider.handleDocumentFormatRequest(mockParams, documentManager) + + assert.equal(res, null, 'Result should be null when there is no document') + }) + + it('should return empty array if no MATLAB connection', async () => { + // Return null MATLAB connection + (matlabLifecycleManager.getMatlabConnection as sinon.SinonStub).resolves(null) + + const res = await formatSupportProvider.handleDocumentFormatRequest(mockParams, documentManager) + + assert.deepEqual(res, [], 'Result should be [] when there is no connection') + }) + + it('should handle successful formatting', async () => { + const formattedCode = 'function y = test(x)\n y = x + 1;\nend' + const expectedEdit = TextEdit.replace( + Range.create(Position.create(0, 0), Position.create(2, 3)), + formattedCode + ) + + mockMatlabConnection.subscribe.callsFake((channel: string, callback: any) => { + setTimeout(() => { + callback({ data: formattedCode }) + }, 0) + return 'subscription-id' + }) + + const result = await formatSupportProvider.handleDocumentFormatRequest(mockParams, documentManager) + + assert.deepStrictEqual(result, [expectedEdit]) + sinon.assert.calledOnce(mockMatlabConnection.publish) + sinon.assert.calledWith(mockMatlabConnection.publish, formatSupportProvider.REQUEST_CHANNEL, { + data: mockTextDocument.getText(), + insertSpaces: true, + tabSize: 4, + channelId: 'test-channel' + }) + }) + }) +}) diff --git a/tests/utils/ExpressionUtils.test.ts b/tests/utils/ExpressionUtils.test.ts new file mode 100644 index 0000000..0939bf7 --- /dev/null +++ b/tests/utils/ExpressionUtils.test.ts @@ -0,0 +1,84 @@ +import assert from 'assert' +import { Position } from 'vscode-languageserver' +import { TextDocument } from 'vscode-languageserver-textdocument' +import * as ExpressionUtils from '../../src/utils/ExpressionUtils' + +describe('ExpressionUtils', () => { + describe('#getExpressionAtPosition', () => { + it('should return null for empty line', () => { + const doc = TextDocument.create('', '', 1, '') + const position = Position.create(0, 0) + const result = ExpressionUtils.getExpressionAtPosition(doc, position) + assert.strictEqual(result, null) + }) + + it('should return correct expression for single identifier', () => { + const doc = TextDocument.create('', '', 1, 'someVariable') + const position = Position.create(0, 5) + const result = ExpressionUtils.getExpressionAtPosition(doc, position) + assert.notStrictEqual(result, null) + assert.strictEqual(result?.fullExpression, 'someVariable') + assert.strictEqual(result?.targetExpression, 'someVariable') + assert.strictEqual(result?.unqualifiedTarget, 'someVariable') + assert.strictEqual(result?.first, 'someVariable') + assert.strictEqual(result?.last, 'someVariable') + }) + + it('should return correct expression for dotted identifier', () => { + const doc = TextDocument.create('', '', 1, 'pkg.Class.method') + const position = Position.create(0, 7) + const result = ExpressionUtils.getExpressionAtPosition(doc, position) + assert.notStrictEqual(result, null) + assert.strictEqual(result?.fullExpression, 'pkg.Class.method') + assert.strictEqual(result?.targetExpression, 'pkg.Class') + assert.strictEqual(result?.unqualifiedTarget, 'Class') + assert.strictEqual(result?.first, 'pkg') + assert.strictEqual(result?.last, 'method') + }) + + it('should handle position at the end of identifier', () => { + const doc = TextDocument.create('', '', 1, 'someVariable') + const position = Position.create(0, 12) + const result = ExpressionUtils.getExpressionAtPosition(doc, position) + assert.notStrictEqual(result, null) + assert.strictEqual(result?.fullExpression, 'someVariable') + }) + + it('should handle multiple identifiers on the same line', () => { + const doc = TextDocument.create('', '', 1, 'firstVar secondVar thirdVar') + const position = Position.create(0, 15) + const result = ExpressionUtils.getExpressionAtPosition(doc, position) + assert.notStrictEqual(result, null) + assert.strictEqual(result?.fullExpression, 'secondVar') + }) + + it('should return null for position between identifiers', () => { + const doc = TextDocument.create('', '', 1, 'firstVar secondVar') + const position = Position.create(0, 10) + const result = ExpressionUtils.getExpressionAtPosition(doc, position) + assert.strictEqual(result, null) + }) + + it('should match identifier next to dot operator', () => { + const doc = TextDocument.create('', '', 1, 'var.^2') + const position = Position.create(0, 2) + const result = ExpressionUtils.getExpressionAtPosition(doc, position) + assert.notStrictEqual(result, null) + assert.strictEqual(result?.fullExpression, 'var') + }) + + it('should return null for a non-identifier character', () => { + const doc = TextDocument.create('', '', 1, 'var.@') + const position = Position.create(0, 4) + const result = ExpressionUtils.getExpressionAtPosition(doc, position) + assert.strictEqual(result, null) + }) + + it('should return null for a number', () => { + const doc = TextDocument.create('', '', 1, '42') + const position = Position.create(0, 1) + const result = ExpressionUtils.getExpressionAtPosition(doc, position) + assert.strictEqual(result, null) + }) + }) +})