Compare commits
11
Commits
2026.6.100
...
33cf282b0a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33cf282b0a | ||
|
|
35a4357551 | ||
|
|
f5bd79f067 | ||
|
|
a39aee1b9d | ||
|
|
4ca1b0cce9 | ||
|
|
6051260a8d | ||
|
|
dfe15dd3db | ||
|
|
5d1c402d2a | ||
|
|
88f834ae79 | ||
|
|
3b233389c8 | ||
|
|
67bd9de18a |
Vendored
+35
-12
@@ -10,13 +10,21 @@
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "${execPath}",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": ["${workspaceFolder}/client/**/*.js"],
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${env:TEMP}/nx-post-support-vscode-debug",
|
||||
"${env:TEMP}/nx-post-support-vscode-debug",
|
||||
"${env:TEMP}/nx-post-support-vscode-debug/test/test.tcl"
|
||||
],
|
||||
"cwd": "${env:TEMP}/nx-post-support-vscode-debug",
|
||||
"outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"autoAttachChildProcesses": true,
|
||||
"preLaunchTask": {
|
||||
"type": "npm",
|
||||
"script": "watch"
|
||||
}
|
||||
"preLaunchTask": "NX Post Support: Compile Debug"
|
||||
},
|
||||
{
|
||||
"name": "Python Attach",
|
||||
@@ -34,10 +42,24 @@
|
||||
"name": "Debug Extension (hidden)",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": ["${workspaceFolder}/client/**/*.js"],
|
||||
"runtimeExecutable": "${execPath}",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${env:TEMP}/nx-post-support-vscode-debug",
|
||||
"${env:TEMP}/nx-post-support-vscode-debug",
|
||||
"${env:TEMP}/nx-post-support-vscode-debug/test/test.tcl"
|
||||
],
|
||||
"cwd": "${env:TEMP}/nx-post-support-vscode-debug",
|
||||
"outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"env": {
|
||||
"USE_DEBUGPY": "True"
|
||||
"USE_DEBUGPY": "True",
|
||||
"NXPS_DEBUG_HOST": "127.0.0.1",
|
||||
"NXPS_DEBUG_PORT": "5678"
|
||||
},
|
||||
"presentation": {
|
||||
"hidden": true,
|
||||
@@ -49,8 +71,9 @@
|
||||
"name": "Python debug server (hidden)",
|
||||
"type": "debugpy",
|
||||
"request": "attach",
|
||||
"listen": { "host": "localhost", "port": 5678 },
|
||||
"justMyCode": true,
|
||||
"listen": { "host": "127.0.0.1", "port": 5678 },
|
||||
"justMyCode": false,
|
||||
"logToFile": true,
|
||||
"presentation": {
|
||||
"hidden": true,
|
||||
"group": "",
|
||||
@@ -63,7 +86,7 @@
|
||||
"name": "Debug Extension and Python",
|
||||
"configurations": ["Python debug server (hidden)", "Debug Extension (hidden)"],
|
||||
"stopAll": true,
|
||||
"preLaunchTask": "npm: watch",
|
||||
"preLaunchTask": "NX Post Support: Compile Debug",
|
||||
"presentation": {
|
||||
"hidden": false,
|
||||
"group": "",
|
||||
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WorkspaceRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DebugRoot
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function ConvertFrom-ExtendedWindowsPath {
|
||||
param([string]$Path)
|
||||
|
||||
if ($Path.StartsWith("\\?\UNC\", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return "\\" + $Path.Substring(8)
|
||||
}
|
||||
if ($Path.StartsWith("\\?\", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $Path.Substring(4)
|
||||
}
|
||||
return $Path
|
||||
}
|
||||
|
||||
$workspacePath = ConvertFrom-ExtendedWindowsPath $WorkspaceRoot
|
||||
$workspaceItem = Get-Item -LiteralPath $workspacePath
|
||||
if (-not $workspaceItem.PSIsContainer) {
|
||||
throw "Workspace root is not a directory: $workspacePath"
|
||||
}
|
||||
$workspacePath = $workspaceItem.FullName
|
||||
|
||||
$debugPath = ConvertFrom-ExtendedWindowsPath $DebugRoot
|
||||
$tempPath = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd("\")
|
||||
$debugParent = [System.IO.Path]::GetFullPath((Split-Path -Parent $debugPath)).TrimEnd("\")
|
||||
if (-not $debugParent.Equals($tempPath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Debug alias must be located directly below the user temp directory: $debugPath"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $debugPath) {
|
||||
$debugItem = Get-Item -LiteralPath $debugPath -Force
|
||||
if ($debugItem.LinkType -ne "Junction") {
|
||||
throw "Debug alias exists but is not a junction: $debugPath"
|
||||
}
|
||||
|
||||
$currentTarget = (Get-Item -LiteralPath $debugItem.Target).FullName
|
||||
if (-not $currentTarget.Equals($workspacePath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
# Removing a junction removes only the link, never the target directory.
|
||||
Remove-Item -LiteralPath $debugPath -Force
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $debugPath)) {
|
||||
New-Item -ItemType Junction -Path $debugPath -Target $workspacePath | Out-Null
|
||||
}
|
||||
|
||||
Write-Output "Debug extension path: $debugPath -> $workspacePath"
|
||||
& npm.cmd --prefix $workspacePath run compile:debug
|
||||
exit $LASTEXITCODE
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "NX Post Support: Compile Debug",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"& { param([string]$WorkspaceRoot, [string]$DebugRoot); $scriptRoot = $WorkspaceRoot; if ($scriptRoot.StartsWith('\\\\?\\')) { $scriptRoot = $scriptRoot.Substring(4) }; & (Join-Path $scriptRoot '.vscode\\prepare-debug.ps1') -WorkspaceRoot $WorkspaceRoot -DebugRoot $DebugRoot; exit $LASTEXITCODE }",
|
||||
"${workspaceFolder}",
|
||||
"${env:TEMP}\\nx-post-support-vscode-debug"
|
||||
],
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"clear": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
## Unreleased
|
||||
|
||||
- Add signature help for custom TCL procedures and built-in NX/MOM procedures
|
||||
- Clean stale TCL indexes on close, delete, and rename operations
|
||||
- Make background parsing and index updates thread-safe
|
||||
|
||||
## [0.0.1]
|
||||
|
||||
- Initial release
|
||||
@@ -17,3 +23,7 @@
|
||||
## [2026.6.100]
|
||||
|
||||
- Fix several bugs
|
||||
|
||||
## [2026.6.200]
|
||||
|
||||
- Fix foramtting bug
|
||||
|
||||
@@ -9,6 +9,7 @@ A comprehensive VS Code extension providing language support for NX CAM postproc
|
||||
- **Multi-language Support** - Supports NX CDL, TCL, and DEF file formats
|
||||
- **Intelligent Code Analysis** - Linting and error detection for postprocessor code
|
||||
- **Auto-completion** - Context-aware code completion for faster development
|
||||
- **Signature Help** - Shows parameters and documentation for custom and NX procedures
|
||||
|
||||
## Supported File Types
|
||||
|
||||
@@ -19,7 +20,7 @@ A comprehensive VS Code extension providing language support for NX CAM postproc
|
||||
## Installation
|
||||
|
||||
1. Install from the VS Code Marketplace
|
||||
2. Install Python 3.8 or higher
|
||||
2. Install Python 3.11 or higher
|
||||
3. Open any `.cdl`, `.tcl`, or `.def` file
|
||||
4. The extension will automatically activate and provide language support
|
||||
|
||||
@@ -40,6 +41,30 @@ Simply open any supported file type and enjoy:
|
||||
- Code completion
|
||||
- Code formatting (Format Document command)
|
||||
- Hover information
|
||||
- Signature help while entering procedure arguments
|
||||
|
||||
## Development and debugging
|
||||
|
||||
Install the root and client dependencies before the first debug session:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm install --prefix client
|
||||
```
|
||||
|
||||
Use one of the checked-in VS Code launch configurations:
|
||||
|
||||
- **Run Extension** debugs the TypeScript extension host.
|
||||
- **Debug Extension and Python** debugs both the TypeScript extension and the
|
||||
Python language server. This is the recommended configuration for LSP work.
|
||||
- **Python Attach** attaches manually to an already running Python process.
|
||||
|
||||
The launch configuration creates a fresh non-minified bundle with embedded
|
||||
source maps and opens `test/test.tcl` so the extension activates immediately.
|
||||
For combined debugging, the Python adapter listens on `127.0.0.1:5678`; the
|
||||
language server waits for that adapter before initialization. The NX
|
||||
Postprocessor Support output channel reports `Python debug mode: enabled` and
|
||||
shows `_debug_server.py` in the server command when the debug path is active.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
Generated
+22
@@ -14,6 +14,7 @@
|
||||
"vscode-languageclient": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/vscode": "^1.96.0"
|
||||
},
|
||||
@@ -21,6 +22,27 @@
|
||||
"vscode": "^1.96.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/fs-extra": {
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz",
|
||||
"integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/jsonfile": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/jsonfile": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz",
|
||||
"integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.10.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"vscode-languageclient": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/vscode": "^1.96.0"
|
||||
}
|
||||
|
||||
@@ -137,6 +137,102 @@ export function hoverCdlHandler(document: vscode.TextDocument, position: vscode.
|
||||
return undefined
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
export function cdlEventAtPosition(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position
|
||||
): string | undefined {
|
||||
const line = document.lineAt(position.line).text
|
||||
const match = /^\s*EVENT\s+([^\s{]+)/.exec(line)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const declarationStart = match.index
|
||||
const eventEnd = line.indexOf(match[1], match.index) + match[1].length
|
||||
if (position.character < declarationStart || position.character > eventEnd) {
|
||||
return undefined
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
export async function definitionCdlEventHandler(
|
||||
document: vscode.TextDocument,
|
||||
position: vscode.Position,
|
||||
token: vscode.CancellationToken
|
||||
): Promise<vscode.Location[] | undefined> {
|
||||
const eventName = cdlEventAtPosition(document, position)
|
||||
if (!eventName) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const handlerName = `MOM_${eventName}`
|
||||
try {
|
||||
const symbols = await vscode.commands.executeCommand<vscode.SymbolInformation[]>(
|
||||
"vscode.executeWorkspaceSymbolProvider",
|
||||
handlerName
|
||||
)
|
||||
const indexedLocations = (symbols || [])
|
||||
.filter(
|
||||
(symbol) =>
|
||||
symbol.kind === vscode.SymbolKind.Function &&
|
||||
(symbol.name === handlerName ||
|
||||
symbol.name.endsWith(`::${handlerName}`))
|
||||
)
|
||||
.map((symbol) => symbol.location)
|
||||
if (indexedLocations.length > 0) {
|
||||
return indexedLocations
|
||||
}
|
||||
} catch {
|
||||
// The Tcl language server may still be starting; use the file fallback below.
|
||||
}
|
||||
|
||||
const declaration = new RegExp(
|
||||
`^\\s*proc\\s+(?:::)?${escapeRegExp(handlerName)}(?=\\s|\\{)`
|
||||
)
|
||||
const tclFiles = await vscode.workspace.findFiles(
|
||||
"**/*.tcl",
|
||||
"**/{.git,.nox,.venv,dist,node_modules,out}/**"
|
||||
)
|
||||
const locations: vscode.Location[] = []
|
||||
|
||||
for (const uri of tclFiles) {
|
||||
if (token.isCancellationRequested) {
|
||||
return undefined
|
||||
}
|
||||
let tclDocument: vscode.TextDocument
|
||||
try {
|
||||
tclDocument = await vscode.workspace.openTextDocument(uri)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (let lineNumber = 0; lineNumber < tclDocument.lineCount; lineNumber++) {
|
||||
const line = tclDocument.lineAt(lineNumber).text
|
||||
const match = declaration.exec(line)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
const start = line.indexOf(handlerName, match.index)
|
||||
locations.push(
|
||||
new vscode.Location(
|
||||
uri,
|
||||
new vscode.Range(
|
||||
lineNumber,
|
||||
start,
|
||||
lineNumber,
|
||||
start + handlerName.length
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return locations.length > 0 ? locations : undefined
|
||||
}
|
||||
|
||||
export function tclDocumentSymbolProvider(document: vscode.TextDocument): vscode.DocumentSymbol[] {
|
||||
const symbols: vscode.DocumentSymbol[] = []
|
||||
const lines = document.getText().split("\n")
|
||||
|
||||
+66
-34
@@ -2,7 +2,7 @@
|
||||
// Licensed under the MIT License.
|
||||
|
||||
import * as fsapi from "fs-extra"
|
||||
import { Disposable, env, LogOutputChannel } from "vscode"
|
||||
import { Disposable, env, LogOutputChannel, workspace } from "vscode"
|
||||
import { State } from "vscode-languageclient"
|
||||
import {
|
||||
LanguageClient,
|
||||
@@ -24,6 +24,13 @@ import { isVirtualWorkspace } from "./vscodeapi"
|
||||
|
||||
export type IInitOptions = { settings: ISettings[]; globalSettings: ISettings }
|
||||
|
||||
let _disposables: Disposable[] = []
|
||||
|
||||
export function disposeServerResources(): void {
|
||||
_disposables.forEach((disposable) => disposable.dispose())
|
||||
_disposables = []
|
||||
}
|
||||
|
||||
async function createServer(
|
||||
settings: ISettings,
|
||||
serverId: string,
|
||||
@@ -32,13 +39,26 @@ async function createServer(
|
||||
initializationOptions: IInitOptions
|
||||
): Promise<LanguageClient> {
|
||||
const command = settings.interpreter[0]
|
||||
if (!command) {
|
||||
throw new Error("No Python interpreter is configured for the language server.")
|
||||
}
|
||||
const cwd = settings.cwd
|
||||
|
||||
// Set debugger path needed for debugging python code.
|
||||
const newEnv = { ...process.env }
|
||||
const debuggerPath = await getDebuggerPath()
|
||||
const isDebugScript = await fsapi.pathExists(DEBUG_SERVER_SCRIPT_PATH)
|
||||
if (newEnv.USE_DEBUGPY && debuggerPath) {
|
||||
const debugRequested = newEnv.USE_DEBUGPY?.toLowerCase() === "true"
|
||||
if (debugRequested && !isDebugScript) {
|
||||
throw new Error(`Python debug bootstrap not found: ${DEBUG_SERVER_SCRIPT_PATH}`)
|
||||
}
|
||||
|
||||
const debuggerPath = debugRequested ? await getDebuggerPath() : undefined
|
||||
if (debugRequested && !debuggerPath) {
|
||||
throw new Error(
|
||||
"Python debugging was requested, but the Python Debugger extension did not provide debugpy."
|
||||
)
|
||||
}
|
||||
if (debugRequested && debuggerPath) {
|
||||
newEnv.DEBUGPY_PATH = debuggerPath
|
||||
} else {
|
||||
newEnv.USE_DEBUGPY = "False"
|
||||
@@ -50,10 +70,13 @@ async function createServer(
|
||||
// Set notification type
|
||||
newEnv.LS_SHOW_NOTIFICATION = settings.showNotifications
|
||||
|
||||
const args =
|
||||
newEnv.USE_DEBUGPY === "False" || !isDebugScript
|
||||
? settings.interpreter.slice(1).concat([SERVER_SCRIPT_PATH])
|
||||
: settings.interpreter.slice(1).concat([DEBUG_SERVER_SCRIPT_PATH])
|
||||
const serverScript = debugRequested ? DEBUG_SERVER_SCRIPT_PATH : SERVER_SCRIPT_PATH
|
||||
const interpreterArgs = settings.interpreter.slice(1)
|
||||
if (debugRequested && !interpreterArgs.includes("-Xfrozen_modules=off")) {
|
||||
interpreterArgs.push("-Xfrozen_modules=off")
|
||||
}
|
||||
const args = interpreterArgs.concat([serverScript])
|
||||
traceInfo(`Python debug mode: ${debugRequested ? "enabled" : "disabled"}`)
|
||||
traceInfo(`Server run command: ${[command, ...args].join(" ")}`)
|
||||
|
||||
const serverOptions: ServerOptions = {
|
||||
@@ -63,6 +86,7 @@ async function createServer(
|
||||
}
|
||||
|
||||
// Options to control the language client
|
||||
const tclFileWatcher = workspace.createFileSystemWatcher("**/*.tcl")
|
||||
const clientOptions: LanguageClientOptions = {
|
||||
// Register the server for python documents
|
||||
documentSelector: isVirtualWorkspace()
|
||||
@@ -76,13 +100,16 @@ async function createServer(
|
||||
outputChannel: outputChannel,
|
||||
traceOutputChannel: outputChannel,
|
||||
revealOutputChannelOn: RevealOutputChannelOn.Never,
|
||||
synchronize: {
|
||||
fileEvents: tclFileWatcher
|
||||
},
|
||||
initializationOptions
|
||||
}
|
||||
|
||||
_disposables.push(tclFileWatcher)
|
||||
return new LanguageClient(serverId, serverName, serverOptions, clientOptions)
|
||||
}
|
||||
|
||||
let _disposables: Disposable[] = []
|
||||
export async function restartServer(
|
||||
serverId: string,
|
||||
serverName: string,
|
||||
@@ -92,40 +119,45 @@ export async function restartServer(
|
||||
if (lsClient) {
|
||||
traceInfo(`Server: Stop requested`)
|
||||
await lsClient.stop()
|
||||
_disposables.forEach((d) => d.dispose())
|
||||
_disposables = []
|
||||
disposeServerResources()
|
||||
}
|
||||
const projectRoot = await getProjectRoot()
|
||||
const workspaceSetting = await getWorkspaceSettings(serverId, projectRoot, true)
|
||||
|
||||
const newLSClient = await createServer(workspaceSetting, serverId, serverName, outputChannel, {
|
||||
settings: await getExtensionSettings(serverId, true),
|
||||
globalSettings: await getGlobalSettings(serverId, false)
|
||||
})
|
||||
traceInfo(`Server: Start requested.`)
|
||||
_disposables.push(
|
||||
newLSClient.onDidChangeState((e) => {
|
||||
switch (e.newState) {
|
||||
case State.Stopped:
|
||||
traceVerbose(`Server State: Stopped`)
|
||||
break
|
||||
case State.Starting:
|
||||
traceVerbose(`Server State: Starting`)
|
||||
break
|
||||
case State.Running:
|
||||
traceVerbose(`Server State: Running`)
|
||||
break
|
||||
}
|
||||
})
|
||||
)
|
||||
try {
|
||||
const newLSClient = await createServer(
|
||||
workspaceSetting,
|
||||
serverId,
|
||||
serverName,
|
||||
outputChannel,
|
||||
{
|
||||
settings: await getExtensionSettings(serverId, true),
|
||||
globalSettings: await getGlobalSettings(serverId, false)
|
||||
}
|
||||
)
|
||||
traceInfo(`Server: Start requested.`)
|
||||
_disposables.push(
|
||||
newLSClient.onDidChangeState((e) => {
|
||||
switch (e.newState) {
|
||||
case State.Stopped:
|
||||
traceVerbose(`Server State: Stopped`)
|
||||
break
|
||||
case State.Starting:
|
||||
traceVerbose(`Server State: Starting`)
|
||||
break
|
||||
case State.Running:
|
||||
traceVerbose(`Server State: Running`)
|
||||
break
|
||||
}
|
||||
})
|
||||
)
|
||||
await newLSClient.start()
|
||||
const level = getLSClientTraceLevel(outputChannel.logLevel, env.logLevel)
|
||||
await newLSClient.setTrace(level)
|
||||
return newLSClient
|
||||
} catch (ex) {
|
||||
traceError(`Server: Start failed: ${ex}`)
|
||||
disposeServerResources()
|
||||
return undefined
|
||||
}
|
||||
|
||||
const level = getLSClientTraceLevel(outputChannel.logLevel, env.logLevel)
|
||||
await newLSClient.setTrace(level)
|
||||
return newLSClient
|
||||
}
|
||||
|
||||
+33
-6
@@ -13,7 +13,8 @@ import {
|
||||
isFirstLineMachine,
|
||||
diagnosticHandler,
|
||||
cdlDocumentSymbolProvider,
|
||||
defDocumentSymbolProvider
|
||||
defDocumentSymbolProvider,
|
||||
definitionCdlEventHandler
|
||||
} from "./common/handlers"
|
||||
import { registerLogger, traceError, traceLog, traceVerbose } from "./common/log/logging"
|
||||
import {
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
onDidChangePythonInterpreter,
|
||||
resolveInterpreter
|
||||
} from "./common/python"
|
||||
import { restartServer } from "./common/server"
|
||||
import { disposeServerResources, restartServer } from "./common/server"
|
||||
import { checkIfConfigurationChanged, getInterpreterFromSetting } from "./common/settings"
|
||||
import { loadServerDefaults } from "./common/setup"
|
||||
import { getLSClientTraceLevel } from "./common/utilities"
|
||||
@@ -37,6 +38,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const serverInfo = loadServerDefaults()
|
||||
const serverName = serverInfo.name
|
||||
const serverId = serverInfo.module
|
||||
const pythonDebugMode = process.env.USE_DEBUGPY?.toLowerCase() === "true"
|
||||
|
||||
// Setup logging
|
||||
const outputChannel = createOutputChannel(serverName)
|
||||
@@ -100,10 +102,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
return runServerQueue
|
||||
}
|
||||
|
||||
if (!pythonDebugMode) {
|
||||
context.subscriptions.push(
|
||||
onDidChangePythonInterpreter(async () => {
|
||||
await runServer()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
context.subscriptions.push(
|
||||
onDidChangePythonInterpreter(async () => {
|
||||
await runServer()
|
||||
}),
|
||||
onDidChangeConfiguration(async (e: vscode.ConfigurationChangeEvent) => {
|
||||
if (checkIfConfigurationChanged(e, serverId)) {
|
||||
await runServer()
|
||||
@@ -115,6 +122,15 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
)
|
||||
|
||||
setImmediate(async () => {
|
||||
if (pythonDebugMode) {
|
||||
// A debugpy listen session is attached to exactly one process. Do not
|
||||
// subscribe to interpreter changes during startup, as the Python
|
||||
// extension can emit a duplicate event and restart that process.
|
||||
traceLog("Python debug mode: starting one stable server session")
|
||||
await runServer()
|
||||
return
|
||||
}
|
||||
|
||||
const interpreter = getInterpreterFromSetting(serverId)
|
||||
if (interpreter === undefined || interpreter.length === 0) {
|
||||
traceLog(`Python extension loading`)
|
||||
@@ -177,6 +193,16 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
)
|
||||
context.subscriptions.push(hoverCdlProvider)
|
||||
|
||||
const definitionCdlEventProvider = vscode.languages.registerDefinitionProvider(
|
||||
{ scheme: "file", language: "cdl" },
|
||||
{
|
||||
provideDefinition(document, position, token) {
|
||||
return definitionCdlEventHandler(document, position, token)
|
||||
}
|
||||
}
|
||||
)
|
||||
context.subscriptions.push(definitionCdlEventProvider)
|
||||
|
||||
const formatDefProvider = vscode.languages.registerDocumentFormattingEditProvider(
|
||||
{ scheme: "file", language: "def" },
|
||||
{
|
||||
@@ -246,7 +272,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
export function deactivate(): Thenable<void> | undefined {
|
||||
if (!client) {
|
||||
disposeServerResources()
|
||||
return undefined
|
||||
}
|
||||
return client.stop()
|
||||
return client.stop().finally(disposeServerResources)
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,19 +1,21 @@
|
||||
const esbuild = require("esbuild")
|
||||
const path = require("path")
|
||||
|
||||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
|
||||
async function main() {
|
||||
const ctx = await esbuild.context({
|
||||
absWorkingDir: __dirname,
|
||||
entryPoints: ["client/src/extension.ts"],
|
||||
bundle: true,
|
||||
format: "cjs",
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
sourcesContent: false,
|
||||
sourcesContent: !production,
|
||||
platform: "node",
|
||||
// outdir: "out",
|
||||
outfile: "./dist/extension.js",
|
||||
outfile: path.join(__dirname, "dist", "extension.js"),
|
||||
external: ["vscode"],
|
||||
logLevel: "silent",
|
||||
plugins: [
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@
|
||||
"name": "nx-post-support",
|
||||
"displayName": "NX Postprocessor Support",
|
||||
"description": "VS Code extension for NX CAM postprocessor development with syntax highlighting, formatting, linting, and auto-completion for CDL, TCL, and DEF files",
|
||||
"version": "2026.6.0",
|
||||
"version": "2026.6.201",
|
||||
"publisher": "Christoph",
|
||||
"icon": "images/nx-1.png",
|
||||
"extensionDependencies": [
|
||||
@@ -124,6 +124,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "node esbuild.js --production",
|
||||
"compile:debug": "node esbuild.js",
|
||||
"watch": "node esbuild.js --watch",
|
||||
"package": "node esbuild.js --production"
|
||||
},
|
||||
|
||||
+47
-24
@@ -6,6 +6,7 @@ import os
|
||||
import pathlib
|
||||
import runpy
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def update_sys_path(path_to_add: str) -> None:
|
||||
@@ -14,10 +15,43 @@ def update_sys_path(path_to_add: str) -> None:
|
||||
sys.path.append(path_to_add)
|
||||
|
||||
|
||||
# Ensure debugger is loaded before we load anything else, to debug initialization.
|
||||
debugger_path = os.getenv("DEBUGPY_PATH", None)
|
||||
if debugger_path:
|
||||
if debugger_path.endswith("debugpy"):
|
||||
def _debug_endpoint() -> tuple[str, int]:
|
||||
host = os.getenv("NXPS_DEBUG_HOST", "127.0.0.1")
|
||||
raw_port = os.getenv("NXPS_DEBUG_PORT", "5678")
|
||||
try:
|
||||
port = int(raw_port)
|
||||
except ValueError as error:
|
||||
raise RuntimeError(f"Invalid NXPS_DEBUG_PORT: {raw_port!r}") from error
|
||||
if not 1 <= port <= 65535:
|
||||
raise RuntimeError(f"NXPS_DEBUG_PORT is outside the valid range: {port}")
|
||||
return host, port
|
||||
|
||||
|
||||
def _connect_debugger(debugpy, host: str, port: int, timeout: float = 15.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_error: OSError | None = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
debugpy.connect((host, port))
|
||||
debugpy.wait_for_client()
|
||||
return
|
||||
except (ConnectionRefusedError, OSError) as error:
|
||||
last_error = error
|
||||
time.sleep(0.25)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Could not connect debugpy to {host}:{port} within {timeout:.0f} seconds"
|
||||
) from last_error
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Ensure debugger is loaded before we load anything else, so server
|
||||
# initialization and module-level feature registration can be debugged.
|
||||
debugger_path = os.getenv("DEBUGPY_PATH")
|
||||
if not debugger_path:
|
||||
raise RuntimeError("DEBUGPY_PATH is missing in Python debug mode")
|
||||
|
||||
if pathlib.Path(debugger_path).name.casefold() == "debugpy":
|
||||
debugger_path = os.fspath(pathlib.Path(debugger_path).parent)
|
||||
|
||||
update_sys_path(debugger_path)
|
||||
@@ -25,25 +59,14 @@ if debugger_path:
|
||||
# pylint: disable=wrong-import-position,import-error
|
||||
import debugpy
|
||||
|
||||
# 5678 is the default port, If you need to change it update it here
|
||||
# and in launch.json.
|
||||
# Connecting requires the "Python debug server" listener (launch.json) to be
|
||||
# up first. If it isn't (e.g. wrong launch config was used), don't crash the
|
||||
# whole language server - just continue running without the debugger attached.
|
||||
try:
|
||||
debugpy.connect(5678)
|
||||
except (ConnectionRefusedError, OSError) as exc:
|
||||
print(
|
||||
f"debugpy: could not connect to debug adapter on port 5678 "
|
||||
f"({exc}); continuing without debugging.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
host, port = _debug_endpoint()
|
||||
print(f"debugpy: waiting for VS Code at {host}:{port}", file=sys.stderr)
|
||||
_connect_debugger(debugpy, host, port)
|
||||
print("debugpy: VS Code attached; starting language server", file=sys.stderr)
|
||||
|
||||
# This will ensure that execution is paused as soon as the debugger
|
||||
# connects to VS Code. If you don't want to pause here comment this
|
||||
# line and set breakpoints as appropriate.
|
||||
# debugpy.breakpoint()
|
||||
server_path = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py")
|
||||
runpy.run_path(server_path, run_name="__main__")
|
||||
|
||||
SERVER_PATH = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py")
|
||||
# NOTE: Set breakpoint in `lsp_server.py` before continuing.
|
||||
runpy.run_path(SERVER_PATH, run_name="__main__")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+329
-116
@@ -44,10 +44,17 @@ from pygls import uris, workspace
|
||||
from common.load_data import standard_items
|
||||
from tools.folding_ranges import build_folding_ranges
|
||||
from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier
|
||||
from tools.completion_items import completion, remove_existing_items, remove_shared_keys
|
||||
from tools.inlay_hint import InlayHintGenerator
|
||||
from tools.file_sourcing import get_all_psc_files, read_psc_file
|
||||
from tools.navigation import (
|
||||
SymbolIdentity,
|
||||
definition_identities,
|
||||
matching_occurrences,
|
||||
symbol_at_position,
|
||||
workspace_symbols,
|
||||
)
|
||||
from tools.signature_help import build_signature_help
|
||||
from lsp_tclserver import TclLanguageServer
|
||||
from pygls.workspace.text_document import TextDocument
|
||||
|
||||
|
||||
WORKSPACE_SETTINGS = {}
|
||||
@@ -59,6 +66,12 @@ LSP_SERVER = TclLanguageServer(
|
||||
name="NX Postprocessor Support", version="0.0.1", max_workers=MAX_WORKERS
|
||||
)
|
||||
|
||||
BUILTIN_PROC_NAMES = {
|
||||
item.label
|
||||
for item in standard_items.tcl_keyword_list + standard_items.nx_procs
|
||||
}
|
||||
BUILTIN_VARIABLE_NAMES = {item.label for item in standard_items.nx_variables}
|
||||
|
||||
# **********************************************************
|
||||
# Tool specific code goes below this.
|
||||
# **********************************************************
|
||||
@@ -77,6 +90,7 @@ LSP_SERVER = TclLanguageServer(
|
||||
def did_open(params: lsp.DidOpenTextDocumentParams) -> None:
|
||||
"""LSP handler for textDocument/didOpen request."""
|
||||
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||
LSP_SERVER.clear_cache_for_uri(document.uri)
|
||||
LSP_SERVER.compute_diagnostics(document)
|
||||
# Also update custom completion and proc docs for this file
|
||||
LSP_SERVER.update_poco_completion_for_file(document)
|
||||
@@ -89,18 +103,91 @@ def did_save(params: lsp.DidSaveTextDocumentParams) -> None:
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
|
||||
def did_close(_: lsp.DidCloseTextDocumentParams) -> None:
|
||||
def did_close(params: lsp.DidCloseTextDocumentParams) -> None:
|
||||
"""LSP handler for textDocument/didClose request."""
|
||||
uri = params.text_document.uri
|
||||
LSP_SERVER.remove_file_state(uri)
|
||||
_index_tcl_file_from_disk(uri)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
|
||||
def did_change(params: lsp.DidChangeTextDocumentParams) -> None:
|
||||
"""LSP handler for textDocument/didChange request"""
|
||||
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||
LSP_SERVER.clear_cache_for_uri(document.uri)
|
||||
LSP_SERVER.compute_diagnostics(document)
|
||||
LSP_SERVER.update_poco_completion_for_file(document)
|
||||
|
||||
|
||||
FILE_OPERATION_OPTIONS = lsp.FileOperationRegistrationOptions(
|
||||
filters=[
|
||||
lsp.FileOperationFilter(
|
||||
scheme="file",
|
||||
pattern=lsp.FileOperationPattern(glob="**/*"),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _index_tcl_file_from_disk(uri: str) -> None:
|
||||
if not uri.startswith("file:"):
|
||||
return
|
||||
|
||||
path = pathlib.Path(uris.to_fs_path(uri))
|
||||
if path.suffix.lower() != ".tcl" or not path.is_file():
|
||||
return
|
||||
|
||||
try:
|
||||
document = TextDocument(uri=uri, language_id="tcl")
|
||||
LSP_SERVER.update_poco_completion_for_file(
|
||||
document,
|
||||
cache_tree=False,
|
||||
require_file_exists=True,
|
||||
)
|
||||
except (OSError, UnicodeError) as error:
|
||||
log_warning(f"Could not re-index {path}: {error}")
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.WORKSPACE_DID_DELETE_FILES, FILE_OPERATION_OPTIONS)
|
||||
def did_delete_files(params: lsp.DeleteFilesParams) -> None:
|
||||
for deleted_file in params.files:
|
||||
LSP_SERVER.remove_file_state(deleted_file.uri)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.WORKSPACE_DID_RENAME_FILES, FILE_OPERATION_OPTIONS)
|
||||
def did_rename_files(params: lsp.RenameFilesParams) -> None:
|
||||
for renamed_file in params.files:
|
||||
old_path = pathlib.Path(uris.to_fs_path(renamed_file.old_uri))
|
||||
new_path = pathlib.Path(uris.to_fs_path(renamed_file.new_uri))
|
||||
indexed_paths = LSP_SERVER.indexed_paths_under_uri(renamed_file.old_uri)
|
||||
new_index_paths: set[pathlib.Path] = set()
|
||||
|
||||
for indexed_path in indexed_paths:
|
||||
if LSP_SERVER.paths_equal(indexed_path, old_path):
|
||||
new_index_paths.add(new_path)
|
||||
else:
|
||||
relative_path = os.path.relpath(indexed_path, old_path)
|
||||
new_index_paths.add(new_path / relative_path)
|
||||
|
||||
# Also handles renaming a previously unindexed file to a TCL file.
|
||||
if new_path.suffix.lower() == ".tcl":
|
||||
new_index_paths.add(new_path)
|
||||
|
||||
LSP_SERVER.remove_file_state(renamed_file.old_uri)
|
||||
for new_index_path in new_index_paths:
|
||||
_index_tcl_file_from_disk(new_index_path.as_uri())
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.WORKSPACE_DID_CHANGE_WATCHED_FILES)
|
||||
def did_change_watched_files(params: lsp.DidChangeWatchedFilesParams) -> None:
|
||||
"""Keep indexes for closed Tcl files synchronized with disk changes."""
|
||||
for change in params.changes:
|
||||
if change.type == lsp.FileChangeType.Deleted:
|
||||
LSP_SERVER.remove_file_state(change.uri)
|
||||
else:
|
||||
_index_tcl_file_from_disk(change.uri)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(
|
||||
lsp.TEXT_DOCUMENT_DIAGNOSTIC,
|
||||
lsp.DiagnosticOptions(
|
||||
@@ -111,13 +198,18 @@ def did_change(params: lsp.DidChangeTextDocumentParams) -> None:
|
||||
)
|
||||
def document_diagnostic(params: lsp.DocumentDiagnosticParams):
|
||||
"""Return diagnostics for the requested document"""
|
||||
was_cached = True
|
||||
if (uri := params.text_document.uri) not in LSP_SERVER.diagnostics:
|
||||
was_cached = False
|
||||
uri = params.text_document.uri
|
||||
diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
|
||||
was_cached = diagnostic_state is not None
|
||||
if diagnostic_state is None:
|
||||
doc = LSP_SERVER.workspace.get_text_document(uri)
|
||||
LSP_SERVER.compute_diagnostics(doc)
|
||||
diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
|
||||
|
||||
version, diagnostics = LSP_SERVER.diagnostics[uri]
|
||||
if diagnostic_state is None:
|
||||
return lsp.FullDocumentDiagnosticReport(items=[])
|
||||
|
||||
version, diagnostics = diagnostic_state
|
||||
result_id = f"{uri}@{version}"
|
||||
|
||||
if was_cached and result_id == params.previous_result_id:
|
||||
@@ -134,7 +226,8 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
||||
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||
|
||||
# Base items
|
||||
poco = [item for items in LSP_SERVER.poco_completion.values() for item in items]
|
||||
poco_completion, _, _ = LSP_SERVER.index_snapshot()
|
||||
poco = [item for items in poco_completion.values() for item in items]
|
||||
base_items = (
|
||||
standard_items.tcl_keyword_list
|
||||
+ standard_items.nx_procs
|
||||
@@ -169,19 +262,58 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
||||
)
|
||||
break
|
||||
|
||||
# Merge with de-duplication for variables only
|
||||
# Merge with de-duplication. Each file keeps its complete index, so a proc
|
||||
# declared in multiple files must only appear once in the completion list.
|
||||
merged: list[lsp.CompletionItem] = []
|
||||
seen_var_labels: set[str] = set()
|
||||
seen_items: set[tuple[str, lsp.CompletionItemKind | None]] = set()
|
||||
for it in base_items + dynamic_items:
|
||||
if getattr(it, "kind", None) == lsp.CompletionItemKind.Variable:
|
||||
if it.label in seen_var_labels:
|
||||
continue
|
||||
seen_var_labels.add(it.label)
|
||||
key = (it.label, getattr(it, "kind", None))
|
||||
if key in seen_items:
|
||||
continue
|
||||
seen_items.add(key)
|
||||
merged.append(it)
|
||||
|
||||
return lsp.CompletionList(is_incomplete=False, items=merged)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(
|
||||
lsp.TEXT_DOCUMENT_SIGNATURE_HELP,
|
||||
lsp.SignatureHelpOptions(
|
||||
trigger_characters=[" "],
|
||||
retrigger_characters=[" "],
|
||||
),
|
||||
)
|
||||
def signature_help(params: lsp.SignatureHelpParams) -> lsp.SignatureHelp | None:
|
||||
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||
tree = LSP_SERVER.get_tree(document)
|
||||
|
||||
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
|
||||
custom_signatures: dict[str, list[str]] = {}
|
||||
custom_docs: dict[str, str] = {}
|
||||
_, proc_signatures, proc_docs = LSP_SERVER.index_snapshot()
|
||||
|
||||
# Prefer declarations from the current document if duplicate proc names
|
||||
# exist in the workspace.
|
||||
for indexed_path, signatures in proc_signatures.items():
|
||||
if indexed_path != filepath:
|
||||
custom_signatures.update(signatures)
|
||||
custom_signatures.update(proc_signatures.get(filepath, {}))
|
||||
|
||||
for indexed_path, docs in proc_docs.items():
|
||||
if indexed_path != filepath:
|
||||
custom_docs.update(docs)
|
||||
custom_docs.update(proc_docs.get(filepath, {}))
|
||||
|
||||
return build_signature_help(
|
||||
document.source,
|
||||
tree,
|
||||
params.position,
|
||||
custom_signatures,
|
||||
custom_docs,
|
||||
standard_items.json_data.get("MOM_procs", []),
|
||||
)
|
||||
|
||||
|
||||
# @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
|
||||
# def document_symbols(params: lsp.DocumentSymbolParams):
|
||||
# doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||
@@ -210,7 +342,8 @@ def inlay_hints(params: lsp.InlayHintParams):
|
||||
|
||||
# Merge proc signatures across files and traverse once
|
||||
merged_signatures = {}
|
||||
for sigs in LSP_SERVER.proc_signatures.values():
|
||||
_, proc_signatures, _ = LSP_SERVER.index_snapshot()
|
||||
for sigs in proc_signatures.values():
|
||||
merged_signatures.update(sigs)
|
||||
|
||||
generator = InlayHintGenerator(merged_signatures)
|
||||
@@ -230,7 +363,8 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
|
||||
|
||||
data = []
|
||||
plugins = []
|
||||
hl = _Highlighter(plugins, LSP_SERVER.poco_completion)
|
||||
poco_completion, _, _ = LSP_SERVER.index_snapshot()
|
||||
hl = _Highlighter(plugins, poco_completion)
|
||||
|
||||
# Reuse cached AST
|
||||
tree = LSP_SERVER.get_tree(document)
|
||||
@@ -322,7 +456,8 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
|
||||
# 2) Otherwise, check if the token is a custom proc and show its preceding doc block
|
||||
# Build a merged map of proc -> docs gathered during initialization and updates
|
||||
proc_docs: dict[str, str] = {}
|
||||
for file_docs in LSP_SERVER.proc_docs.values():
|
||||
_, _, indexed_proc_docs = LSP_SERVER.index_snapshot()
|
||||
for file_docs in indexed_proc_docs.values():
|
||||
proc_docs.update(file_docs)
|
||||
|
||||
if token in proc_docs:
|
||||
@@ -335,73 +470,153 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
|
||||
|
||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION)
|
||||
def goto_definition(params: lsp.DefinitionParams):
|
||||
"""Provide go-to-definition locations for Tcl procs.
|
||||
|
||||
Strategy:
|
||||
- Find the token under the cursor.
|
||||
- If it matches a custom proc collected in proc_signatures, locate its declaration
|
||||
by searching the current document first, then other indexed files.
|
||||
- Return a Location pointing to the proc name in its declaration line.
|
||||
"""
|
||||
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||
pos = params.position
|
||||
try:
|
||||
line = doc.lines[pos.line]
|
||||
except IndexError:
|
||||
"""Resolve Tcl proc and variable definitions through the symbol index."""
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
if context is None:
|
||||
return None
|
||||
|
||||
# Identify token under cursor
|
||||
token = None
|
||||
for m in re.finditer(r"\b\w+\b", line):
|
||||
if m.start() <= pos.character <= m.end():
|
||||
token = m.group(0)
|
||||
break
|
||||
if not token:
|
||||
indexes, definitions, _, identity = context
|
||||
locations = [
|
||||
lsp.Location(uri=index.uri, range=occurrence.range)
|
||||
for index, occurrence in matching_occurrences(
|
||||
identity, indexes, definitions
|
||||
)
|
||||
if occurrence.is_definition
|
||||
]
|
||||
return _sorted_locations(locations) or None
|
||||
|
||||
|
||||
def _navigation_context(uri: str, position: lsp.Position):
|
||||
indexes = LSP_SERVER.navigation_snapshot()
|
||||
filepath = str(pathlib.Path(uris.to_fs_path(uri)))
|
||||
index = indexes.get(filepath)
|
||||
if index is None:
|
||||
document = LSP_SERVER.workspace.get_text_document(uri)
|
||||
LSP_SERVER.update_poco_completion_for_file(document)
|
||||
indexes = LSP_SERVER.navigation_snapshot()
|
||||
index = indexes.get(filepath)
|
||||
if index is None:
|
||||
return None
|
||||
|
||||
# Helper to search a single source text for a proc declaration
|
||||
def find_decl_in_source(source_text: str, uri: str) -> Optional[lsp.Location]:
|
||||
lines = source_text.split("\n")
|
||||
pattern = re.compile(r"^\s*proc\s+" + re.escape(token) + r"\b")
|
||||
for i, ln in enumerate(lines):
|
||||
m = pattern.match(ln)
|
||||
if m:
|
||||
start_char = ln.find(token)
|
||||
if start_char < 0:
|
||||
start_char = max(m.end() - len(token), 0)
|
||||
start = lsp.Position(i, start_char)
|
||||
end = lsp.Position(i, start_char + len(token))
|
||||
return lsp.Location(uri=uri, range=lsp.Range(start=start, end=end))
|
||||
definitions = definition_identities(indexes)
|
||||
result = symbol_at_position(index, position, definitions)
|
||||
if result is None:
|
||||
return None
|
||||
occurrence, identity = result
|
||||
return indexes, definitions, occurrence, identity
|
||||
|
||||
|
||||
def _sorted_locations(locations: list[lsp.Location]) -> list[lsp.Location]:
|
||||
return sorted(
|
||||
locations,
|
||||
key=lambda location: (
|
||||
location.uri,
|
||||
location.range.start.line,
|
||||
location.range.start.character,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_REFERENCES)
|
||||
def references(params: lsp.ReferenceParams) -> list[lsp.Location]:
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
if context is None:
|
||||
return []
|
||||
|
||||
indexes, definitions, _, identity = context
|
||||
locations = [
|
||||
lsp.Location(uri=index.uri, range=occurrence.range)
|
||||
for index, occurrence in matching_occurrences(
|
||||
identity, indexes, definitions
|
||||
)
|
||||
if params.context.include_declaration or not occurrence.is_definition
|
||||
]
|
||||
return _sorted_locations(locations)
|
||||
|
||||
|
||||
def _is_renamable(
|
||||
identity: SymbolIdentity,
|
||||
indexes,
|
||||
definitions: set[SymbolIdentity],
|
||||
) -> bool:
|
||||
if identity not in definitions or identity.kind not in {"proc", "variable"}:
|
||||
return False
|
||||
|
||||
basename = identity.name.rsplit("::", 1)[-1]
|
||||
if identity.kind == "proc":
|
||||
if basename in BUILTIN_PROC_NAMES:
|
||||
return False
|
||||
definition_count = sum(
|
||||
occurrence.is_definition and occurrence.identity == identity
|
||||
for index in indexes.values()
|
||||
for occurrence in index.occurrences
|
||||
)
|
||||
return definition_count == 1
|
||||
|
||||
return basename not in BUILTIN_VARIABLE_NAMES
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_PREPARE_RENAME)
|
||||
def prepare_rename(params: lsp.PrepareRenameParams):
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
if context is None:
|
||||
return None
|
||||
|
||||
# 1) Search in current document
|
||||
loc = find_decl_in_source(doc.source, doc.uri)
|
||||
if loc:
|
||||
return loc
|
||||
indexes, definitions, occurrence, identity = context
|
||||
if not _is_renamable(identity, indexes, definitions):
|
||||
return None
|
||||
return lsp.PrepareRenameResult_Type1(
|
||||
range=occurrence.range, placeholder=occurrence.placeholder
|
||||
)
|
||||
|
||||
# 2) Search in indexed files from proc_signatures
|
||||
# Build list of candidate files that declare this token as a proc
|
||||
candidate_files: list[str] = []
|
||||
for file_path, procs in LSP_SERVER.proc_signatures.items():
|
||||
if token in procs:
|
||||
candidate_files.append(file_path)
|
||||
|
||||
for fp in candidate_files:
|
||||
uri = pathlib.Path(fp).as_uri()
|
||||
# Try to get from workspace if available; else read from disk
|
||||
try:
|
||||
other_doc = LSP_SERVER.workspace.get_text_document(uri)
|
||||
source = other_doc.source
|
||||
except Exception:
|
||||
try:
|
||||
source = pathlib.Path(fp).read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
loc = find_decl_in_source(source, uri)
|
||||
if loc:
|
||||
return loc
|
||||
@LSP_SERVER.feature(
|
||||
lsp.TEXT_DOCUMENT_RENAME,
|
||||
lsp.RenameOptions(prepare_provider=True),
|
||||
)
|
||||
def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None:
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", params.new_name):
|
||||
return None
|
||||
|
||||
return None
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
if context is None:
|
||||
return None
|
||||
|
||||
indexes, definitions, _, identity = context
|
||||
if not _is_renamable(identity, indexes, definitions):
|
||||
return None
|
||||
|
||||
changes: dict[str, list[lsp.TextEdit]] = {}
|
||||
seen = set()
|
||||
for index, occurrence in matching_occurrences(identity, indexes, definitions):
|
||||
key = (
|
||||
index.uri,
|
||||
occurrence.range.start.line,
|
||||
occurrence.range.start.character,
|
||||
occurrence.range.end.line,
|
||||
occurrence.range.end.character,
|
||||
)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
changes.setdefault(index.uri, []).append(
|
||||
lsp.TextEdit(range=occurrence.range, new_text=params.new_name)
|
||||
)
|
||||
|
||||
for edits in changes.values():
|
||||
edits.sort(
|
||||
key=lambda edit: (
|
||||
edit.range.start.line,
|
||||
edit.range.start.character,
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return lsp.WorkspaceEdit(changes=changes)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.WORKSPACE_SYMBOL)
|
||||
def workspace_symbol(params: lsp.WorkspaceSymbolParams):
|
||||
return workspace_symbols(LSP_SERVER.navigation_snapshot(), params.query)
|
||||
|
||||
|
||||
# **********************************************************
|
||||
@@ -476,6 +691,9 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
|
||||
legend=semantic_tokens_legend, full=True, range=False
|
||||
),
|
||||
definition_provider=True,
|
||||
references_provider=True,
|
||||
rename_provider=lsp.RenameOptions(prepare_provider=True),
|
||||
workspace_symbol_provider=True,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -486,48 +704,43 @@ def initialized(_params: lsp.InitializedParams):
|
||||
|
||||
def index_workspace():
|
||||
try:
|
||||
root = LSP_SERVER.workspace.root_path
|
||||
try:
|
||||
root = LSP_SERVER.workspace.root_path
|
||||
except RuntimeError:
|
||||
root = None
|
||||
if not root:
|
||||
log_to_output("Background indexing skipped: no workspace folder is open.")
|
||||
return
|
||||
log_to_output("Background indexing started...")
|
||||
psc_files = get_all_psc_files(pathlib.Path(root))
|
||||
for psc_file in psc_files:
|
||||
poco_files = read_psc_file(psc_file)
|
||||
for sourced_layer in poco_files:
|
||||
completion.reset()
|
||||
try:
|
||||
file_root = pathlib.Path(root).joinpath(
|
||||
sourced_layer.subfolder if sourced_layer.subfolder else ""
|
||||
)
|
||||
for tcl_file in sourced_layer.files:
|
||||
filepath = pathlib.Path(file_root).joinpath(
|
||||
f"{tcl_file}.tcl"
|
||||
)
|
||||
if not filepath.exists():
|
||||
continue
|
||||
completion.reset()
|
||||
document = LSP_SERVER.workspace.get_text_document(
|
||||
filepath.as_uri()
|
||||
)
|
||||
tree = LSP_SERVER.parser.parse(document.source)
|
||||
tree.accept(completion, recurse=True)
|
||||
remove_existing_items(
|
||||
completion.custom_functions, LSP_SERVER.poco_completion
|
||||
)
|
||||
LSP_SERVER.poco_completion[str(filepath)] = (
|
||||
completion.custom_functions
|
||||
)
|
||||
remove_shared_keys(
|
||||
LSP_SERVER.proc_signatures, completion.proc_signatures
|
||||
)
|
||||
LSP_SERVER.proc_signatures[str(filepath)] = (
|
||||
completion.proc_signatures
|
||||
)
|
||||
from tools.proc_docs import build_proc_docs
|
||||
|
||||
LSP_SERVER.proc_docs[str(filepath)] = build_proc_docs(
|
||||
tree, document.source
|
||||
)
|
||||
except Exception as e:
|
||||
log_to_output(f"Fehler beim Parsen von {filepath}: {e}")
|
||||
root_path = pathlib.Path(root)
|
||||
skipped_directories = {
|
||||
".git",
|
||||
".nox",
|
||||
".venv",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"out",
|
||||
}
|
||||
tcl_files = (
|
||||
path
|
||||
for path in root_path.rglob("*.tcl")
|
||||
if not any(
|
||||
part.casefold() in skipped_directories
|
||||
for part in path.relative_to(root_path).parts[:-1]
|
||||
)
|
||||
)
|
||||
for filepath in sorted(tcl_files, key=lambda path: str(path).casefold()):
|
||||
try:
|
||||
document = TextDocument(
|
||||
uri=filepath.as_uri(), language_id="tcl"
|
||||
)
|
||||
LSP_SERVER.update_poco_completion_for_file(
|
||||
document,
|
||||
cache_tree=False,
|
||||
require_file_exists=True,
|
||||
)
|
||||
except Exception as error:
|
||||
log_to_output(f"Fehler beim Parsen von {filepath}: {error}")
|
||||
log_to_output("Background indexing completed.")
|
||||
except Exception as e:
|
||||
log_to_output(f"Background indexing failed: {e}")
|
||||
|
||||
+243
-59
@@ -1,16 +1,21 @@
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import threading
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import lsprotocol.types as lsp
|
||||
from pygls.workspace.text_document import TextDocument
|
||||
from tclint.lexer import TclSyntaxError
|
||||
from tclint.format import FormatterOpts
|
||||
from tools.formatter import NxFormatter as Formatter
|
||||
from tclint.violations import Violation
|
||||
from plugins.poco_plugin import commands
|
||||
from tools import checks, parser
|
||||
from pygls import server, uris
|
||||
from tools.completion_items import completion, remove_existing_items, remove_shared_keys
|
||||
from pygls.workspace.text_document import TextDocument
|
||||
from tclint.format import FormatterOpts
|
||||
from tclint.lexer import TclSyntaxError
|
||||
from tclint.violations import Violation
|
||||
|
||||
from tools import checks, parser
|
||||
from tools.completion_items import CompletionCollector
|
||||
from tools.formatter import NxFormatter as Formatter
|
||||
from tools.navigation import FileSymbolIndex, build_file_symbol_index
|
||||
from tools.proc_docs import build_proc_docs
|
||||
|
||||
|
||||
@@ -27,62 +32,216 @@ class TclLanguageServer(server.LanguageServer):
|
||||
self.poco_completion: dict = {}
|
||||
self.proc_signatures: dict = {}
|
||||
self.proc_docs: dict = {}
|
||||
self.navigation_indexes: dict[str, FileSymbolIndex] = {}
|
||||
# Cache: (uri, version) -> (tree, violations)
|
||||
self._ast_cache = {}
|
||||
self._parser_lock = threading.RLock()
|
||||
self._index_lock = threading.RLock()
|
||||
self._index_tokens: dict[str, int] = {}
|
||||
self._index_versions: dict[str, int | None] = {}
|
||||
self._next_index_token = 0
|
||||
self._diagnostic_tokens: dict[str, int] = {}
|
||||
self._next_diagnostic_token = 0
|
||||
|
||||
def _parse_source(self, source: str):
|
||||
self.parser.violations = []
|
||||
tree = self.parser.parse(source)
|
||||
return tree, list(self.parser.violations)
|
||||
|
||||
def parse_source(self, source: str):
|
||||
"""Parse without retaining an AST, serialized around the shared parser."""
|
||||
with self._parser_lock:
|
||||
tree, _ = self._parse_source(source)
|
||||
return tree
|
||||
|
||||
def get_tree(self, document: TextDocument):
|
||||
key = (document.uri, document.version)
|
||||
cached = self._ast_cache.get(key)
|
||||
if cached:
|
||||
return cached[0]
|
||||
# Parse and cache
|
||||
self.parser.violations = []
|
||||
tree = self.parser.parse(document.source)
|
||||
violations = list(self.parser.violations)
|
||||
self._ast_cache[key] = (tree, violations)
|
||||
return tree
|
||||
with self._parser_lock:
|
||||
cached = self._ast_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached[0]
|
||||
tree, violations = self._parse_source(document.source)
|
||||
self._ast_cache[key] = (tree, violations)
|
||||
return tree
|
||||
|
||||
def get_tree_and_violations(self, document: TextDocument):
|
||||
key = (document.uri, document.version)
|
||||
cached = self._ast_cache.get(key)
|
||||
if cached:
|
||||
return cached
|
||||
# Parse and cache
|
||||
self.parser.violations = []
|
||||
tree = self.parser.parse(document.source)
|
||||
violations = list(self.parser.violations)
|
||||
self._ast_cache[key] = (tree, violations)
|
||||
return tree, violations
|
||||
with self._parser_lock:
|
||||
cached = self._ast_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
tree, violations = self._parse_source(document.source)
|
||||
self._ast_cache[key] = (tree, violations)
|
||||
return tree, violations
|
||||
|
||||
def clear_cache_for_uri(self, uri: str):
|
||||
to_delete = [k for k in self._ast_cache.keys() if k[0] == uri]
|
||||
for k in to_delete:
|
||||
del self._ast_cache[k]
|
||||
with self._parser_lock:
|
||||
to_delete = [key for key in self._ast_cache if key[0] == uri]
|
||||
for key in to_delete:
|
||||
del self._ast_cache[key]
|
||||
|
||||
def update_poco_completion_for_file(self, document: TextDocument):
|
||||
@staticmethod
|
||||
def _normalized_path(path: pathlib.Path | str) -> str:
|
||||
return os.path.normcase(os.path.abspath(os.fspath(path)))
|
||||
|
||||
@classmethod
|
||||
def _is_same_or_child(
|
||||
cls, candidate: pathlib.Path | str, parent: pathlib.Path | str
|
||||
) -> bool:
|
||||
candidate_path = cls._normalized_path(candidate)
|
||||
parent_path = cls._normalized_path(parent)
|
||||
try:
|
||||
return os.path.commonpath([candidate_path, parent_path]) == parent_path
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def paths_equal(
|
||||
cls, first: pathlib.Path | str, second: pathlib.Path | str
|
||||
) -> bool:
|
||||
return cls._normalized_path(first) == cls._normalized_path(second)
|
||||
|
||||
def index_snapshot(self) -> tuple[dict, dict, dict]:
|
||||
"""Return stable copies for request handlers running beside the indexer."""
|
||||
with self._index_lock:
|
||||
return (
|
||||
{path: list(items) for path, items in self.poco_completion.items()},
|
||||
{
|
||||
path: dict(signatures)
|
||||
for path, signatures in self.proc_signatures.items()
|
||||
},
|
||||
{path: dict(docs) for path, docs in self.proc_docs.items()},
|
||||
)
|
||||
|
||||
def diagnostic_snapshot(self, uri: str):
|
||||
with self._index_lock:
|
||||
return self.diagnostics.get(uri)
|
||||
|
||||
def navigation_snapshot(self) -> dict[str, FileSymbolIndex]:
|
||||
"""Return an immutable snapshot of the workspace symbol indexes."""
|
||||
with self._index_lock:
|
||||
return dict(self.navigation_indexes)
|
||||
|
||||
def _begin_index_update(self, filepath: str, version: int | None) -> int | None:
|
||||
with self._index_lock:
|
||||
indexed_version = self._index_versions.get(filepath)
|
||||
if version is None and indexed_version is not None:
|
||||
return None
|
||||
if (
|
||||
version is not None
|
||||
and indexed_version is not None
|
||||
and version < indexed_version
|
||||
):
|
||||
return None
|
||||
|
||||
self._next_index_token += 1
|
||||
token = self._next_index_token
|
||||
self._index_tokens[filepath] = token
|
||||
self._index_versions[filepath] = version
|
||||
return token
|
||||
|
||||
def _discard_index_update(self, filepath: str, token: int) -> None:
|
||||
with self._index_lock:
|
||||
if self._index_tokens.get(filepath) != token:
|
||||
return
|
||||
self.poco_completion.pop(filepath, None)
|
||||
self.proc_signatures.pop(filepath, None)
|
||||
self.proc_docs.pop(filepath, None)
|
||||
self.navigation_indexes.pop(filepath, None)
|
||||
|
||||
def indexed_paths_under_uri(self, uri: str) -> list[pathlib.Path]:
|
||||
target = pathlib.Path(uris.to_fs_path(uri))
|
||||
with self._index_lock:
|
||||
indexed_paths = set(self.poco_completion)
|
||||
indexed_paths.update(self.proc_signatures)
|
||||
indexed_paths.update(self.proc_docs)
|
||||
indexed_paths.update(self.navigation_indexes)
|
||||
indexed_paths.update(self._index_tokens)
|
||||
return [
|
||||
pathlib.Path(path)
|
||||
for path in indexed_paths
|
||||
if self._is_same_or_child(path, target)
|
||||
]
|
||||
|
||||
def remove_file_state(self, uri: str) -> None:
|
||||
"""Remove cached and indexed state for a file or a complete folder."""
|
||||
target = pathlib.Path(uris.to_fs_path(uri))
|
||||
|
||||
with self._index_lock:
|
||||
for store in (
|
||||
self.poco_completion,
|
||||
self.proc_signatures,
|
||||
self.proc_docs,
|
||||
self.navigation_indexes,
|
||||
self._index_tokens,
|
||||
self._index_versions,
|
||||
):
|
||||
for path in list(store):
|
||||
if self._is_same_or_child(path, target):
|
||||
del store[path]
|
||||
|
||||
diagnostic_uris = set(self.diagnostics)
|
||||
diagnostic_uris.update(self._diagnostic_tokens)
|
||||
for diagnostic_uri in diagnostic_uris:
|
||||
try:
|
||||
diagnostic_path = pathlib.Path(uris.to_fs_path(diagnostic_uri))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if self._is_same_or_child(diagnostic_path, target):
|
||||
self.diagnostics.pop(diagnostic_uri, None)
|
||||
self._diagnostic_tokens.pop(diagnostic_uri, None)
|
||||
|
||||
with self._parser_lock:
|
||||
for key in list(self._ast_cache):
|
||||
try:
|
||||
cached_path = pathlib.Path(uris.to_fs_path(key[0]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if self._is_same_or_child(cached_path, target):
|
||||
del self._ast_cache[key]
|
||||
|
||||
def update_poco_completion_for_file(
|
||||
self,
|
||||
document: TextDocument,
|
||||
*,
|
||||
cache_tree: bool = True,
|
||||
require_file_exists: bool = False,
|
||||
):
|
||||
"""Update poco_completion for a specific file when it changes"""
|
||||
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
|
||||
token = self._begin_index_update(filepath, document.version)
|
||||
if token is None:
|
||||
return False
|
||||
|
||||
# Remove existing completion items for this file
|
||||
if filepath in self.poco_completion:
|
||||
del self.poco_completion[filepath]
|
||||
if filepath in self.proc_signatures:
|
||||
del self.proc_signatures[filepath]
|
||||
if filepath in self.proc_docs:
|
||||
del self.proc_docs[filepath]
|
||||
|
||||
# Parse and extract new completion items
|
||||
completion.reset()
|
||||
collector = CompletionCollector()
|
||||
try:
|
||||
tree = self.get_tree(document)
|
||||
tree.accept(completion, recurse=True)
|
||||
remove_existing_items(completion.custom_functions, self.poco_completion)
|
||||
self.poco_completion[filepath] = completion.custom_functions
|
||||
remove_shared_keys(self.proc_signatures, completion.proc_signatures)
|
||||
self.proc_signatures[filepath] = completion.proc_signatures
|
||||
self.proc_docs[filepath] = build_proc_docs(tree, document.source)
|
||||
tree = (
|
||||
self.get_tree(document)
|
||||
if cache_tree
|
||||
else self.parse_source(document.source)
|
||||
)
|
||||
tree.accept(collector, recurse=True)
|
||||
docs = build_proc_docs(tree, document.source)
|
||||
navigation_index = build_file_symbol_index(
|
||||
filepath, document.uri, tree
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error parsing {filepath}: {e}")
|
||||
self._discard_index_update(filepath, token)
|
||||
return False
|
||||
|
||||
if require_file_exists and not pathlib.Path(filepath).is_file():
|
||||
self._discard_index_update(filepath, token)
|
||||
return False
|
||||
|
||||
with self._index_lock:
|
||||
if self._index_tokens.get(filepath) != token:
|
||||
return False
|
||||
self.poco_completion[filepath] = list(collector.custom_functions)
|
||||
self.proc_signatures[filepath] = dict(collector.proc_signatures)
|
||||
self.proc_docs[filepath] = docs
|
||||
self.navigation_indexes[filepath] = navigation_index
|
||||
return True
|
||||
|
||||
def format(
|
||||
self,
|
||||
@@ -98,22 +257,28 @@ class TclLanguageServer(server.LanguageServer):
|
||||
FormatterOpts(
|
||||
indent=indent,
|
||||
spaces_in_braces=False,
|
||||
balanced_spaces_in_braces=False,
|
||||
max_blank_lines=500,
|
||||
indent_namespace_eval=True,
|
||||
indent_mixed_tab_size=0,
|
||||
emacs=False,
|
||||
debug_whitespace=False,
|
||||
),
|
||||
)
|
||||
|
||||
if range is not None:
|
||||
start, end = range
|
||||
return formatter.format_partial(document.source[start:end], self.parser)
|
||||
with self._parser_lock:
|
||||
if range is not None:
|
||||
start, end = range
|
||||
return formatter.format_partial(document.source[start:end], self.parser)
|
||||
|
||||
return formatter.format_top(document.source, self.parser)
|
||||
return formatter.format_top(document.source, self.parser)
|
||||
|
||||
def linter(
|
||||
self,
|
||||
document: TextDocument,
|
||||
) -> List[Violation]:
|
||||
tree, violations = self.get_tree_and_violations(document)
|
||||
tree, cached_violations = self.get_tree_and_violations(document)
|
||||
violations = list(cached_violations)
|
||||
for checker in checks.get_checkers():
|
||||
violations += checker.check(document.source, tree)
|
||||
return violations
|
||||
@@ -140,8 +305,12 @@ class TclLanguageServer(server.LanguageServer):
|
||||
for violation in violations:
|
||||
message = violation.message
|
||||
severity = lsp.DiagnosticSeverity.Warning
|
||||
start = lsp.Position(line=violation.start[0] - 1, character=violation.start[1] - 1)
|
||||
end = lsp.Position(line=violation.end[0] - 1, character=violation.end[1] - 1)
|
||||
start = lsp.Position(
|
||||
line=violation.start[0] - 1, character=violation.start[1] - 1
|
||||
)
|
||||
end = lsp.Position(
|
||||
line=violation.end[0] - 1, character=violation.end[1] - 1
|
||||
)
|
||||
|
||||
diagnostics.append(
|
||||
lsp.Diagnostic(
|
||||
@@ -162,12 +331,27 @@ class TclLanguageServer(server.LanguageServer):
|
||||
return self.lint(document)
|
||||
|
||||
def compute_diagnostics(self, document: TextDocument):
|
||||
# `None` sentinel ensures that `diagnostics` gets updated if the URI is not
|
||||
# present.
|
||||
_, previous = self.diagnostics.get(document, (0, None))
|
||||
with self._index_lock:
|
||||
self._next_diagnostic_token += 1
|
||||
token = self._next_diagnostic_token
|
||||
self._diagnostic_tokens[document.uri] = token
|
||||
|
||||
diagnostics = self._compute_diagnostics(document)
|
||||
|
||||
# Only update if the list has changed
|
||||
if previous != diagnostics:
|
||||
self.diagnostics[document.uri] = (document.version, diagnostics)
|
||||
with self._index_lock:
|
||||
if self._diagnostic_tokens.get(document.uri) != token:
|
||||
return
|
||||
|
||||
current = self.diagnostics.get(document.uri)
|
||||
if current is not None:
|
||||
current_version, _ = current
|
||||
if (
|
||||
current_version is not None
|
||||
and document.version is not None
|
||||
and current_version > document.version
|
||||
):
|
||||
return
|
||||
|
||||
# Keep the result id in sync even when only the document version changed.
|
||||
if current != (document.version, diagnostics):
|
||||
self.diagnostics[document.uri] = (document.version, diagnostics)
|
||||
|
||||
@@ -18,7 +18,7 @@ class CompletionItems:
|
||||
self._custom_functions.append(value)
|
||||
|
||||
|
||||
class _Completion(Visitor):
|
||||
class CompletionCollector(Visitor):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._custom_functions: list[lsp.CompletionItem] = []
|
||||
@@ -32,10 +32,6 @@ class _Completion(Visitor):
|
||||
def proc_signatures(self):
|
||||
return self._proc_signatures
|
||||
|
||||
def reset(self):
|
||||
self._custom_functions = []
|
||||
self._proc_signatures = {}
|
||||
|
||||
def _append_unique(self, item: lsp.CompletionItem):
|
||||
# Avoid duplicate labels within the same file scan
|
||||
if not any(ci.label == item.label for ci in self._custom_functions):
|
||||
@@ -90,35 +86,3 @@ class _Completion(Visitor):
|
||||
clean_name = base_name[2:] # remove leading '::' for completion display
|
||||
if clean_name not in BUILTIN_VAR_LABELS:
|
||||
self._append_unique(lsp.CompletionItem(label=clean_name, kind=lsp.CompletionItemKind.Variable))
|
||||
|
||||
|
||||
def remove_existing_items(items: list[lsp.CompletionItem], store: dict) -> None:
|
||||
"""
|
||||
Entfernt alle CompletionItems aus dem store, deren label in der items-Liste vorkommt.
|
||||
Änderungen erfolgen in-place.
|
||||
"""
|
||||
labels_to_remove = {item.label for item in items}
|
||||
|
||||
for key in list(store.keys()):
|
||||
filtered = [ci for ci in store[key] if ci.label not in labels_to_remove]
|
||||
if filtered:
|
||||
store[key] = filtered
|
||||
else:
|
||||
del store[key]
|
||||
|
||||
|
||||
def remove_shared_keys(nested_dict: dict[str, dict[str, list]], flat_dict: dict[str, list]) -> None:
|
||||
"""
|
||||
Entfernt alle Keys aus nested_dict[file][func], wenn func auch in flat_dict vorhanden ist.
|
||||
Änderungen erfolgen in-place.
|
||||
"""
|
||||
for file_path, func_dict in list(nested_dict.items()):
|
||||
for func_name in list(func_dict.keys()):
|
||||
if func_name in flat_dict:
|
||||
del nested_dict[file_path][func_name]
|
||||
|
||||
if not nested_dict[file_path]:
|
||||
del nested_dict[file_path]
|
||||
|
||||
|
||||
completion = _Completion()
|
||||
|
||||
@@ -26,6 +26,8 @@ class NxFormatter(BaseFormatter):
|
||||
formatted += [line]
|
||||
|
||||
if expr.pos[0] == expr.end_pos[0]:
|
||||
return self._brace(formatted)
|
||||
space_before = expr.children[0].pos[1] - expr.pos[1] - 1
|
||||
space_after = expr.end_pos[1] - expr.children[-1].end_pos[1] - 1
|
||||
return self._brace(formatted, (space_before, space_after))
|
||||
|
||||
return ["{"] + self._indent(formatted, self.opts.indent) + ["}"]
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import lsprotocol.types as lsp
|
||||
from tclint.syntax_tree import BareWord, Command, List, Node, Script, VarSub
|
||||
|
||||
|
||||
ROOT_NAMESPACE = "::"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SymbolIdentity:
|
||||
kind: str
|
||||
name: str
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SymbolOccurrence:
|
||||
identity: SymbolIdentity
|
||||
range: lsp.Range
|
||||
placeholder: str
|
||||
is_definition: bool = False
|
||||
symbol_kind: lsp.SymbolKind = lsp.SymbolKind.Variable
|
||||
container_name: str | None = None
|
||||
fallback_identity: SymbolIdentity | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FileSymbolIndex:
|
||||
path: str
|
||||
uri: str
|
||||
occurrences: tuple[SymbolOccurrence, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Scope:
|
||||
filepath: str
|
||||
namespace: str = ROOT_NAMESPACE
|
||||
proc_name: str | None = None
|
||||
global_variables: tuple[tuple[str, str], ...] = ()
|
||||
namespace_variables: tuple[tuple[str, str], ...] = ()
|
||||
|
||||
|
||||
def _without_array_index(name: str) -> str:
|
||||
return name.split("(", 1)[0]
|
||||
|
||||
|
||||
def _basename(name: str) -> str:
|
||||
return _without_array_index(name).rsplit("::", 1)[-1]
|
||||
|
||||
|
||||
def _qualify(name: str, namespace: str) -> str:
|
||||
name = _without_array_index(name)
|
||||
if name.startswith("::"):
|
||||
return name
|
||||
if namespace == ROOT_NAMESPACE:
|
||||
return f"::{name}"
|
||||
return f"{namespace}::{name}"
|
||||
|
||||
|
||||
def _namespace_of(qualified_name: str) -> str:
|
||||
parent = qualified_name.rsplit("::", 1)[0]
|
||||
return parent or ROOT_NAMESPACE
|
||||
|
||||
|
||||
def _display_name(identity: SymbolIdentity) -> str:
|
||||
if identity.kind == "variable" and identity.scope is not None:
|
||||
return identity.name
|
||||
return identity.name.removeprefix("::")
|
||||
|
||||
|
||||
def _container_name(identity: SymbolIdentity) -> str | None:
|
||||
if identity.scope is not None:
|
||||
_, _, proc_name = identity.scope.partition("::proc::")
|
||||
return proc_name.removeprefix("::") or None
|
||||
|
||||
qualified = identity.name.removeprefix("::")
|
||||
if "::" not in qualified:
|
||||
return None
|
||||
return qualified.rsplit("::", 1)[0]
|
||||
|
||||
|
||||
def _static_contents(node: Node | None) -> str | None:
|
||||
value = getattr(node, "contents", None)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _name_range(node: Node, raw_name: str, *, variable_sub: bool = False) -> lsp.Range:
|
||||
if variable_sub:
|
||||
line, column = node.pos
|
||||
column += 2 if getattr(node, "braced", False) else 1
|
||||
else:
|
||||
position = getattr(node, "contents_pos", None) or node.pos
|
||||
line, column = position
|
||||
|
||||
normalized = _without_array_index(raw_name)
|
||||
prefix_length = normalized.rfind("::") + 2 if "::" in normalized else 0
|
||||
start_character = column - 1 + prefix_length
|
||||
name = normalized[prefix_length:]
|
||||
return lsp.Range(
|
||||
start=lsp.Position(line=line - 1, character=start_character),
|
||||
end=lsp.Position(line=line - 1, character=start_character + len(name)),
|
||||
)
|
||||
|
||||
|
||||
def _proc_identity(raw_name: str, namespace: str) -> SymbolIdentity:
|
||||
return SymbolIdentity(kind="proc", name=_qualify(raw_name, namespace))
|
||||
|
||||
|
||||
def _proc_fallback(raw_name: str, namespace: str) -> SymbolIdentity | None:
|
||||
if raw_name.startswith("::") or "::" in raw_name or namespace == ROOT_NAMESPACE:
|
||||
return None
|
||||
return SymbolIdentity(kind="proc", name=_qualify(raw_name, ROOT_NAMESPACE))
|
||||
|
||||
|
||||
def _variable_identity(raw_name: str, scope: _Scope) -> SymbolIdentity:
|
||||
normalized = _without_array_index(raw_name)
|
||||
if normalized.startswith("::"):
|
||||
return SymbolIdentity(kind="variable", name=normalized)
|
||||
|
||||
if "::" in normalized:
|
||||
return SymbolIdentity(
|
||||
kind="variable", name=_qualify(normalized, scope.namespace)
|
||||
)
|
||||
|
||||
if scope.proc_name is None:
|
||||
return SymbolIdentity(
|
||||
kind="variable", name=_qualify(normalized, scope.namespace)
|
||||
)
|
||||
|
||||
for alias, target in scope.global_variables + scope.namespace_variables:
|
||||
if normalized == alias:
|
||||
return SymbolIdentity(kind="variable", name=target)
|
||||
|
||||
return SymbolIdentity(
|
||||
kind="variable",
|
||||
name=normalized,
|
||||
scope=f"{scope.filepath}::proc::{scope.proc_name}",
|
||||
)
|
||||
|
||||
|
||||
def _variable_command_nodes(command: Command) -> list[tuple[Node, bool]]:
|
||||
routine = _static_contents(command.routine)
|
||||
if routine == "set" and command.args:
|
||||
return [(command.args[0], len(command.args) >= 2)]
|
||||
if routine in {"incr", "append", "lappend"} and command.args:
|
||||
return [(command.args[0], True)]
|
||||
if routine == "lset" and command.args:
|
||||
return [(command.args[0], False)]
|
||||
if routine == "unset":
|
||||
return [
|
||||
(argument, False)
|
||||
for argument in command.args
|
||||
if not (_static_contents(argument) or "").startswith("-")
|
||||
]
|
||||
if routine == "array" and len(command.args) >= 2:
|
||||
return [
|
||||
(command.args[1], _static_contents(command.args[0]) == "set")
|
||||
]
|
||||
if routine == "dict" and len(command.args) >= 2:
|
||||
subcommand = _static_contents(command.args[0])
|
||||
if subcommand in {"set", "unset", "append", "incr", "lappend", "update", "with"}:
|
||||
return [
|
||||
(
|
||||
command.args[1],
|
||||
subcommand in {"set", "append", "incr", "lappend"},
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _binding_nodes(node: Node) -> list[Node]:
|
||||
if isinstance(node, List):
|
||||
return list(node.children)
|
||||
return [node]
|
||||
|
||||
|
||||
def _variable_binding_nodes(command: Command) -> list[Node]:
|
||||
routine = _static_contents(command.routine)
|
||||
if routine in {"foreach", "lmap"} and len(command.args) >= 3:
|
||||
return [
|
||||
variable
|
||||
for variable_list in command.args[:-1:2]
|
||||
for variable in _binding_nodes(variable_list)
|
||||
]
|
||||
if routine == "lassign" and len(command.args) >= 2:
|
||||
return list(command.args[1:])
|
||||
if routine == "catch" and len(command.args) >= 2:
|
||||
return list(command.args[1:3])
|
||||
if (
|
||||
routine == "dict"
|
||||
and command.args
|
||||
and _static_contents(command.args[0]) == "update"
|
||||
):
|
||||
return list(command.args[3:-1:2])
|
||||
return []
|
||||
|
||||
|
||||
def _variable_declaration_nodes(command: Command) -> list[Node]:
|
||||
routine = _static_contents(command.routine)
|
||||
if routine == "global":
|
||||
return list(command.args)
|
||||
if routine == "variable":
|
||||
return list(command.args[::2])
|
||||
return []
|
||||
|
||||
|
||||
def _scan_proc_imports(
|
||||
node: Node, namespace: str
|
||||
) -> tuple[dict[str, str], dict[str, str]]:
|
||||
global_variables: dict[str, str] = {}
|
||||
namespace_variables: dict[str, str] = {}
|
||||
|
||||
def walk(current: Node) -> None:
|
||||
if isinstance(current, Command):
|
||||
routine = _static_contents(current.routine)
|
||||
if routine == "proc":
|
||||
return
|
||||
if routine == "global":
|
||||
for argument in current.args:
|
||||
name = _static_contents(argument)
|
||||
if name:
|
||||
global_variables[_basename(name)] = _qualify(
|
||||
name, ROOT_NAMESPACE
|
||||
)
|
||||
elif routine == "variable":
|
||||
for argument in current.args[::2]:
|
||||
name = _static_contents(argument)
|
||||
if name:
|
||||
namespace_variables[_basename(name)] = _qualify(
|
||||
name, namespace
|
||||
)
|
||||
|
||||
for child in getattr(current, "children", []):
|
||||
walk(child)
|
||||
|
||||
walk(node)
|
||||
return global_variables, namespace_variables
|
||||
|
||||
|
||||
def build_file_symbol_index(
|
||||
filepath: str, uri: str, tree: Node
|
||||
) -> FileSymbolIndex:
|
||||
occurrences: list[SymbolOccurrence] = []
|
||||
|
||||
def add_proc(
|
||||
node: Node,
|
||||
raw_name: str,
|
||||
scope: _Scope,
|
||||
*,
|
||||
is_definition: bool,
|
||||
) -> None:
|
||||
identity = _proc_identity(raw_name, scope.namespace)
|
||||
occurrences.append(
|
||||
SymbolOccurrence(
|
||||
identity=identity,
|
||||
fallback_identity=(
|
||||
None
|
||||
if is_definition
|
||||
else _proc_fallback(raw_name, scope.namespace)
|
||||
),
|
||||
range=_name_range(node, raw_name),
|
||||
placeholder=_basename(raw_name),
|
||||
is_definition=is_definition,
|
||||
symbol_kind=lsp.SymbolKind.Function,
|
||||
container_name=_container_name(identity),
|
||||
)
|
||||
)
|
||||
|
||||
def add_variable(
|
||||
node: Node,
|
||||
raw_name: str,
|
||||
scope: _Scope,
|
||||
*,
|
||||
is_definition: bool,
|
||||
variable_sub: bool = False,
|
||||
identity: SymbolIdentity | None = None,
|
||||
) -> None:
|
||||
symbol_identity = identity or _variable_identity(raw_name, scope)
|
||||
occurrences.append(
|
||||
SymbolOccurrence(
|
||||
identity=symbol_identity,
|
||||
range=_name_range(node, raw_name, variable_sub=variable_sub),
|
||||
placeholder=_basename(raw_name),
|
||||
is_definition=is_definition,
|
||||
symbol_kind=lsp.SymbolKind.Variable,
|
||||
container_name=_container_name(symbol_identity),
|
||||
)
|
||||
)
|
||||
|
||||
def walk_embedded(node: Node, scope: _Scope) -> None:
|
||||
if isinstance(node, Script):
|
||||
walk_script(node, scope)
|
||||
return
|
||||
if isinstance(node, Command):
|
||||
walk_command(node, scope)
|
||||
return
|
||||
if isinstance(node, VarSub):
|
||||
raw_name = getattr(node, "value", None)
|
||||
if isinstance(raw_name, str):
|
||||
add_variable(
|
||||
node,
|
||||
raw_name,
|
||||
scope,
|
||||
is_definition=False,
|
||||
variable_sub=True,
|
||||
)
|
||||
|
||||
for child in getattr(node, "children", []):
|
||||
walk_embedded(child, scope)
|
||||
|
||||
def walk_proc(command: Command, scope: _Scope) -> None:
|
||||
if len(command.args) < 3:
|
||||
return
|
||||
raw_name = _static_contents(command.args[0])
|
||||
body = command.args[2]
|
||||
if raw_name is None or not isinstance(body, Script):
|
||||
return
|
||||
|
||||
add_proc(command.args[0], raw_name, scope, is_definition=True)
|
||||
proc_identity = _proc_identity(raw_name, scope.namespace)
|
||||
proc_namespace = _namespace_of(proc_identity.name)
|
||||
global_variables, namespace_variables = _scan_proc_imports(
|
||||
body, proc_namespace
|
||||
)
|
||||
proc_scope = _Scope(
|
||||
filepath=filepath,
|
||||
namespace=proc_namespace,
|
||||
proc_name=proc_identity.name,
|
||||
global_variables=tuple(sorted(global_variables.items())),
|
||||
namespace_variables=tuple(sorted(namespace_variables.items())),
|
||||
)
|
||||
|
||||
parameters = command.args[1]
|
||||
for parameter in getattr(parameters, "children", []):
|
||||
parameter_node = parameter
|
||||
if isinstance(parameter, List) and parameter.children:
|
||||
parameter_node = parameter.children[0]
|
||||
parameter_name = _static_contents(parameter_node)
|
||||
if parameter_name:
|
||||
add_variable(
|
||||
parameter_node,
|
||||
parameter_name,
|
||||
proc_scope,
|
||||
is_definition=True,
|
||||
)
|
||||
|
||||
walk_script(body, proc_scope)
|
||||
|
||||
def walk_namespace(command: Command, scope: _Scope) -> bool:
|
||||
if len(command.args) < 3 or _static_contents(command.args[0]) != "eval":
|
||||
return False
|
||||
raw_name = _static_contents(command.args[1])
|
||||
body = command.args[2]
|
||||
if raw_name is None or not isinstance(body, Script):
|
||||
return False
|
||||
|
||||
namespace = _qualify(raw_name, scope.namespace)
|
||||
identity = SymbolIdentity(kind="namespace", name=namespace)
|
||||
occurrences.append(
|
||||
SymbolOccurrence(
|
||||
identity=identity,
|
||||
range=_name_range(command.args[1], raw_name),
|
||||
placeholder=_basename(raw_name),
|
||||
is_definition=True,
|
||||
symbol_kind=lsp.SymbolKind.Namespace,
|
||||
container_name=_container_name(identity),
|
||||
)
|
||||
)
|
||||
walk_script(
|
||||
body,
|
||||
_Scope(filepath=filepath, namespace=namespace),
|
||||
)
|
||||
return True
|
||||
|
||||
def walk_command(command: Command, scope: _Scope) -> None:
|
||||
routine = _static_contents(command.routine)
|
||||
if routine == "proc":
|
||||
walk_proc(command, scope)
|
||||
return
|
||||
if routine == "namespace" and walk_namespace(command, scope):
|
||||
return
|
||||
|
||||
if routine:
|
||||
add_proc(command.routine, routine, scope, is_definition=False)
|
||||
|
||||
declaration_nodes = _variable_declaration_nodes(command)
|
||||
declaration_ids = {id(node) for node in declaration_nodes}
|
||||
for node in declaration_nodes:
|
||||
raw_name = _static_contents(node)
|
||||
if raw_name:
|
||||
is_definition = routine == "variable" and scope.proc_name is None
|
||||
if routine == "global":
|
||||
identity = SymbolIdentity(
|
||||
kind="variable",
|
||||
name=_qualify(raw_name, ROOT_NAMESPACE),
|
||||
)
|
||||
else:
|
||||
identity = SymbolIdentity(
|
||||
kind="variable",
|
||||
name=_qualify(raw_name, scope.namespace),
|
||||
)
|
||||
add_variable(
|
||||
node,
|
||||
raw_name,
|
||||
scope,
|
||||
is_definition=is_definition,
|
||||
identity=identity,
|
||||
)
|
||||
|
||||
for node, is_definition in _variable_command_nodes(command):
|
||||
if id(node) in declaration_ids:
|
||||
continue
|
||||
raw_name = _static_contents(node)
|
||||
if raw_name:
|
||||
add_variable(
|
||||
node,
|
||||
raw_name,
|
||||
scope,
|
||||
is_definition=is_definition,
|
||||
)
|
||||
|
||||
for node in _variable_binding_nodes(command):
|
||||
raw_name = _static_contents(node)
|
||||
if raw_name:
|
||||
add_variable(node, raw_name, scope, is_definition=True)
|
||||
|
||||
for argument in command.args:
|
||||
walk_embedded(argument, scope)
|
||||
|
||||
def walk_script(script: Node, scope: _Scope) -> None:
|
||||
for child in getattr(script, "children", []):
|
||||
walk_embedded(child, scope)
|
||||
|
||||
walk_script(tree, _Scope(filepath=filepath))
|
||||
return FileSymbolIndex(path=filepath, uri=uri, occurrences=tuple(occurrences))
|
||||
|
||||
|
||||
def definition_identities(indexes: dict[str, FileSymbolIndex]) -> set[SymbolIdentity]:
|
||||
return {
|
||||
occurrence.identity
|
||||
for index in indexes.values()
|
||||
for occurrence in index.occurrences
|
||||
if occurrence.is_definition
|
||||
}
|
||||
|
||||
|
||||
def resolve_identity(
|
||||
occurrence: SymbolOccurrence, definitions: set[SymbolIdentity]
|
||||
) -> SymbolIdentity:
|
||||
if occurrence.identity in definitions or occurrence.fallback_identity is None:
|
||||
return occurrence.identity
|
||||
if occurrence.fallback_identity in definitions:
|
||||
return occurrence.fallback_identity
|
||||
return occurrence.identity
|
||||
|
||||
|
||||
def symbol_at_position(
|
||||
index: FileSymbolIndex,
|
||||
position: lsp.Position,
|
||||
definitions: set[SymbolIdentity],
|
||||
) -> tuple[SymbolOccurrence, SymbolIdentity] | None:
|
||||
for occurrence in index.occurrences:
|
||||
start = occurrence.range.start
|
||||
end = occurrence.range.end
|
||||
if (
|
||||
position.line == start.line == end.line
|
||||
and start.character <= position.character < end.character
|
||||
):
|
||||
return occurrence, resolve_identity(occurrence, definitions)
|
||||
return None
|
||||
|
||||
|
||||
def matching_occurrences(
|
||||
identity: SymbolIdentity,
|
||||
indexes: dict[str, FileSymbolIndex],
|
||||
definitions: set[SymbolIdentity],
|
||||
) -> list[tuple[FileSymbolIndex, SymbolOccurrence]]:
|
||||
matches = []
|
||||
for index in indexes.values():
|
||||
for occurrence in index.occurrences:
|
||||
if resolve_identity(occurrence, definitions) == identity:
|
||||
matches.append((index, occurrence))
|
||||
return matches
|
||||
|
||||
|
||||
def workspace_symbols(
|
||||
indexes: dict[str, FileSymbolIndex], query: str
|
||||
) -> list[lsp.SymbolInformation]:
|
||||
query = query.casefold()
|
||||
results = []
|
||||
seen = set()
|
||||
for index in indexes.values():
|
||||
for occurrence in index.occurrences:
|
||||
identity = occurrence.identity
|
||||
if not occurrence.is_definition:
|
||||
continue
|
||||
if identity.kind == "variable" and identity.scope is not None:
|
||||
continue
|
||||
|
||||
name = _display_name(identity)
|
||||
if query and query not in name.casefold():
|
||||
continue
|
||||
|
||||
key = identity
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
results.append(
|
||||
lsp.SymbolInformation(
|
||||
name=name,
|
||||
kind=occurrence.symbol_kind,
|
||||
location=lsp.Location(uri=index.uri, range=occurrence.range),
|
||||
container_name=occurrence.container_name,
|
||||
)
|
||||
)
|
||||
return sorted(results, key=lambda symbol: symbol.name.casefold())
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import lsprotocol.types as lsp
|
||||
from tclint.syntax_tree import Command, Node
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActiveCall:
|
||||
name: str
|
||||
active_argument: int
|
||||
|
||||
|
||||
def _to_lsp_position(position: tuple[int, int] | None) -> tuple[int, int] | None:
|
||||
if position is None:
|
||||
return None
|
||||
return position[0] - 1, position[1] - 1
|
||||
|
||||
|
||||
def _contains_cursor(
|
||||
command: Command, source_lines: list[str], cursor: tuple[int, int]
|
||||
) -> bool:
|
||||
start = _to_lsp_position(getattr(command, "pos", None))
|
||||
end = _to_lsp_position(getattr(command, "end_pos", None))
|
||||
if start is None or end is None or cursor < start:
|
||||
return False
|
||||
|
||||
if cursor <= end:
|
||||
return True
|
||||
|
||||
# tclint excludes trailing whitespace from a command's range. Keep the
|
||||
# command active while the cursor is in that whitespace so typing a space
|
||||
# after the command name or an argument can trigger signature help.
|
||||
if cursor[0] != end[0] or cursor[0] >= len(source_lines):
|
||||
return False
|
||||
|
||||
line = source_lines[cursor[0]]
|
||||
if cursor[1] > len(line):
|
||||
return False
|
||||
|
||||
return line[end[1] : cursor[1]].isspace()
|
||||
|
||||
|
||||
def _active_argument(command: Command, cursor: tuple[int, int]) -> int:
|
||||
for index, argument in enumerate(command.args):
|
||||
start = _to_lsp_position(getattr(argument, "pos", None))
|
||||
end = _to_lsp_position(getattr(argument, "end_pos", None))
|
||||
if start is None or end is None:
|
||||
continue
|
||||
if cursor < start or start <= cursor <= end:
|
||||
return index
|
||||
|
||||
return len(command.args)
|
||||
|
||||
|
||||
def find_active_call(
|
||||
source: str, tree: Node, position: lsp.Position
|
||||
) -> ActiveCall | None:
|
||||
"""Return the innermost Tcl command at the cursor and its argument index."""
|
||||
cursor = (position.line, position.character)
|
||||
source_lines = source.split("\n")
|
||||
candidates: list[tuple[int, tuple[int, int], Command]] = []
|
||||
|
||||
def walk(node: Node, depth: int = 0) -> None:
|
||||
if isinstance(node, Command) and _contains_cursor(node, source_lines, cursor):
|
||||
start = _to_lsp_position(getattr(node, "pos", None)) or (0, 0)
|
||||
candidates.append((depth, start, node))
|
||||
|
||||
for child in getattr(node, "children", []):
|
||||
walk(child, depth + 1)
|
||||
|
||||
walk(tree)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
_, _, command = max(candidates, key=lambda item: (item[0], item[1]))
|
||||
name = getattr(command.routine, "contents", None)
|
||||
if not name:
|
||||
return None
|
||||
|
||||
return ActiveCall(name=name, active_argument=_active_argument(command, cursor))
|
||||
|
||||
|
||||
def _parameter_label(
|
||||
signature_label: str, name: str, start_at: int = 0
|
||||
) -> str | tuple[int, int]:
|
||||
start = signature_label.find(name, start_at)
|
||||
if start < 0:
|
||||
start = signature_label.lower().find(name.lower(), start_at)
|
||||
if start < 0:
|
||||
return name
|
||||
return start, start + len(name)
|
||||
|
||||
|
||||
def _custom_signature(
|
||||
name: str, parameter_names: list[str], documentation: str | None
|
||||
) -> lsp.SignatureInformation:
|
||||
label = " ".join([name, *parameter_names])
|
||||
parameters = []
|
||||
search_from = len(name)
|
||||
for parameter_name in parameter_names:
|
||||
parameter_label = _parameter_label(label, parameter_name, search_from)
|
||||
parameters.append(lsp.ParameterInformation(label=parameter_label))
|
||||
if isinstance(parameter_label, tuple):
|
||||
search_from = parameter_label[1]
|
||||
return lsp.SignatureInformation(
|
||||
label=label,
|
||||
documentation=(
|
||||
lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=documentation)
|
||||
if documentation
|
||||
else None
|
||||
),
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
|
||||
def _builtin_signature(item: dict[str, Any]) -> lsp.SignatureInformation:
|
||||
label = item.get("format") or item.get("label", "")
|
||||
parameters = []
|
||||
for parameter in item.get("parameters", []):
|
||||
name = parameter.get("name", "")
|
||||
parameters.append(
|
||||
lsp.ParameterInformation(
|
||||
label=_parameter_label(label, name),
|
||||
documentation=parameter.get("desc") or None,
|
||||
)
|
||||
)
|
||||
|
||||
return lsp.SignatureInformation(
|
||||
label=label,
|
||||
documentation=item.get("description") or None,
|
||||
parameters=parameters,
|
||||
)
|
||||
|
||||
|
||||
def build_signature_help(
|
||||
source: str,
|
||||
tree: Node,
|
||||
position: lsp.Position,
|
||||
custom_signatures: dict[str, list[str]],
|
||||
custom_docs: dict[str, str],
|
||||
builtin_items: list[dict[str, Any]],
|
||||
) -> lsp.SignatureHelp | None:
|
||||
call = find_active_call(source, tree, position)
|
||||
if call is None:
|
||||
return None
|
||||
|
||||
if call.name in custom_signatures:
|
||||
signature = _custom_signature(
|
||||
call.name,
|
||||
custom_signatures[call.name],
|
||||
custom_docs.get(call.name),
|
||||
)
|
||||
else:
|
||||
item = next(
|
||||
(item for item in builtin_items if item.get("label") == call.name), None
|
||||
)
|
||||
if item is None:
|
||||
return None
|
||||
signature = _builtin_signature(item)
|
||||
|
||||
parameter_count = len(signature.parameters or [])
|
||||
active_parameter = (
|
||||
min(call.active_argument, parameter_count - 1) if parameter_count else None
|
||||
)
|
||||
signature.active_parameter = active_parameter
|
||||
return lsp.SignatureHelp(
|
||||
signatures=[signature],
|
||||
active_signature=0,
|
||||
active_parameter=active_parameter,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
THIS_DIR = Path(__file__).parent
|
||||
SRC_DIR = THIS_DIR.parent.parent / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
import _debug_server
|
||||
|
||||
|
||||
def test_debug_endpoint_defaults(monkeypatch):
|
||||
monkeypatch.delenv("NXPS_DEBUG_HOST", raising=False)
|
||||
monkeypatch.delenv("NXPS_DEBUG_PORT", raising=False)
|
||||
|
||||
assert _debug_server._debug_endpoint() == ("127.0.0.1", 5678)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", ["invalid", "0", "65536"])
|
||||
def test_debug_endpoint_rejects_invalid_port(monkeypatch, port):
|
||||
monkeypatch.setenv("NXPS_DEBUG_PORT", port)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
_debug_server._debug_endpoint()
|
||||
|
||||
|
||||
def test_connect_debugger_retries_until_adapter_is_ready(monkeypatch):
|
||||
class FakeDebugpy:
|
||||
def __init__(self):
|
||||
self.connect_calls = 0
|
||||
self.wait_calls = 0
|
||||
|
||||
def connect(self, endpoint):
|
||||
assert endpoint == ("127.0.0.1", 5678)
|
||||
self.connect_calls += 1
|
||||
if self.connect_calls < 3:
|
||||
raise ConnectionRefusedError("listener is starting")
|
||||
|
||||
def wait_for_client(self):
|
||||
self.wait_calls += 1
|
||||
|
||||
fake_debugpy = FakeDebugpy()
|
||||
monkeypatch.setattr(_debug_server.time, "sleep", lambda _seconds: None)
|
||||
|
||||
_debug_server._connect_debugger(
|
||||
fake_debugpy, "127.0.0.1", 5678, timeout=1.0
|
||||
)
|
||||
|
||||
assert fake_debugpy.connect_calls == 3
|
||||
assert fake_debugpy.wait_calls == 1
|
||||
@@ -0,0 +1,220 @@
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
|
||||
|
||||
THIS_DIR = Path(__file__).parent
|
||||
SRC_DIR = THIS_DIR.parent.parent / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
import lsprotocol.types as lsp # type: ignore
|
||||
from pygls.workspace.text_document import TextDocument
|
||||
|
||||
import lsp_server
|
||||
from lsp_tclserver import TclLanguageServer
|
||||
|
||||
|
||||
def _server() -> TclLanguageServer:
|
||||
return TclLanguageServer(name="stability-test", version="1", max_workers=4)
|
||||
|
||||
|
||||
def _document(path: Path, source: str, version: int = 1) -> TextDocument:
|
||||
return TextDocument(
|
||||
uri=path.as_uri(),
|
||||
source=source,
|
||||
version=version,
|
||||
language_id="tcl",
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_proc_stays_indexed_when_other_file_is_removed(tmp_path: Path):
|
||||
server = _server()
|
||||
first = _document(
|
||||
tmp_path / "first.tcl", "proc shared {first} { return $first }"
|
||||
)
|
||||
second = _document(
|
||||
tmp_path / "second.tcl", "proc shared {second} { return $second }"
|
||||
)
|
||||
|
||||
assert server.update_poco_completion_for_file(first)
|
||||
assert server.update_poco_completion_for_file(second)
|
||||
_, signatures, _ = server.index_snapshot()
|
||||
assert "shared" in signatures[first.path]
|
||||
assert "shared" in signatures[second.path]
|
||||
|
||||
server.diagnostics[first.uri] = (first.version, [])
|
||||
server.remove_file_state(first.uri)
|
||||
|
||||
completions, signatures, docs = server.index_snapshot()
|
||||
assert first.path not in completions
|
||||
assert first.path not in signatures
|
||||
assert first.path not in docs
|
||||
assert first.path not in server.navigation_snapshot()
|
||||
assert "shared" in signatures[second.path]
|
||||
assert server.diagnostic_snapshot(first.uri) is None
|
||||
|
||||
|
||||
def test_close_replaces_unsaved_index_with_saved_file(tmp_path: Path, monkeypatch):
|
||||
path = tmp_path / "close.tcl"
|
||||
path.write_text("proc saved_proc {} { return }", encoding="utf-8")
|
||||
document = _document(path, "proc unsaved_proc {} { return }")
|
||||
server = _server()
|
||||
server.update_poco_completion_for_file(document)
|
||||
server.get_tree(document)
|
||||
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
|
||||
|
||||
lsp_server.did_close(
|
||||
lsp.DidCloseTextDocumentParams(
|
||||
text_document=lsp.TextDocumentIdentifier(uri=document.uri)
|
||||
)
|
||||
)
|
||||
|
||||
_, signatures, _ = server.index_snapshot()
|
||||
assert "unsaved_proc" not in signatures[document.path]
|
||||
assert "saved_proc" in signatures[document.path]
|
||||
assert all(key[0] != document.uri for key in server._ast_cache)
|
||||
|
||||
|
||||
def test_delete_and_rename_notifications_update_index(tmp_path: Path, monkeypatch):
|
||||
server = _server()
|
||||
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
|
||||
|
||||
deleted_path = tmp_path / "deleted.tcl"
|
||||
deleted = _document(deleted_path, "proc deleted_proc {} { return }")
|
||||
server.update_poco_completion_for_file(deleted)
|
||||
lsp_server.did_delete_files(
|
||||
lsp.DeleteFilesParams(files=[lsp.FileDelete(uri=deleted.uri)])
|
||||
)
|
||||
_, signatures, _ = server.index_snapshot()
|
||||
assert deleted.path not in signatures
|
||||
assert deleted.path not in server.navigation_snapshot()
|
||||
|
||||
old_path = tmp_path / "old.tcl"
|
||||
new_path = tmp_path / "new.tcl"
|
||||
old_path.write_text("proc renamed_proc {} { return }", encoding="utf-8")
|
||||
old_document = _document(old_path, old_path.read_text(encoding="utf-8"))
|
||||
server.update_poco_completion_for_file(old_document)
|
||||
old_path.rename(new_path)
|
||||
|
||||
lsp_server.did_rename_files(
|
||||
lsp.RenameFilesParams(
|
||||
files=[
|
||||
lsp.FileRename(old_uri=old_path.as_uri(), new_uri=new_path.as_uri())
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
_, signatures, _ = server.index_snapshot()
|
||||
assert old_document.path not in signatures
|
||||
renamed_signatures = next(
|
||||
value
|
||||
for indexed_path, value in signatures.items()
|
||||
if server.paths_equal(indexed_path, new_path)
|
||||
)
|
||||
assert "renamed_proc" in renamed_signatures
|
||||
assert any(
|
||||
server.paths_equal(indexed_path, new_path)
|
||||
for indexed_path in server.navigation_snapshot()
|
||||
)
|
||||
|
||||
|
||||
def test_parallel_file_indexing_keeps_every_file(tmp_path: Path):
|
||||
server = _server()
|
||||
documents = [
|
||||
_document(
|
||||
tmp_path / f"parallel_{index}.tcl",
|
||||
f"proc parallel_{index} {{value}} {{ return $value }}",
|
||||
)
|
||||
for index in range(20)
|
||||
]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=8) as executor:
|
||||
results = list(
|
||||
executor.map(
|
||||
lambda document: server.update_poco_completion_for_file(
|
||||
document, cache_tree=False
|
||||
),
|
||||
documents,
|
||||
)
|
||||
)
|
||||
|
||||
assert all(results)
|
||||
completions, signatures, _ = server.index_snapshot()
|
||||
assert len(completions) == len(documents)
|
||||
for index, document in enumerate(documents):
|
||||
assert f"parallel_{index}" in signatures[document.path]
|
||||
|
||||
|
||||
def test_disk_index_cannot_overwrite_newer_open_document(tmp_path: Path):
|
||||
server = _server()
|
||||
path = tmp_path / "versioned.tcl"
|
||||
open_document = _document(path, "proc current_proc {} { return }", version=5)
|
||||
disk_document = TextDocument(
|
||||
uri=path.as_uri(),
|
||||
source="proc stale_proc {} { return }",
|
||||
version=None,
|
||||
language_id="tcl",
|
||||
)
|
||||
|
||||
assert server.update_poco_completion_for_file(open_document)
|
||||
assert not server.update_poco_completion_for_file(
|
||||
disk_document, cache_tree=False
|
||||
)
|
||||
|
||||
_, signatures, _ = server.index_snapshot()
|
||||
assert "current_proc" in signatures[open_document.path]
|
||||
assert "stale_proc" not in signatures[open_document.path]
|
||||
|
||||
|
||||
def test_repeated_lint_does_not_mutate_cached_violations(tmp_path: Path):
|
||||
server = _server()
|
||||
document = _document(
|
||||
tmp_path / "lint.tcl",
|
||||
"proc invalid {{optional 1} required} { return }",
|
||||
)
|
||||
|
||||
first = server.linter(document)
|
||||
second = server.linter(document)
|
||||
|
||||
assert len(first) == 1
|
||||
assert len(second) == 1
|
||||
assert second[0].message == first[0].message
|
||||
|
||||
|
||||
def test_diagnostics_keep_latest_document_version(tmp_path: Path):
|
||||
server = _server()
|
||||
path = tmp_path / "diagnostics.tcl"
|
||||
first = _document(path, "set value 1", version=1)
|
||||
latest = _document(path, "set value 2", version=2)
|
||||
|
||||
server.compute_diagnostics(first)
|
||||
server.clear_cache_for_uri(first.uri)
|
||||
server.compute_diagnostics(latest)
|
||||
server.compute_diagnostics(first)
|
||||
|
||||
version, _ = server.diagnostic_snapshot(first.uri)
|
||||
assert version == 2
|
||||
|
||||
|
||||
def test_delete_invalidates_in_flight_diagnostics(tmp_path: Path, monkeypatch):
|
||||
server = _server()
|
||||
document = _document(tmp_path / "in_flight.tcl", "set value 1")
|
||||
started = Event()
|
||||
release = Event()
|
||||
|
||||
def slow_diagnostics(_document):
|
||||
started.set()
|
||||
assert release.wait(timeout=5)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(server, "_compute_diagnostics", slow_diagnostics)
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(server.compute_diagnostics, document)
|
||||
assert started.wait(timeout=5)
|
||||
server.remove_file_state(document.uri)
|
||||
release.set()
|
||||
future.result(timeout=5)
|
||||
|
||||
assert server.diagnostic_snapshot(document.uri) is None
|
||||
@@ -0,0 +1,271 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
THIS_DIR = Path(__file__).parent
|
||||
SRC_DIR = THIS_DIR.parent.parent / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
import lsprotocol.types as lsp # type: ignore
|
||||
from pygls.workspace.text_document import TextDocument
|
||||
|
||||
import lsp_server
|
||||
from lsp_tclserver import TclLanguageServer
|
||||
from tools.navigation import (
|
||||
SymbolIdentity,
|
||||
build_file_symbol_index,
|
||||
definition_identities,
|
||||
matching_occurrences,
|
||||
symbol_at_position,
|
||||
workspace_symbols,
|
||||
)
|
||||
from tools.parser import CustomParser
|
||||
|
||||
|
||||
def _index(path: Path, source: str):
|
||||
return build_file_symbol_index(
|
||||
str(path), path.as_uri(), CustomParser().parse(source)
|
||||
)
|
||||
|
||||
|
||||
def _document(path: Path, source: str) -> TextDocument:
|
||||
return TextDocument(
|
||||
uri=path.as_uri(),
|
||||
source=source,
|
||||
version=1,
|
||||
language_id="tcl",
|
||||
)
|
||||
|
||||
|
||||
def _position(source: str, token: str, occurrence: int = 0) -> lsp.Position:
|
||||
offset = -1
|
||||
for _ in range(occurrence + 1):
|
||||
offset = source.index(token, offset + 1)
|
||||
before = source[:offset]
|
||||
return lsp.Position(
|
||||
line=before.count("\n"),
|
||||
character=offset - (before.rfind("\n") + 1),
|
||||
)
|
||||
|
||||
|
||||
def _range_text(source: str, range_: lsp.Range) -> str:
|
||||
assert range_.start.line == range_.end.line
|
||||
line = source.splitlines()[range_.start.line]
|
||||
return line[range_.start.character : range_.end.character]
|
||||
|
||||
|
||||
def test_proc_references_respect_namespaces_and_root_fallback(tmp_path: Path):
|
||||
first_source = """proc shared {value} { return $value }
|
||||
namespace eval shop {
|
||||
proc shared {value} { return $value }
|
||||
proc call {} { shared 1 }
|
||||
}
|
||||
"""
|
||||
second_source = """shared 2
|
||||
namespace eval shop { shared 3 }
|
||||
::shop::shared 4
|
||||
"""
|
||||
first = _index(tmp_path / "first.tcl", first_source)
|
||||
second = _index(tmp_path / "second.tcl", second_source)
|
||||
indexes = {first.path: first, second.path: second}
|
||||
definitions = definition_identities(indexes)
|
||||
|
||||
root = SymbolIdentity(kind="proc", name="::shared")
|
||||
namespaced = SymbolIdentity(kind="proc", name="::shop::shared")
|
||||
|
||||
assert len(matching_occurrences(root, indexes, definitions)) == 2
|
||||
assert len(matching_occurrences(namespaced, indexes, definitions)) == 4
|
||||
|
||||
|
||||
def test_local_variable_identity_does_not_leak_between_procs(tmp_path: Path):
|
||||
source = """proc first {} {
|
||||
set value 1
|
||||
puts $value
|
||||
}
|
||||
proc second {} {
|
||||
set value 2
|
||||
puts $value
|
||||
}
|
||||
"""
|
||||
index = _index(tmp_path / "locals.tcl", source)
|
||||
indexes = {index.path: index}
|
||||
definitions = definition_identities(indexes)
|
||||
position = _position(source, "$value")
|
||||
result = symbol_at_position(
|
||||
index,
|
||||
lsp.Position(position.line, position.character + 1),
|
||||
definitions,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
_, identity = result
|
||||
matches = matching_occurrences(identity, indexes, definitions)
|
||||
assert len(matches) == 2
|
||||
assert all(match.identity.scope and "::first" in match.identity.scope for _, match in matches)
|
||||
|
||||
|
||||
def test_foreach_binding_can_be_renamed_without_touching_other_proc(
|
||||
tmp_path: Path,
|
||||
):
|
||||
source = """proc first {items} {
|
||||
foreach item $items { puts $item }
|
||||
}
|
||||
proc second {items} {
|
||||
foreach item $items { puts $item }
|
||||
}
|
||||
"""
|
||||
index = _index(tmp_path / "foreach.tcl", source)
|
||||
indexes = {index.path: index}
|
||||
definitions = definition_identities(indexes)
|
||||
position = _position(source, "item", occurrence=1)
|
||||
result = symbol_at_position(index, position, definitions)
|
||||
|
||||
assert result is not None
|
||||
_, identity = result
|
||||
matches = matching_occurrences(identity, indexes, definitions)
|
||||
assert identity in definitions
|
||||
assert len(matches) == 2
|
||||
assert all("::first" in (occurrence.identity.scope or "") for _, occurrence in matches)
|
||||
|
||||
|
||||
def test_variable_ranges_preserve_qualifiers_and_tcl_substitution(tmp_path: Path):
|
||||
source = """namespace eval shop {
|
||||
variable value 0
|
||||
proc use {} {
|
||||
variable value
|
||||
set value 1
|
||||
puts ${value}
|
||||
}
|
||||
}
|
||||
set ::shop::value 2
|
||||
"""
|
||||
index = _index(tmp_path / "variables.tcl", source)
|
||||
indexes = {index.path: index}
|
||||
definitions = definition_identities(indexes)
|
||||
identity = SymbolIdentity(kind="variable", name="::shop::value")
|
||||
matches = matching_occurrences(identity, indexes, definitions)
|
||||
|
||||
assert len(matches) == 5
|
||||
assert all(_range_text(source, occurrence.range) == "value" for _, occurrence in matches)
|
||||
|
||||
|
||||
def test_qualified_proc_body_and_variable_import_use_declared_namespace(
|
||||
tmp_path: Path,
|
||||
):
|
||||
source = """namespace eval current {
|
||||
proc ::other::use {} {
|
||||
variable value
|
||||
puts $value
|
||||
variable ::external::setting
|
||||
puts $setting
|
||||
}
|
||||
}
|
||||
namespace eval other { variable value 1 }
|
||||
namespace eval external { variable setting 2 }
|
||||
"""
|
||||
index = _index(tmp_path / "qualified.tcl", source)
|
||||
indexes = {index.path: index}
|
||||
definitions = definition_identities(indexes)
|
||||
|
||||
other_value = SymbolIdentity(kind="variable", name="::other::value")
|
||||
external_setting = SymbolIdentity(
|
||||
kind="variable", name="::external::setting"
|
||||
)
|
||||
assert len(matching_occurrences(other_value, indexes, definitions)) == 3
|
||||
assert len(matching_occurrences(external_setting, indexes, definitions)) == 3
|
||||
|
||||
|
||||
def test_workspace_symbols_include_procs_namespaces_and_global_variables(tmp_path: Path):
|
||||
source = """set globalValue 1
|
||||
set globalValue 2
|
||||
proc rootProc {} { return }
|
||||
namespace eval shop { proc namespacedProc {} { return } }
|
||||
"""
|
||||
index = _index(tmp_path / "symbols.tcl", source)
|
||||
|
||||
symbols = workspace_symbols({index.path: index}, "")
|
||||
names = [symbol.name for symbol in symbols]
|
||||
|
||||
assert names.count("globalValue") == 1
|
||||
assert "rootProc" in names
|
||||
assert "shop" in names
|
||||
assert "shop::namespacedProc" in names
|
||||
|
||||
|
||||
def test_lsp_references_definition_rename_and_workspace_symbols(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
declaration_source = "proc customProc {value} { return $value }\n"
|
||||
usage_source = "set result [customProc 1]\n"
|
||||
declaration = _document(tmp_path / "declaration.tcl", declaration_source)
|
||||
usage = _document(tmp_path / "usage.tcl", usage_source)
|
||||
server = TclLanguageServer(name="navigation-test", version="1", max_workers=1)
|
||||
assert server.update_poco_completion_for_file(declaration)
|
||||
assert server.update_poco_completion_for_file(usage)
|
||||
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
|
||||
position = _position(usage_source, "customProc")
|
||||
identifier = lsp.TextDocumentIdentifier(uri=usage.uri)
|
||||
|
||||
definitions = lsp_server.goto_definition(
|
||||
lsp.DefinitionParams(text_document=identifier, position=position)
|
||||
)
|
||||
assert definitions is not None
|
||||
assert len(definitions) == 1
|
||||
assert definitions[0].uri == declaration.uri
|
||||
|
||||
references = lsp_server.references(
|
||||
lsp.ReferenceParams(
|
||||
text_document=identifier,
|
||||
position=position,
|
||||
context=lsp.ReferenceContext(include_declaration=True),
|
||||
)
|
||||
)
|
||||
assert len(references) == 2
|
||||
|
||||
prepared = lsp_server.prepare_rename(
|
||||
lsp.PrepareRenameParams(text_document=identifier, position=position)
|
||||
)
|
||||
assert prepared is not None
|
||||
assert prepared.placeholder == "customProc"
|
||||
|
||||
edit = lsp_server.rename(
|
||||
lsp.RenameParams(
|
||||
text_document=identifier,
|
||||
position=position,
|
||||
new_name="renamedProc",
|
||||
)
|
||||
)
|
||||
assert edit is not None
|
||||
assert edit.changes is not None
|
||||
assert set(edit.changes) == {declaration.uri, usage.uri}
|
||||
assert all(
|
||||
text_edit.new_text == "renamedProc"
|
||||
for edits in edit.changes.values()
|
||||
for text_edit in edits
|
||||
)
|
||||
|
||||
symbols = lsp_server.workspace_symbol(
|
||||
lsp.WorkspaceSymbolParams(query="custom")
|
||||
)
|
||||
assert [symbol.name for symbol in symbols] == ["customProc"]
|
||||
|
||||
|
||||
def test_duplicate_proc_definition_cannot_be_renamed(tmp_path: Path, monkeypatch):
|
||||
server = TclLanguageServer(name="navigation-test", version="1", max_workers=1)
|
||||
documents = [
|
||||
_document(tmp_path / f"duplicate_{number}.tcl", "proc duplicate {} { return }")
|
||||
for number in range(2)
|
||||
]
|
||||
for document in documents:
|
||||
assert server.update_poco_completion_for_file(document)
|
||||
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
|
||||
|
||||
result = lsp_server.prepare_rename(
|
||||
lsp.PrepareRenameParams(
|
||||
text_document=lsp.TextDocumentIdentifier(uri=documents[0].uri),
|
||||
position=lsp.Position(line=0, character=6),
|
||||
)
|
||||
)
|
||||
|
||||
assert result is None
|
||||
@@ -0,0 +1,92 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
THIS_DIR = Path(__file__).parent
|
||||
SRC_DIR = THIS_DIR.parent.parent / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
import lsprotocol.types as lsp # type: ignore
|
||||
|
||||
from common.load_data import standard_items
|
||||
from tools.parser import CustomParser
|
||||
from tools.signature_help import build_signature_help, find_active_call
|
||||
|
||||
|
||||
CUSTOM_SIGNATURES = {
|
||||
"SERVICE_spacer_output": ["type", "length", "line_num", "output"]
|
||||
}
|
||||
|
||||
|
||||
def test_custom_proc_signature_and_active_parameter():
|
||||
source = 'SERVICE_spacer_output "*" '
|
||||
tree = CustomParser().parse(source)
|
||||
|
||||
result = build_signature_help(
|
||||
source,
|
||||
tree,
|
||||
lsp.Position(line=0, character=len(source)),
|
||||
CUSTOM_SIGNATURES,
|
||||
{"SERVICE_spacer_output": "Outputs a spacer line."},
|
||||
[],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.active_parameter == 1
|
||||
signature = result.signatures[0]
|
||||
assert signature.label == "SERVICE_spacer_output type length line_num output"
|
||||
assert ( # type: ignore[union-attr]
|
||||
signature.documentation.value == "Outputs a spacer line."
|
||||
)
|
||||
assert signature.parameters is not None
|
||||
assert signature.parameters[3].label == (43, 49)
|
||||
|
||||
|
||||
def test_innermost_command_is_used_for_nested_call():
|
||||
source = 'set result [SERVICE_spacer_output "*" 20]'
|
||||
tree = CustomParser().parse(source)
|
||||
position = lsp.Position(line=0, character=source.index("20"))
|
||||
|
||||
call = find_active_call(source, tree, position)
|
||||
|
||||
assert call is not None
|
||||
assert call.name == "SERVICE_spacer_output"
|
||||
assert call.active_argument == 1
|
||||
|
||||
|
||||
def test_builtin_signature_includes_parameter_documentation():
|
||||
source = "MOM_abort "
|
||||
tree = CustomParser().parse(source)
|
||||
|
||||
result = build_signature_help(
|
||||
source,
|
||||
tree,
|
||||
lsp.Position(line=0, character=len(source)),
|
||||
{},
|
||||
{},
|
||||
standard_items.json_data["MOM_procs"],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.active_parameter == 0
|
||||
signature = result.signatures[0]
|
||||
assert signature.label == "MOM_abort <message>"
|
||||
assert signature.parameters is not None
|
||||
assert signature.parameters[0].documentation
|
||||
|
||||
|
||||
def test_unknown_command_has_no_signature_help():
|
||||
source = "unknown_custom_command "
|
||||
tree = CustomParser().parse(source)
|
||||
|
||||
result = build_signature_help(
|
||||
source,
|
||||
tree,
|
||||
lsp.Position(line=0, character=len(source)),
|
||||
{},
|
||||
{},
|
||||
standard_items.json_data["MOM_procs"],
|
||||
)
|
||||
|
||||
assert result is None
|
||||
@@ -114,3 +114,6 @@ proc SERVICE_get_tool_data {} {
|
||||
LIB_GE_command_buffer_edit_replace MOM_end_of_program_LIB END_OF_PROGRAM @END_OF_PROG {
|
||||
MOM_do_template "end_of_program_rewind"
|
||||
} EndOfProgramRewind
|
||||
|
||||
|
||||
SERVICE_remove_file "test"
|
||||
Reference in New Issue
Block a user