diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94f04095..78cf6b83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Nimble uses: nim-lang/setup-nimble-action@v1 with: - nimble-version: "0.20.1" + nimble-version: "latest" repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Restore nimble dependencies from cache @@ -35,6 +35,10 @@ jobs: restore-keys: | ${{ runner.os }}-${{ env.cache_nonce }}- + - name: Install Nimble fork with the nim#head fix + shell: bash + run: nimble install https://github.com/moigagoo/nimble@#bugfix/nimble_dump_with_nim_head + - name: Install dependencies shell: bash run: nimble -y install -l diff --git a/ls.nim b/ls.nim index d6cd6a95..88269de5 100644 --- a/ls.nim +++ b/ls.nim @@ -96,6 +96,7 @@ type nimExpandMacro*: Option[bool] maxNimsuggestProcesses*: Option[int] #max number of nimsuggest processes to keep alive. zero means unlimited + useNimTrack*: Option[bool] NlsFileInfo* = ref object of RootObj projectFile*: Future[string] @@ -271,14 +272,28 @@ proc supportSignatureHelp*(cc: LspClientCapabilities): bool = caps.isSome and caps.get.signatureHelp.isSome proc getNimbleDumpInfo*( - ls: LanguageServer, nimbleFile: string + ls: LanguageServer, nimbleFile: string, workingDir = "" ): Future[NimbleDumpInfo] {.async.} = if nimbleFile in ls.nimDumpCache: return ls.nimDumpCache.getOrDefault(nimbleFile) + # `nimble dump` resolves the project's Nim (`nimDir`) relative to the process + # working directory. When a project pins a local Nim (e.g. `nim#head` in + # `nimbledeps`), running `nimble dump` from the wrong directory reports the + # global Nim instead. Run it in the project directory so the project-local Nim + # is picked up. Fall back to the nimble file's directory when no working dir + # is supplied. + let dumpDir = + if workingDir != "": + workingDir + elif nimbleFile != "": + nimbleFile.parentDir + else: + getCurrentDir() var process: AsyncProcessRef try: process = await startProcess( "nimble", + workingDir = dumpDir, arguments = @["dump", nimbleFile], options = {UsePath}, stderrHandle = AsyncProcess.Pipe, @@ -508,10 +523,15 @@ proc getNimVersion(nimDir: string): string = proc getNimSuggestPathAndVersion( ls: LanguageServer, conf: NlsConfig, workingDir: string ): Future[(string, string)] {.async.} = - #Attempting to see if the project is using a custom Nim version, if it's the case this will be slower than usual - let nimbleDumpInfo = await ls.getNimbleDumpInfo("") - let nimDir = nimbleDumpInfo.nimDir.get "" + let nimbleFiles = walkFiles(workingDir / "*.nimble").toSeq + + let nimbleDumpInfo = + if nimbleFiles.len > 0: + await ls.getNimbleDumpInfo(nimbleFiles[0], workingDir) + else: + await ls.getNimbleDumpInfo("", workingDir) + let nimDir = nimbleDumpInfo.nimDir.get "" var nimsuggestPath = expandTilde(conf.nimsuggestPath.get("")) var nimVersion = "" if nimsuggestPath == "": @@ -527,11 +547,19 @@ proc getNimSuggestPathAndVersion( debug "Using {nimVersion}", nimVersion = nimVersion (nimsuggestPath, nimVersion) -proc getNimPath*(conf: NlsConfig): Option[string] = +proc getNimPath*( + ls: LanguageServer, conf: NlsConfig, workingDir = "" +): Future[Option[string]] {.async.} = if conf.nimSuggestPath.isSome and conf.nimsuggestPath.get().fileExists(): some(conf.nimSuggestPath.get.parentDir / "nim") else: - let path = findExe "nim" + let (nimsuggestPath, _) = await ls.getNimSuggestPathAndVersion(conf, workingDir) + let path = + if nimsuggestPath.fileExists(): + nimsuggestPath.parentDir / "nim" + else: + findExe "nim" + if path != "": some(path) else: @@ -597,7 +625,7 @@ proc getRootPath*(ip: LspInitializeParams): string = proc getRootPath*(ip: McpInitializeParams): string = getCurrentDir().pathToUri.uriToPath -proc getWorkingDir(ls: LanguageServer, path: string): Future[string] {.async.} = +proc getWorkingDir*(ls: LanguageServer, path: string): Future[string] {.async.} = let rootPath = case ls.serverMode of lsp: ls.lspInitializeParams.getRootPath @@ -607,7 +635,7 @@ proc getWorkingDir(ls: LanguageServer, path: string): Future[string] {.async.} = pathRelativeToRoot = path.tryRelativeTo(rootPath) mapping = ls.getWorkspaceConfiguration.await().workingDirectoryMapping.get(@[]) - result = getCurrentDir() + result = rootPath for m in mapping: if pathRelativeToRoot.isSome and m.projectFile == pathRelativeToRoot.get(): @@ -945,7 +973,7 @@ proc checkProject*(ls: LanguageServer, uri: string): Future[void] {.async.} = let conf = await ls.getAndWaitForWorkspaceConfiguration() let useNimCheck = conf.useNimCheck.get(USE_NIM_CHECK_BY_DEFAULT) - let nimPath = getNimPath(conf) + let nimPath = await ls.getNimPath(conf) if useNimCheck and nimPath.isSome: proc getFilePath(c: CheckResult): string = @@ -1258,7 +1286,7 @@ proc getProjectFile*(fileUri: string, ls: LanguageServer): Future[string] {.asyn proc checkFile*(ls: LanguageServer, uri: string): Future[void] {.async.} = let conf = await ls.getAndWaitForWorkspaceConfiguration() let useNimCheck = conf.useNimCheck.get(USE_NIM_CHECK_BY_DEFAULT) - let nimPath = conf.getNimPath() + let nimPath = await ls.getNimPath(conf) let token = fmt "Checking file {uri}" ls.workDoneProgressCreate(token) ls.progress(token, "begin", fmt "Checking {uri.uriToPath}") diff --git a/nimlangserver.nimble b/nimlangserver.nimble index 3ac0cf43..883c1fcf 100644 --- a/nimlangserver.nimble +++ b/nimlangserver.nimble @@ -16,9 +16,9 @@ requires "nim == 2.0.8", "." task test, "run tests": - --silent --run - setCommand "c", "tests/all.nim" + --silent + setCommand("c", "tests/all.nim") task book, "Generate book": exec "mdbook build book -d ../docs" diff --git a/routes/lsp.nim b/routes/lsp.nim index dda7308a..ea8823c4 100644 --- a/routes/lsp.nim +++ b/routes/lsp.nim @@ -14,7 +14,7 @@ import stew/byteutils, with, ], - ../[testrunner, nimexpand, asyncprocmonitor, suggestapi, ls, utils], + ../[testrunner, nimexpand, asyncprocmonitor, suggestapi, trackapi, ls, utils], ../protocol/[enums, types] import macros except error @@ -157,6 +157,34 @@ proc definition*( ): Future[seq[Location]] {.async.} = with (params.position, params.textDocument): asyncSpawn ls.addProjectFileToPendingRequest(id.uint, uri) + let config = await ls.getWorkspaceConfiguration() + # `nim track` only works on files as saved on disk; it has no dirty-buffer + # support. Use it only when the file is open and has no unsaved changes, + # otherwise fall back to nimsuggest (which supports dirty buffers). + if config.useNimTrack.get(false) and uri in ls.openFiles and + not ls.openFiles[uri].changed: + let ch = ls.getCharacter(uri, line, character) + if ch.isNone: + return @[] + let projectFile = await ls.openFiles[uri].projectFile + let timeout = config.timeout.get(REQUEST_TIMEOUT) + let workingDir = await ls.getWorkingDir(projectFile) + let nimPath = await ls.getNimPath(config, workingDir) + if nimPath.isNone: + return @[] + result = ( + await track( + projectFile, + uriToPath(uri), + line + 1, + ch.get, + tmDef, + nimPath = nimPath.get, + workingDir = workingDir, + timeout = timeout, + ) + ).map(x => x.toUtf16Pos(ls).toLocation) + return let ns = await ls.tryGetNimsuggest(uri) if ns.isNone: return @[] @@ -410,14 +438,14 @@ proc hover*( content.value.add &"```nim\n{expanded[0].doc}\n```" else: # debug "Couldnt expand the macro. Trying with nim expand", suggest = suggest[] - let nimPath = config.getNimPath() + let nimPath = await ls.getNimPath(config) if nimPath.isSome: let expanded = await nimExpandMacro(nimPath.get, suggest, uriToPath(uri)) content.value.add &"```nim\n{expanded}\n```" if suggest.section == ideDef and suggest.symkind in ["skProc"] and config.nimExpandArc.get(NIM_EXPAND_ARC_BY_DEFAULT): debug "#Expanding arc", suggest = suggest[] - let nimPath = config.getNimPath() + let nimPath = await ls.getNimPath(config) if nimPath.isSome: let expanded = await nimExpandArc(nimPath.get, suggest, uriToPath(uri)) let arcContent = "#Expanded arc \n" & expanded @@ -432,6 +460,36 @@ proc references*( ls: LanguageServer, params: ReferenceParams ): Future[seq[Location]] {.async.} = with (params.position, params.textDocument, params.context): + let config = await ls.getWorkspaceConfiguration() + # `nim track` only works on files as saved on disk; it has no dirty-buffer + # support. Use it only when the file is open and has no unsaved changes, + # otherwise fall back to nimsuggest (which supports dirty buffers). + if config.useNimTrack.get(false) and uri in ls.openFiles and + not ls.openFiles[uri].changed: + let ch = ls.getCharacter(uri, line, character) + if ch.isNone: + return @[] + let projectFile = await ls.openFiles[uri].projectFile + let mode = if includeDeclaration: tmDefUsages else: tmUsages + let timeout = config.timeout.get(REQUEST_TIMEOUT) + let workingDir = await ls.getWorkingDir(projectFile) + let nimPath = await ls.getNimPath(config, workingDir) + if nimPath.isNone: + return @[] + let refs = await track( + projectFile, + uriToPath(uri), + line + 1, + ch.get, + mode, + nimPath = nimPath.get, + workingDir = workingDir, + timeout = timeout, + ) + result = refs + .filter(suggest => suggest.section != ideDef or includeDeclaration) + .map(x => x.toUtf16Pos(ls).toLocation) + return let nimsuggest = await ls.tryGetNimsuggest(uri) if nimsuggest.isNone: return @[] @@ -854,7 +912,8 @@ proc listTests*( ls: LanguageServer, params: ListTestsParams ): Future[ListTestsResult] {.async.} = let config = await ls.getWorkspaceConfiguration() - let nimPath = config.getNimPath() + let workspaceRoot = ls.lspInitializeParams.getRootPath + let nimPath = await ls.getNimPath(config, workspaceRoot) if nimPath.isNone: error "Nim path not found when listing tests" return ListTestsResult( @@ -862,7 +921,6 @@ proc listTests*( entryPoint: params.entryPoint, suites: initTable[string, TestSuiteInfo]() ) ) - let workspaceRoot = ls.lspInitializeParams.getRootPath let testProjectInfo = await listTests(params.entryPoint, nimPath.get(), workspaceRoot) result.projectInfo = testProjectInfo @@ -870,11 +928,11 @@ proc runTests*( ls: LanguageServer, params: RunTestParams ): Future[RunTestProjectResult] {.async.} = let config = await ls.getWorkspaceConfiguration() - let nimPath = config.getNimPath() + let workspaceRoot = ls.lspInitializeParams.getRootPath + let nimPath = await ls.getNimPath(config, workspaceRoot) if nimPath.isNone: error "Nim path not found when running tests" return RunTestProjectResult() - let workspaceRoot = ls.lspInitializeParams.getRootPath await runTests( params.entryPoint, nimPath.get(), diff --git a/routes/mcp.nim b/routes/mcp.nim index 7c89b485..a95b91f5 100644 --- a/routes/mcp.nim +++ b/routes/mcp.nim @@ -1,7 +1,7 @@ import std/[os, sequtils, tables, json], pkg/[chronos, json_rpc/server, chronicles, json_serialization], - ../[suggestapi, ls, utils], + ../[suggestapi, trackapi, ls, utils], ../protocol/types const McpProtocolVersion* = "2025-11-25" @@ -15,29 +15,31 @@ proc nimFindReferences(): McpTool = description: "Find references of the symbol under cursor in the current workspace.", inputSchema: McpToolSchema( `type`: "object", - properties: %*{ - "path": {"type": "string"}, - "line": {"type": "integer"}, - "column": {"type": "integer"}, - }, + properties: + %*{ + "path": {"type": "string"}, + "line": {"type": "integer"}, + "column": {"type": "integer"}, + }, required: @["path", "line", "column"], ), outputSchema: McpToolSchema( `type`: "object", - properties: %*{ - "refs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "line": {"type": "integer"}, - "column": {"type": "integer"}, + properties: + %*{ + "refs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "line": {"type": "integer"}, + "column": {"type": "integer"}, + }, + "required": ["path", "line", "column"], }, - "required": ["path", "line", "column"], - }, - } - }, + } + }, required: @["refs"], ), ) @@ -55,21 +57,22 @@ proc nimFindSymbols(): McpTool = ), outputSchema: McpToolSchema( `type`: "object", - properties: %*{ - "syms": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "line": {"type": "integer"}, - "column": {"type": "integer"}, - "kind": {"type": "string"}, + properties: + %*{ + "syms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "line": {"type": "integer"}, + "column": {"type": "integer"}, + "kind": {"type": "string"}, + }, + "required": ["path", "line", "column", "kind"], }, - "required": ["path", "line", "column", "kind"], - }, - } - }, + } + }, required: @["syms"], ), ) @@ -84,21 +87,22 @@ proc nimListSymbols(): McpTool = ), outputSchema: McpToolSchema( `type`: "object", - properties: %*{ - "syms": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "line": {"type": "integer"}, - "column": {"type": "integer"}, - "kind": {"type": "string"}, + properties: + %*{ + "syms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "line": {"type": "integer"}, + "column": {"type": "integer"}, + "kind": {"type": "string"}, + }, + "required": ["name", "line", "column", "kind"], }, - "required": ["name", "line", "column", "kind"], - }, - } - }, + } + }, required: @["syms"], ), ) @@ -112,22 +116,23 @@ proc nimCheckProject(): McpTool = inputSchema: McpToolSchema(`type`: "object", properties: %*{}, required: @[]), outputSchema: McpToolSchema( `type`: "object", - properties: %*{ - "diags": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "line": {"type": "integer"}, - "column": {"type": "integer"}, - "severity": {"type": "string"}, - "message": {"type": "string"}, + properties: + %*{ + "diags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "line": {"type": "integer"}, + "column": {"type": "integer"}, + "severity": {"type": "string"}, + "message": {"type": "string"}, + }, + "required": ["path", "line", "column", "severity", "message"], }, - "required": ["path", "line", "column", "severity", "message"], - }, - } - }, + } + }, required: @["diags"], ), ) @@ -142,21 +147,22 @@ proc nimCheckFile(): McpTool = ), outputSchema: McpToolSchema( `type`: "object", - properties: %*{ - "diags": { - "type": "array", - "items": { - "type": "object", - "properties": { - "line": {"type": "integer"}, - "column": {"type": "integer"}, - "severity": {"type": "string"}, - "message": {"type": "string"}, + properties: + %*{ + "diags": { + "type": "array", + "items": { + "type": "object", + "properties": { + "line": {"type": "integer"}, + "column": {"type": "integer"}, + "severity": {"type": "string"}, + "message": {"type": "string"}, + }, + "required": ["line", "column", "severity", "message"], }, - "required": ["line", "column", "severity", "message"], - }, - } - }, + } + }, required: @["diags"], ), ) @@ -169,32 +175,34 @@ proc nimFindTypeDefinition(): McpTool = "Find the type definition of the symbol under cursor in the current workspace.", inputSchema: McpToolSchema( `type`: "object", - properties: %*{ - "path": {"type": "string"}, - "line": {"type": "integer"}, - "column": {"type": "integer"}, - }, + properties: + %*{ + "path": {"type": "string"}, + "line": {"type": "integer"}, + "column": {"type": "integer"}, + }, required: @["path", "line", "column"], ), outputSchema: McpToolSchema( `type`: "object", - properties: %*{ - "defs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": {"type": "string"}, - "line": {"type": "integer"}, - "column": {"type": "integer"}, - "name": {"type": "string"}, - "type": {"type": "string"}, - "kind": {"type": "string"}, + properties: + %*{ + "defs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "line": {"type": "integer"}, + "column": {"type": "integer"}, + "name": {"type": "string"}, + "type": {"type": "string"}, + "kind": {"type": "string"}, + }, + "required": ["path", "line", "column", "name", "type", "kind"], }, - "required": ["path", "line", "column", "name", "type", "kind"], - }, - } - }, + } + }, required: @["defs"], ), ) @@ -216,6 +224,43 @@ proc callNimFindReferences( TextDocumentItem(uri: uri, languageId: "nim", version: 0, text: readFile(path)) ) + let config = await ls.getWorkspaceConfiguration() + + if config.useNimTrack.get(false): + let projectFile = await ls.openFiles[uri].projectFile + let timeout = config.timeout.get(REQUEST_TIMEOUT) + let workingDir = await ls.getWorkingDir(projectFile) + let nimPath = await ls.getNimPath(config, workingDir) + if nimPath.isNone: + return McpCallToolResult( + content: @[McpContentBlock(`type`: TextContent, text: "Nim not found")], + isError: true, + ) + let refs = await track( + projectFile, + path, + line, + column, + tmUsages, + nimPath = nimPath.get, + workingDir = workingDir, + timeout = timeout, + ) + + var usageReferencesJson = newJArray() + for reference in refs: + usageReferencesJson.add %*{ + "path": reference.filePath, "line": reference.line, "column": reference.column + } + + let structuredContent = %*{"refs": usageReferencesJson} + + return McpCallToolResult( + content: @[McpContentBlock(`type`: TextContent, text: $structuredContent)], + structuredContent: structuredContent, + isError: false, + ) + let nimsuggest = await ls.tryGetNimsuggest(uri) if nimsuggest.isSome: @@ -247,9 +292,12 @@ proc callNimFindSymbols( ): Future[McpCallToolResult] {.async.} = if len(ls.projectFiles) == 0: return McpCallToolResult( - content: @[ - McpContentBlock(`type`: TextContent, text: "Tool works only in Nimble projects") - ], + content: + @[ + McpContentBlock( + `type`: TextContent, text: "Tool works only in Nimble projects" + ) + ], isError: true, ) @@ -343,9 +391,12 @@ proc callNimCheckProject( ): Future[McpCallToolResult] {.async.} = if len(ls.projectFiles) == 0: return McpCallToolResult( - content: @[ - McpContentBlock(`type`: TextContent, text: "Tool works only in Nimble projects") - ], + content: + @[ + McpContentBlock( + `type`: TextContent, text: "Tool works only in Nimble projects" + ) + ], isError: true, ) @@ -513,14 +564,15 @@ proc listTools*( ): Future[McpListToolsResult] {.async.} = debug "Call tool received..." McpListToolsResult( - tools: @[ - nimFindReferences(), - nimFindSymbols(), - nimListSymbols(), - nimCheckProject(), - nimCheckFile(), - nimFindTypeDefinition(), - ] + tools: + @[ + nimFindReferences(), + nimFindSymbols(), + nimListSymbols(), + nimCheckProject(), + nimCheckFile(), + nimFindTypeDefinition(), + ] ) proc callTool*( @@ -529,7 +581,7 @@ proc callTool*( debug "Call tool received...", name = params.name await ls.nimsuggestInit - + case params.name of "nimFindReferences": await callNimFindReferences(ls, params) diff --git a/tests/all.nim b/tests/all.nim index 2af2ddd5..596e1c30 100644 --- a/tests/all.nim +++ b/tests/all.nim @@ -1,6 +1,7 @@ import tsuggestapi, tnimlangserver, + tnimtrack, tprojectsetup, textensions, tmisc, diff --git a/tests/projects/trackproject/src/trackproject.nim b/tests/projects/trackproject/src/trackproject.nim new file mode 100644 index 00000000..6e54063d --- /dev/null +++ b/tests/projects/trackproject/src/trackproject.nim @@ -0,0 +1,8 @@ +# This is just an example to get you started. A typical library package +# exports the main API in this file. Note that you cannot rename this file +# but you can remove it if you wish. + +proc add*(x, y: int): int = + ## Adds two numbers together. + return x + y + diff --git a/tests/projects/trackproject/src/trackproject/submodule.nim b/tests/projects/trackproject/src/trackproject/submodule.nim new file mode 100644 index 00000000..f55a6acd --- /dev/null +++ b/tests/projects/trackproject/src/trackproject/submodule.nim @@ -0,0 +1,12 @@ +# This is just an example to get you started. Users of your library will +# import this file by writing ``import trackproject/submodule``. Feel free to rename or +# remove this file altogether. You may create additional modules alongside +# this file as required. + +type + Submodule* = object + name*: string + +proc initSubmodule*(): Submodule = + ## Initialises a new ``Submodule`` object. + Submodule(name: "Anonymous") diff --git a/tests/projects/trackproject/tests/config.nims b/tests/projects/trackproject/tests/config.nims new file mode 100644 index 00000000..3bb69f82 --- /dev/null +++ b/tests/projects/trackproject/tests/config.nims @@ -0,0 +1 @@ +switch("path", "$projectDir/../src") \ No newline at end of file diff --git a/tests/projects/trackproject/tests/test1.nim b/tests/projects/trackproject/tests/test1.nim new file mode 100644 index 00000000..645d6b5e --- /dev/null +++ b/tests/projects/trackproject/tests/test1.nim @@ -0,0 +1,12 @@ +# This is just an example to get you started. You may wish to put all of your +# tests into a single file, or separate them into multiple `test1`, `test2` +# etc. files (better names are recommended, just make sure the name starts with +# the letter 't'). +# +# To run these tests, simply execute `nimble test`. + +import unittest + +import trackproject +test "can add": + check add(5, 5) == 10 diff --git a/tests/projects/trackproject/trackproject.nimble b/tests/projects/trackproject/trackproject.nimble new file mode 100644 index 00000000..dec2dc9d --- /dev/null +++ b/tests/projects/trackproject/trackproject.nimble @@ -0,0 +1,11 @@ +# Package + +version = "0.1.0" +author = "Constantine Molchanov" +description = "A new awesome nimble package" +license = "MIT" +srcDir = "src" + +# Dependencies + +requires "nim#head" diff --git a/tests/tmcp.nim b/tests/tmcp.nim index 1e5a1df5..cbf0502d 100644 --- a/tests/tmcp.nim +++ b/tests/tmcp.nim @@ -12,9 +12,7 @@ type McpSocketClient = ref object proc initMcpServer( mainFile: string -): Future[(LanguageServer, McpInitializeResult)] {. - async: (raises: [CatchableError]) -.} = +): Future[(LanguageServer, McpInitializeResult)] {.async: (raises: [CatchableError]).} = let cmdParams = CommandLineParams(mode: some ServerMode.mcp, transport: some TransportMode.stdio) diff --git a/tests/tnimlangserver.nim b/tests/tnimlangserver.nim index 23f79f81..26a93066 100644 --- a/tests/tnimlangserver.nim +++ b/tests/tnimlangserver.nim @@ -1,6 +1,4 @@ -import ../[ - nimlangserver, ls, lstransports, utils -] +import ../[nimlangserver, ls, lstransports, utils] import ../protocol/[enums, types] import std/[options, json, os, jsonutils, sequtils, strutils, sugar, strformat] import json_rpc/[rpcclient] @@ -9,108 +7,88 @@ import lspsocketclient import unittest2 suite "Nimlangserver": - let cmdParams = CommandLineParams(mode: some lsp, transport: some socket, port: getNextFreePort()) + let cmdParams = + CommandLineParams(mode: some lsp, transport: some socket, port: getNextFreePort()) let ls = main(cmdParams) #we could accesss to the ls here to test against its state let client = newLspSocketClient() client.registerNotification( - "window/showMessage", - "window/workDoneProgress/create", - "workspace/configuration", - "extension/statusUpdate", - "textDocument/publishDiagnostics", - "$/progress" + "window/showMessage", "window/workDoneProgress/create", "workspace/configuration", + "extension/statusUpdate", "textDocument/publishDiagnostics", "$/progress", ) waitFor client.connect("localhost", cmdParams.port) - + test "initialize from the client should call initialized on the server": - let initParams = LspInitializeParams %* { + let initParams = + LspInitializeParams %* { "processId": %getCurrentProcessId(), "rootUri": fixtureUri("projects/hw/"), - "capabilities": { - "window": { - "workDoneProgress": true - }, - "workspace": {"configuration": true} - } - } + "capabilities": + {"window": {"workDoneProgress": true}, "workspace": {"configuration": true}}, + } let initializeResult = waitFor client.initialize(initParams) - - check initializeResult.capabilities.textDocumentSync.isSome + check initializeResult.capabilities.textDocumentSync.isSome let helloWorldUri = fixtureUri("projects/hw/hw.nim") - suite "Suggest API selection": - let cmdParams = CommandLineParams(mode: some lsp, transport: some socket, port: getNextFreePort()) + let cmdParams = + CommandLineParams(mode: some lsp, transport: some socket, port: getNextFreePort()) let ls = main(cmdParams) #we could accesss to the ls here to test against its state let client = newLspSocketClient() client.registerNotification( - "window/showMessage", - "window/workDoneProgress/create", - "workspace/configuration", - "extension/statusUpdate", - "textDocument/publishDiagnostics", - "$/progress" - ) + "window/showMessage", "window/workDoneProgress/create", "workspace/configuration", + "extension/statusUpdate", "textDocument/publishDiagnostics", "$/progress", + ) - waitFor client.connect("localhost", cmdParams.port) - let initParams = LspInitializeParams %* { - "processId": %getCurrentProcessId(), - "rootUri": fixtureUri("projects/hw/"), - "capabilities": { - "window": { - "workDoneProgress": true - }, - "workspace": {"configuration": true} - } - } + let initParams = + LspInitializeParams %* { + "processId": %getCurrentProcessId(), + "rootUri": fixtureUri("projects/hw/"), + "capabilities": + {"window": {"workDoneProgress": true}, "workspace": {"configuration": true}}, + } discard waitFor client.initialize(initParams) client.notify("initialized", newJObject()) test "Suggest api": #The client adds the notifications into the call table and we wait until they arrived. - let helloWorldFile = "projects/hw/hw.nim" + let helloWorldFile = "projects/hw/hw.nim" client.notify("textDocument/didOpen", %createDidOpenParams(helloWorldFile)) let hwAbsFile = helloWorldFile.fixtureUri.uriToPath check waitFor client.waitForNotificationMessage( - fmt"Nimsuggest initialized for {hwAbsFile}", + fmt"Nimsuggest initialized for {hwAbsFile}" ) - client.notify("textDocument/didOpen", - %createDidOpenParams("projects/hw/useRoot.nim")) + client.notify( + "textDocument/didOpen", %createDidOpenParams("projects/hw/useRoot.nim") + ) let hoverParams = positionParams("projects/hw/hw.nim".fixtureUri, 2, 0) hover = client.call("textDocument/hover", %hoverParams).waitFor check hover.kind == JNull suite "LSP features": - let cmdParams = CommandLineParams(mode: some lsp, transport: some socket, port: getNextFreePort()) + let cmdParams = + CommandLineParams(mode: some lsp, transport: some socket, port: getNextFreePort()) let ls = main(cmdParams) #we could accesss to the ls here to test against its state let client = newLspSocketClient() client.registerNotification( - "window/showMessage", - "window/workDoneProgress/create", - "workspace/configuration", - "extension/statusUpdate", - "textDocument/publishDiagnostics", - "$/progress" - ) + "window/showMessage", "window/workDoneProgress/create", "workspace/configuration", + "extension/statusUpdate", "textDocument/publishDiagnostics", "$/progress", + ) waitFor client.connect("localhost", cmdParams.port) - let initParams = LspInitializeParams %* { + let initParams = + LspInitializeParams %* { "processId": %getCurrentProcessId(), "rootUri": fixtureUri("projects/hw/"), - "capabilities": { - "window": { - "workDoneProgress": false - }, - "workspace": {"configuration": true} - } - } + "capabilities": + {"window": {"workDoneProgress": false}, "workspace": {"configuration": true}}, + } discard waitFor client.initialize(initParams) @@ -123,22 +101,16 @@ suite "LSP features": let hoverParams = positionParams(helloWorldUri, 1, 6) hover = client.call("textDocument/hover", %hoverParams).waitFor - expected = %*{ - "contents": { - "kind": "markdown", - "value": "```nim\nhw.a안녕: proc (){.noSideEffect, gcsafe, raises: [].}\n```" - }, - "range": { - "start": { - "line": 1, - "character": 6 + expected = + %*{ + "contents": { + "kind": "markdown", + "value": + "```nim\nhw.a안녕: proc (){.noSideEffect, gcsafe, raises: [].}\n```", }, - "end": { - "line": 1, - "character": 9 - } + "range": + {"start": {"line": 1, "character": 6}, "end": {"line": 1, "character": 9}}, } - } check hover == expected test "Sending hover(no content)": @@ -156,141 +128,103 @@ suite "LSP features": test "Definitions.": let positionParams = positionParams(helloWorldUri, 1, 6) - locations = to(waitFor client.call("textDocument/definition", %positionParams), - seq[Location]) - expected = seq[Location] %* [{ - "uri": helloWorldUri, - "range": { - "start": { - "line": 0, - "character": 5 - }, - "end": { - "line": 0, - "character": 8 + locations = to( + waitFor client.call("textDocument/definition", %positionParams), seq[Location] + ) + expected = + seq[Location] %* [ + { + "uri": helloWorldUri, + "range": + {"start": {"line": 0, "character": 5}, "end": {"line": 0, "character": 8}}, } - } - }] + ] check %locations == %expected test "References.": - let referenceParams = ReferenceParams %* { - "context": { - "includeDeclaration": true - }, - "position": { - "line": 1, - "character": 6 - }, - "textDocument": { - "uri": helloWorldUri - } - } - let locations = to(waitFor client.call("textDocument/references", %referenceParams), - seq[Location]) - let expected = seq[Location] %* [{ - "uri": helloWorldUri, - "range": { - "start": { - "line": 0, - "character": 5 - }, - "end": { - "line": 0, - "character": 8 - } + let referenceParams = + ReferenceParams %* { + "context": {"includeDeclaration": true}, + "position": {"line": 1, "character": 6}, + "textDocument": {"uri": helloWorldUri}, } - }, { - "uri": helloWorldUri, - "range": { - "start": { - "line": 1, - "character": 6 + let locations = to( + waitFor client.call("textDocument/references", %referenceParams), seq[Location] + ) + let expected = + seq[Location] %* [ + { + "uri": helloWorldUri, + "range": + {"start": {"line": 0, "character": 5}, "end": {"line": 0, "character": 8}}, }, - "end": { - "line": 1, - "character": 9 - } - } - }] + { + "uri": helloWorldUri, + "range": + {"start": {"line": 1, "character": 6}, "end": {"line": 1, "character": 9}}, + }, + ] check %locations == %expected test "References(exclude def)": - let referenceParams = ReferenceParams %* { - "context": { - "includeDeclaration": false - }, - "position": { - "line": 1, - "character": 7 - }, - "textDocument": { - "uri": helloWorldUri - } - } - let locations = to(waitFor client.call("textDocument/references", - %referenceParams), - seq[Location]) - let expected = seq[Location] %* [{ - "uri": helloWorldUri, - "range": { - "start": { - "line": 1, - "character": 6 - }, - "end": { - "line": 1, - "character": 9 - } + let referenceParams = + ReferenceParams %* { + "context": {"includeDeclaration": false}, + "position": {"line": 1, "character": 7}, + "textDocument": {"uri": helloWorldUri}, } - }] + let locations = to( + waitFor client.call("textDocument/references", %referenceParams), seq[Location] + ) + let expected = + seq[Location] %* [ + { + "uri": helloWorldUri, + "range": + {"start": {"line": 1, "character": 6}, "end": {"line": 1, "character": 9}}, + } + ] check %locations == %expected test "Prepare rename": let renameParams = PrepareRenameParams( textDocument: TextDocumentIdentifier(uri: helloWorldUri), - position: Position(line: 2, character: 6) + position: Position(line: 2, character: 6), ) - let resp = client.call("textDocument/prepareRename", %renameParams) - .waitFor() - check resp == %* { - "start":{"line":2,"character":4}, - "end":{"line":2,"character":7} - } - + let resp = client.call("textDocument/prepareRename", %renameParams).waitFor() + check resp == + %*{"start": {"line": 2, "character": 4}, "end": {"line": 2, "character": 7}} test "Prepare rename doesn't allow non-project symbols": let renameParams = PrepareRenameParams( textDocument: TextDocumentIdentifier(uri: helloWorldUri), - position: Position(line: 8, character: 10) + position: Position(line: 8, character: 10), ) - let resp = client.call("textDocument/prepareRename", %renameParams) - .waitFor() + let resp = client.call("textDocument/prepareRename", %renameParams).waitFor() check resp.kind == JNull test "Rename": let renameParams = RenameParams( - textDocument: TextDocumentIdentifier(uri: helloWorldUri), - newName: "hello", - position: Position(line: 2, character: 6) + textDocument: TextDocumentIdentifier(uri: helloWorldUri), + newName: "hello", + position: Position(line: 2, character: 6), ) - let changes = client.call("textDocument/rename", %renameParams) - .waitFor().to(WorkSpaceEdit).changes.get() + let changes = client + .call("textDocument/rename", %renameParams) + .waitFor() + .to(WorkSpaceEdit).changes + .get() check changes.len == 1 check changes[helloWorldUri].len == 3 - check changes[helloWorldUri].mapIt(it["newText"].getStr()) == @["hello", "hello", "hello"] + check changes[helloWorldUri].mapIt(it["newText"].getStr()) == + @["hello", "hello", "hello"] test "didChange then sending hover.": - let didChangeParams = DidChangeTextDocumentParams %* { - "textDocument": { - "uri": helloWorldUri, - "version": 1 - }, - "contentChanges": [{ - "text": "\nproc a() = discard\na()\n" - } - ] - } + let didChangeParams = + DidChangeTextDocumentParams %* { + "textDocument": {"uri": helloWorldUri, "version": 1}, + "contentChanges": [{"text": "\nproc a() = discard\na()\n"}], + } client.notify("textDocument/didChange", %didChangeParams) let @@ -299,20 +233,17 @@ suite "LSP features": doAssert contains($hover, "hw.a: proc ()") test "Completion": - let completionParams = CompletionParams %* { - "position": { - "line": 3, - "character": 2 - }, - "textDocument": { - "uri": fixtureUri("projects/hw/hw.nim") - } - } + let completionParams = + CompletionParams %* { + "position": {"line": 3, "character": 2}, + "textDocument": {"uri": fixtureUri("projects/hw/hw.nim")}, + } - let actualEchoCompletionItem = - to(waitFor client.call("textDocument/completion", %completionParams), - seq[CompletionItem]) - .filter(item => item.label == "echo")[0] + let actualEchoCompletionItem = to( + waitFor client.call("textDocument/completion", %completionParams), + seq[CompletionItem], + ) + .filter(item => item.label == "echo")[0] doAssert actualEchoCompletionItem.label == "echo" doAssert actualEchoCompletionItem.kind.get == 3 @@ -325,36 +256,30 @@ suite "LSP features": nullResponse = waitFor client.call("shutdown", nullValue) doAssert nullResponse == nullValue - doAssert ls.isShutdown + doAssert ls.isShutdown suite "Null configuration:": - let cmdParams = CommandLineParams(mode: some lsp, transport: some socket, port: getNextFreePort()) + let cmdParams = + CommandLineParams(mode: some lsp, transport: some socket, port: getNextFreePort()) let ls = main(cmdParams) let client = newLspSocketClient() client.registerNotification( - "window/showMessage", - "window/workDoneProgress/create", - "workspace/configuration", - "extension/statusUpdate", - "extension/statusUpdate", - "textDocument/publishDiagnostics", - "$/progress" - ) - + "window/showMessage", "window/workDoneProgress/create", "workspace/configuration", + "extension/statusUpdate", "extension/statusUpdate", + "textDocument/publishDiagnostics", "$/progress", + ) + waitFor client.connect("localhost", cmdParams.port) - let initParams = LspInitializeParams %* { + let initParams = + LspInitializeParams %* { "processId": %getCurrentProcessId(), "rootUri": fixtureUri("projects/hw/"), "capabilities": { "workspace": {"configuration": true}, - "textDocument": { - "rename": { - "prepareSupport": true - } - } - } - } + "textDocument": {"rename": {"prepareSupport": true}}, + }, + } discard waitFor client.initialize(initParams) client.notify("initialized", newJObject()) diff --git a/tests/tnimtrack.nim b/tests/tnimtrack.nim new file mode 100644 index 00000000..eeb1a4bb --- /dev/null +++ b/tests/tnimtrack.nim @@ -0,0 +1,129 @@ +import ../[nimlangserver, ls, lstransports, utils] +import ../protocol/[enums, types] +import std/[options, json, os, osproc, jsonutils, sequtils, strutils, strformat] +import json_rpc/[rpcclient] +import chronicles +import lspsocketclient +import unittest2 + +suite "Nim track with nim >= 2.3.1": + let trackProjectDir = absolutePath("tests" / "projects" / "trackproject") + let savedDir = getCurrentDir() + setCurrentDir(trackProjectDir) + discard execCmdEx("nimble install -l -y") + discard execCmdEx("nimble setup") + setCurrentDir(savedDir) + + let cmdParams = + CommandLineParams(mode: some lsp, transport: some socket, port: getNextFreePort()) + let ls = main(cmdParams) + let client = newLspSocketClient() + client.registerNotification( + "window/showMessage", "extension/statusUpdate", "textDocument/publishDiagnostics", + "$/progress", + ) + waitFor client.connect("localhost", cmdParams.port) + + let conf = NlsConfig(useNimTrack: some true) + ls.workspaceConfiguration = newFuture[JsonNode]() + ls.workspaceConfiguration.complete(% @[conf]) + + let initParams = + LspInitializeParams %* { + "processId": %getCurrentProcessId(), + "rootUri": fixtureUri("projects/trackproject/"), + "capabilities": {"window": {"workDoneProgress": false}}, + } + discard waitFor client.initialize(initParams) + + let trackFile = "projects/trackproject/src/trackproject.nim" + client.notify("textDocument/didOpen", %createDidOpenParams(trackFile)) + + let trackAbsFile = trackFile.fixtureUri.uriToPath + check waitFor client.waitForNotificationMessage( + fmt"Nimsuggest initialized for {trackAbsFile}" + ) + + let trackUri = fixtureUri("projects/trackproject/src/trackproject.nim") + + test "Definition with nim track": + client.notify("textDocument/didOpen", %createDidOpenParams(trackFile)) + discard waitFor client.waitForNotificationMessage( + fmt"Nimsuggest initialized for {trackAbsFile}" + ) + let + positionParams = positionParams(trackUri, 4, 6) + locations = to( + waitFor client.call("textDocument/definition", %positionParams), seq[Location] + ) + check locations.len == 1 + check locations[0].uri.pathToUri().contains("trackproject.nim") + + test "References with nim track": + client.notify("textDocument/didOpen", %createDidOpenParams(trackFile)) + discard waitFor client.waitForNotificationMessage( + fmt"Nimsuggest initialized for {trackAbsFile}" + ) + let referenceParams = + ReferenceParams %* { + "context": {"includeDeclaration": false}, + "position": {"line": 4, "character": 6}, + "textDocument": {"uri": trackUri}, + } + let locations = to( + waitFor client.call("textDocument/references", %referenceParams), seq[Location] + ) + check locations.len >= 1 + +suite "Nim track unavailable with nim < 2.3.1": + let cmdParams = + CommandLineParams(mode: some lsp, transport: some socket, port: getNextFreePort()) + let ls = main(cmdParams) + let client = newLspSocketClient() + client.registerNotification( + "window/showMessage", "extension/statusUpdate", "textDocument/publishDiagnostics", + "$/progress", + ) + waitFor client.connect("localhost", cmdParams.port) + + let conf = NlsConfig(useNimTrack: some true) + ls.workspaceConfiguration = newFuture[JsonNode]() + ls.workspaceConfiguration.complete(% @[conf]) + + let initParams = + LspInitializeParams %* { + "processId": %getCurrentProcessId(), + "rootUri": fixtureUri("projects/hw/"), + "capabilities": {"window": {"workDoneProgress": false}}, + } + discard waitFor client.initialize(initParams) + + let hwFile = "projects/hw/hw.nim" + client.notify("textDocument/didOpen", %createDidOpenParams(hwFile)) + + let hwAbsFile = hwFile.fixtureUri.uriToPath + check waitFor client.waitForNotificationMessage( + fmt"Nimsuggest initialized for {hwAbsFile}" + ) + + let hwUri = fixtureUri("projects/hw/hw.nim") + + test "Definition returns empty": + let + positionParams = positionParams(hwUri, 1, 6) + locations = to( + waitFor client.call("textDocument/definition", %positionParams), seq[Location] + ) + check locations.len == 0 + + test "References returns empty": + let referenceParams = + ReferenceParams %* { + "context": {"includeDeclaration": false}, + "position": {"line": 1, "character": 6}, + "textDocument": {"uri": hwUri}, + } + let locations = to( + waitFor client.call("textDocument/references", %referenceParams), seq[Location] + ) + check locations.len == 0 diff --git a/trackapi.nim b/trackapi.nim new file mode 100644 index 00000000..b919d291 --- /dev/null +++ b/trackapi.nim @@ -0,0 +1,68 @@ +import chronos, chronos/asyncproc, strutils, strformat, chronicles, suggestapi, utils + +type TrackMode* = enum + tmDef = "def" + tmUsages = "usages" + tmDefUsages = "defusages" + +proc parseTrackOutput(raw: string): seq[Suggest] = + for line in raw.splitLines: + if line.len == 0 or line.startsWith("Hint:") or + not (line.startsWith("def\t") or line.startsWith("use\t")): + continue + let tokens = line.split('\t') + if tokens.len < 8: + continue + result.add Suggest( + section: parseEnum[IdeCmd]("ide" & capitalizeAscii(tokens[0])), + symKind: tokens[1], + qualifiedPath: parseQualifiedPath(tokens[2]), + forth: tokens[3], + filePath: tokens[4], + line: parseInt(tokens[5]), + column: parseInt(tokens[6]), + ) + +proc track*( + projectFile, file: string, + line, col: int, + mode: TrackMode, + nimPath: string, + workingDir: string, + timeout = REQUEST_TIMEOUT, +): Future[seq[Suggest]] {.async.} = + let arg = fmt "--{$mode}:{file},{line},{col}" + + debug "nim track", projectFile = projectFile, arg = arg + + let process = await startProcess( + nimPath, + workingDir = workingDir, + arguments = @["track", projectFile, arg], + options = {UsePath}, + stdoutHandle = AsyncProcess.Pipe, + stderrHandle = AsyncProcess.Pipe, + ) + + try: + let stdoutFuture = process.stdoutStream.read() + let stderrFuture = process.stderrStream.read() + let exitCode = await process.waitForExit(timeout.milliseconds) + let stdoutBytes = await stdoutFuture + var stderrStr = "" + try: + stderrStr = (await stderrFuture).toString + except CatchableError: + discard + if "invalid command: track" in stderrStr: + warn "nim track not supported (requires nim >= 2.3.1)", nimPath = nimPath + return @[] + if exitCode != 0: + debug "nim track exit", exitCode = exitCode + result = parseTrackOutput(stdoutBytes.toString) + except CancelledError as e: + await shutdownChildProcess(process) + raise e + except CatchableError as e: + debug "nim track exception", error = e.msg, name = e.name + result = @[]