Compare commits

..
8 Commits
Author SHA1 Message Date
Christoph Brandau af195a577b refactor(lsp): cache analysis results and debounce diagnostics
build_and_puplish.yml / build_and_publish (release) Successful in 29s
This change adds cached and incremental analysis for the LSP
server to improve responsiveness. The client now debounces
diagnostic updates to avoid excessive recomputation. The
server introduces per-document line caches and various
caches for completions, inlay hints, and metadata to
support faster, incremental updates.

- Debounce diagnostics on text changes to reduce noise.
- Add caches for completions, inlay hints, and metadata.
- Introduce incremental analysis with per-document line caches.
2026-08-19 13:41:53 +02:00
Christoph Brandau ecb50be2b8 feat(inlay-hints): add configurable parameter name hints
Adds configurable inlay hints for TCL procedures and merges signatures from built-ins and workspace files. The feature supports parameterNames and suppressWhenArgumentMatchesName and respects an optional range filter and current-file priority.

- Introduces built-in and custom inlay hint builders
- Honors inlayHints parameterNames and suppression options
- Adds tests validating hints, ranges, and priority rules
2026-08-19 12:59:51 +02:00
Christoph 61d4785775 Update version to 2026.8.100 2026-08-19 07:47:31 +00:00
Christoph Brandau 541704f45a feat(cdl): add CDL event handler parsing and snippet support
build_and_puplish.yml / build_and_publish (release) Successful in 36s
This adds a small DSL-aware helper to extract CDL event
declarations and generate a ready-to-use snippet. It also
extends hover support to show the snippet and parameter
hints for CDL events, improving developer productivity.

- Generate a MOM event handler scaffold and local vars
- Hover shows the generated snippet and mom_ parameter hints
2026-08-19 09:45:57 +02:00
Christoph Brandau 33cf282b0a feat(debug): enable Python debug workflow and debug server integration
Adds an end-to-end Python debug workflow for the extension.
Includes a new prepare-debug.ps1 script and a VS Code task.
Extends the Python server and extension to coordinate a debug session and safe startup.

- Add prepare-debug.ps1 and a VS Code task to build the debug bundle
- Enable Python debug wiring in the server and tests
- Ensure a single stable Python debug session during startup
2026-08-17 11:02:22 +02:00
Christoph Brandau 35a4357551 feat(navigation): add symbol index and LSP navigation features
The changes introduce a Tcl symbol index powering LSP navigation
features across the workspace. A navigation API exposes
snapshots and update hooks, enabling goto-definition,
references, and rename using the index. Background indexing
now watches Tcl files and rebuilds the index to stay in sync.

- Add Tcl symbol index and navigation snapshot API
- Wire go-to-definition, references, and rename using the index
- Watch Tcl files and refresh the index in the background
2026-08-17 09:24:45 +02:00
Christoph Brandau f5bd79f067 feat(lsp): add incremental indexing and file ops support
Adds a thread-safe incremental index and snapshot API for LSP.
Introduces cache invalidation and file operation hooks for delete
and rename. This keeps indices in sync with disk changes.
Supports reindexing TCL files from disk when needed.

- Adds workspace file change handlers to sync indices on delete/rename.
- Introduces locking and snapshot helpers to safely access shared state.
- Refactors to invalidate caches on edits and reindex TCL files.
2026-08-17 08:45:09 +02:00
Christoph Brandau a39aee1b9d feat(server): add TCL signature help support
Adds a signature help system for TCL commands.
The LSP server now exposes signature help for custom and
built-in MOM procedures, enabling parameter hints while editing.
Tests and documentation were added to cover common usage.

- Adds signature_help module to parse and present signatures
- Integrates with LSP server to provide signature help on the client
- Adds tests for custom and built-in procedures
2026-08-17 08:31:15 +02:00
29 changed files with 3527 additions and 458 deletions
+35 -12
View File
@@ -10,13 +10,21 @@
"type": "extensionHost", "type": "extensionHost",
"request": "launch", "request": "launch",
"runtimeExecutable": "${execPath}", "runtimeExecutable": "${execPath}",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"], "args": [
"outFiles": ["${workspaceFolder}/client/**/*.js"], "--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, "autoAttachChildProcesses": true,
"preLaunchTask": { "preLaunchTask": "NX Post Support: Compile Debug"
"type": "npm",
"script": "watch"
}
}, },
{ {
"name": "Python Attach", "name": "Python Attach",
@@ -34,10 +42,24 @@
"name": "Debug Extension (hidden)", "name": "Debug Extension (hidden)",
"type": "extensionHost", "type": "extensionHost",
"request": "launch", "request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"], "runtimeExecutable": "${execPath}",
"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>/**"],
"env": { "env": {
"USE_DEBUGPY": "True" "USE_DEBUGPY": "True",
"NXPS_DEBUG_HOST": "127.0.0.1",
"NXPS_DEBUG_PORT": "5678"
}, },
"presentation": { "presentation": {
"hidden": true, "hidden": true,
@@ -49,8 +71,9 @@
"name": "Python debug server (hidden)", "name": "Python debug server (hidden)",
"type": "debugpy", "type": "debugpy",
"request": "attach", "request": "attach",
"listen": { "host": "localhost", "port": 5678 }, "listen": { "host": "127.0.0.1", "port": 5678 },
"justMyCode": true, "justMyCode": false,
"logToFile": true,
"presentation": { "presentation": {
"hidden": true, "hidden": true,
"group": "", "group": "",
@@ -63,7 +86,7 @@
"name": "Debug Extension and Python", "name": "Debug Extension and Python",
"configurations": ["Python debug server (hidden)", "Debug Extension (hidden)"], "configurations": ["Python debug server (hidden)", "Debug Extension (hidden)"],
"stopAll": true, "stopAll": true,
"preLaunchTask": "npm: watch", "preLaunchTask": "NX Post Support: Compile Debug",
"presentation": { "presentation": {
"hidden": false, "hidden": false,
"group": "", "group": "",
+57
View File
@@ -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
+26
View File
@@ -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
}
}
]
}
+11
View File
@@ -1,3 +1,14 @@
## Unreleased
- Prevent truncated TCL inlay hints and add configurable parameter hint modes
- Add inlay hints for built-in NX procedures, variadic arguments, and visible ranges
- Add inlay hint documentation and navigation to custom procedure definitions
- 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
- Improve TCL response times with debounced edits and cached semantic, inlay, hover, completion, and variable indexes
- Debounce CDL/DEF diagnostics and remove per-line diagnostic logging
## [0.0.1] ## [0.0.1]
- Initial release - Initial release
+32 -1
View File
@@ -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 - **Multi-language Support** - Supports NX CDL, TCL, and DEF file formats
- **Intelligent Code Analysis** - Linting and error detection for postprocessor code - **Intelligent Code Analysis** - Linting and error detection for postprocessor code
- **Auto-completion** - Context-aware code completion for faster development - **Auto-completion** - Context-aware code completion for faster development
- **Signature Help** - Shows parameters and documentation for custom and NX procedures
## Supported File Types ## Supported File Types
@@ -19,7 +20,7 @@ A comprehensive VS Code extension providing language support for NX CAM postproc
## Installation ## Installation
1. Install from the VS Code Marketplace 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 3. Open any `.cdl`, `.tcl`, or `.def` file
4. The extension will automatically activate and provide language support 4. The extension will automatically activate and provide language support
@@ -30,6 +31,12 @@ The extension can be configured through VS Code settings:
- `nx-post-support.interpreter` - Specify custom Python interpreter path for the language server - `nx-post-support.interpreter` - Specify custom Python interpreter path for the language server
- `nx-post-support.formatter` - Enable/disable the TCL formatter (default: false) - `nx-post-support.formatter` - Enable/disable the TCL formatter (default: false)
- `nx-post-support.inlayHint` - Enable/disable inlay Hints (default: true) - `nx-post-support.inlayHint` - Enable/disable inlay Hints (default: true)
- `nx-post-support.inlayHints.parameterNames` - Show parameter names for `all`, only `literals`, or `none` (default: `all`)
- `nx-post-support.inlayHints.suppressWhenArgumentMatchesName` - Hide redundant hints such as `value:` before `$value` (default: true)
TCL files default to unlimited inlay hint length so that VS Code does not
truncate later parameter names on a line. An explicit user setting for
`editor.inlayHints.maximumLength` still takes precedence.
## Usage ## Usage
@@ -40,6 +47,30 @@ Simply open any supported file type and enjoy:
- Code completion - Code completion
- Code formatting (Format Document command) - Code formatting (Format Document command)
- Hover information - 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 ## Contributing
+22
View File
@@ -14,6 +14,7 @@
"vscode-languageclient": "^9.0.1" "vscode-languageclient": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/vscode": "^1.96.0" "@types/vscode": "^1.96.0"
}, },
@@ -21,6 +22,27 @@
"vscode": "^1.96.0" "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": { "node_modules/@types/node": {
"version": "22.10.5", "version": "22.10.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz",
+1
View File
@@ -12,6 +12,7 @@
"vscode-languageclient": "^9.0.1" "vscode-languageclient": "^9.0.1"
}, },
"devDependencies": { "devDependencies": {
"@types/fs-extra": "^11.0.4",
"@types/node": "^22.10.5", "@types/node": "^22.10.5",
"@types/vscode": "^1.96.0" "@types/vscode": "^1.96.0"
} }
+116
View File
@@ -0,0 +1,116 @@
export interface CdlEventHandler {
eventName: string
parameterNames: string[]
}
function structuralCode(line: string): string {
let result = ""
let inString = false
let escaped = false
for (const character of line) {
if (escaped) {
escaped = false
result += inString ? " " : character
continue
}
if (character === "\\") {
escaped = true
result += inString ? " " : character
continue
}
if (character === '"') {
inString = !inString
result += " "
continue
}
if (character === "#" && !inString) {
break
}
result += inString ? " " : character
}
return result
}
function braceDelta(line: string): number {
let delta = 0
for (const character of structuralCode(line)) {
if (character === "{") {
delta += 1
} else if (character === "}") {
delta -= 1
}
}
return delta
}
export function cdlEventHandlerAtLine(
source: string,
declarationLine: number
): CdlEventHandler | undefined {
const lines = source.split(/\r?\n/)
const declaration = lines[declarationLine]
if (declaration === undefined) {
return undefined
}
const eventMatch = /^\s*EVENT\s+([^\s{]+)/.exec(structuralCode(declaration))
if (!eventMatch) {
return undefined
}
const parameterNames: string[] = []
let eventOpened = false
let depth = 0
for (let lineNumber = declarationLine; lineNumber < lines.length; lineNumber++) {
const line = lines[lineNumber]
const code = structuralCode(line)
if (eventOpened && depth === 1) {
const parameterMatch = /^\s*PARAM\s+([^\s{]+)/.exec(code)
if (parameterMatch) {
parameterNames.push(parameterMatch[1])
}
}
const delta = braceDelta(line)
if (!eventOpened && delta > 0) {
eventOpened = true
}
if (eventOpened) {
depth += delta
if (depth <= 0) {
break
}
}
}
return {
eventName: eventMatch[1],
parameterNames
}
}
function momEventName(eventName: string): string {
return `MOM_${eventName.replace(/^MOM_/i, "")}`
}
function momVariableName(parameterName: string): string {
return `mom_${parameterName.replace(/^mom_/i, "")}`
}
export function createCdlEventHandlerSnippet(handler: CdlEventHandler): string {
const globals = [
...new Set(handler.parameterNames.map((parameter) => momVariableName(parameter)))
]
const lines = [`proc ${momEventName(handler.eventName)} { } {`]
if (globals.length > 0) {
lines.push(...globals.map((variable) => ` global ${variable}`), "")
}
lines.push(" #Put your UDE Handler Tcl here", "", "}")
return lines.join("\n")
}
+137 -27
View File
@@ -1,4 +1,10 @@
import * as vscode from "vscode" import * as vscode from "vscode"
import {
cdlEventHandlerAtLine,
createCdlEventHandlerSnippet
} from "./cdlEventHandler"
const MACHINE_HEADER_REGEX = /^MACHINE\s+\S+/
export function formatCdlFile(content: string): string { export function formatCdlFile(content: string): string {
let indentLevel = 0 let indentLevel = 0
@@ -37,15 +43,19 @@ export function formatDefFile(content: string): string {
} }
export function isFirstLineMachine(content: string): boolean { export function isFirstLineMachine(content: string): boolean {
const lines = content.split("\n").map((line) => line.trim()) let lineStart = 0
for (const line of lines) { while (lineStart <= content.length) {
console.log(line) const newline = content.indexOf("\n", lineStart)
const lineEnd = newline === -1 ? content.length : newline
const line = content.slice(lineStart, lineEnd).trim()
if (line === "" || line.startsWith("#")) { if (line === "" || line.startsWith("#")) {
console.log("skipping line") if (newline === -1) {
return false
}
lineStart = newline + 1
continue continue
} }
const machineRegex = /^MACHINE\s+\S+/ return MACHINE_HEADER_REGEX.test(line)
return machineRegex.test(line)
} }
return false return false
} }
@@ -53,10 +63,11 @@ export function isFirstLineMachine(content: string): boolean {
export function diagnosticHandler(document: vscode.TextDocument) { export function diagnosticHandler(document: vscode.TextDocument) {
const diagnostics: vscode.Diagnostic[] = [] const diagnostics: vscode.Diagnostic[] = []
if (document.languageId === "cdl" || document.languageId === "def") { if (document.languageId === "cdl" || document.languageId === "def") {
if (!isFirstLineMachine(document.getText())) { const text = document.getText()
if (!isFirstLineMachine(text)) {
const range = new vscode.Range( const range = new vscode.Range(
document.positionAt(0), document.positionAt(0),
document.positionAt(document.getText().length) document.positionAt(text.length)
) )
const diagnostic = new vscode.Diagnostic( const diagnostic = new vscode.Diagnostic(
range, range,
@@ -110,33 +121,132 @@ export function completionHandlerCdl(document: vscode.TextDocument, position: vs
} }
export function hoverCdlHandler(document: vscode.TextDocument, position: vscode.Position) { export function hoverCdlHandler(document: vscode.TextDocument, position: vscode.Position) {
const wordRange = document.getWordRangeAtPosition(position) const line = document.lineAt(position.line).text
const word = document.getText(wordRange) const eventMatch = /^\s*EVENT\s+([^\s{]+)/.exec(line)
const text = document.getText() if (eventMatch) {
const lines = text.split("\n") const eventStart = line.indexOf(eventMatch[1], eventMatch.index)
const declarationEnd = eventStart + eventMatch[1].length
let hoverText: string | undefined if (position.character <= declarationEnd) {
const handler = cdlEventHandlerAtLine(document.getText(), position.line)
for (const line of lines) { if (handler) {
const words = line.split(/\s+/) const markdown = new vscode.MarkdownString()
const wordIndex = words.indexOf(word) markdown.appendCodeblock(createCdlEventHandlerSnippet(handler), "tcl")
return new vscode.Hover(
if (wordIndex > 0) { markdown,
if (words[wordIndex - 1] === "EVENT") { new vscode.Range(position.line, eventMatch.index, position.line, declarationEnd)
hoverText = `MOM_${word}` )
} else if (words[wordIndex - 1] === "PARAM") {
hoverText = `mom_${word}`
} }
break
} }
} }
if (hoverText) { const parameterMatch = /^\s*PARAM\s+([^\s{]+)/.exec(line)
return new vscode.Hover(hoverText) if (parameterMatch) {
const parameterStart = line.indexOf(parameterMatch[1], parameterMatch.index)
const parameterEnd = parameterStart + parameterMatch[1].length
if (position.character >= parameterStart && position.character <= parameterEnd) {
return new vscode.Hover(`mom_${parameterMatch[1].replace(/^mom_/i, "")}`)
}
} }
return undefined 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[] { export function tclDocumentSymbolProvider(document: vscode.TextDocument): vscode.DocumentSymbol[] {
const symbols: vscode.DocumentSymbol[] = [] const symbols: vscode.DocumentSymbol[] = []
const lines = document.getText().split("\n") const lines = document.getText().split("\n")
+66 -34
View File
@@ -2,7 +2,7 @@
// Licensed under the MIT License. // Licensed under the MIT License.
import * as fsapi from "fs-extra" 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 { State } from "vscode-languageclient"
import { import {
LanguageClient, LanguageClient,
@@ -24,6 +24,13 @@ import { isVirtualWorkspace } from "./vscodeapi"
export type IInitOptions = { settings: ISettings[]; globalSettings: ISettings } export type IInitOptions = { settings: ISettings[]; globalSettings: ISettings }
let _disposables: Disposable[] = []
export function disposeServerResources(): void {
_disposables.forEach((disposable) => disposable.dispose())
_disposables = []
}
async function createServer( async function createServer(
settings: ISettings, settings: ISettings,
serverId: string, serverId: string,
@@ -32,13 +39,26 @@ async function createServer(
initializationOptions: IInitOptions initializationOptions: IInitOptions
): Promise<LanguageClient> { ): Promise<LanguageClient> {
const command = settings.interpreter[0] const command = settings.interpreter[0]
if (!command) {
throw new Error("No Python interpreter is configured for the language server.")
}
const cwd = settings.cwd const cwd = settings.cwd
// Set debugger path needed for debugging python code. // Set debugger path needed for debugging python code.
const newEnv = { ...process.env } const newEnv = { ...process.env }
const debuggerPath = await getDebuggerPath()
const isDebugScript = await fsapi.pathExists(DEBUG_SERVER_SCRIPT_PATH) 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 newEnv.DEBUGPY_PATH = debuggerPath
} else { } else {
newEnv.USE_DEBUGPY = "False" newEnv.USE_DEBUGPY = "False"
@@ -50,10 +70,13 @@ async function createServer(
// Set notification type // Set notification type
newEnv.LS_SHOW_NOTIFICATION = settings.showNotifications newEnv.LS_SHOW_NOTIFICATION = settings.showNotifications
const args = const serverScript = debugRequested ? DEBUG_SERVER_SCRIPT_PATH : SERVER_SCRIPT_PATH
newEnv.USE_DEBUGPY === "False" || !isDebugScript const interpreterArgs = settings.interpreter.slice(1)
? settings.interpreter.slice(1).concat([SERVER_SCRIPT_PATH]) if (debugRequested && !interpreterArgs.includes("-Xfrozen_modules=off")) {
: settings.interpreter.slice(1).concat([DEBUG_SERVER_SCRIPT_PATH]) interpreterArgs.push("-Xfrozen_modules=off")
}
const args = interpreterArgs.concat([serverScript])
traceInfo(`Python debug mode: ${debugRequested ? "enabled" : "disabled"}`)
traceInfo(`Server run command: ${[command, ...args].join(" ")}`) traceInfo(`Server run command: ${[command, ...args].join(" ")}`)
const serverOptions: ServerOptions = { const serverOptions: ServerOptions = {
@@ -63,6 +86,7 @@ async function createServer(
} }
// Options to control the language client // Options to control the language client
const tclFileWatcher = workspace.createFileSystemWatcher("**/*.tcl")
const clientOptions: LanguageClientOptions = { const clientOptions: LanguageClientOptions = {
// Register the server for python documents // Register the server for python documents
documentSelector: isVirtualWorkspace() documentSelector: isVirtualWorkspace()
@@ -76,13 +100,16 @@ async function createServer(
outputChannel: outputChannel, outputChannel: outputChannel,
traceOutputChannel: outputChannel, traceOutputChannel: outputChannel,
revealOutputChannelOn: RevealOutputChannelOn.Never, revealOutputChannelOn: RevealOutputChannelOn.Never,
synchronize: {
fileEvents: tclFileWatcher
},
initializationOptions initializationOptions
} }
_disposables.push(tclFileWatcher)
return new LanguageClient(serverId, serverName, serverOptions, clientOptions) return new LanguageClient(serverId, serverName, serverOptions, clientOptions)
} }
let _disposables: Disposable[] = []
export async function restartServer( export async function restartServer(
serverId: string, serverId: string,
serverName: string, serverName: string,
@@ -92,40 +119,45 @@ export async function restartServer(
if (lsClient) { if (lsClient) {
traceInfo(`Server: Stop requested`) traceInfo(`Server: Stop requested`)
await lsClient.stop() await lsClient.stop()
_disposables.forEach((d) => d.dispose()) disposeServerResources()
_disposables = []
} }
const projectRoot = await getProjectRoot() const projectRoot = await getProjectRoot()
const workspaceSetting = await getWorkspaceSettings(serverId, projectRoot, true) 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 { 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() await newLSClient.start()
const level = getLSClientTraceLevel(outputChannel.logLevel, env.logLevel)
await newLSClient.setTrace(level)
return newLSClient
} catch (ex) { } catch (ex) {
traceError(`Server: Start failed: ${ex}`) traceError(`Server: Start failed: ${ex}`)
disposeServerResources()
return undefined return undefined
} }
const level = getLSClientTraceLevel(outputChannel.logLevel, env.logLevel)
await newLSClient.setTrace(level)
return newLSClient
} }
+23 -3
View File
@@ -18,6 +18,12 @@ export interface ISettings {
interpreter: string[] interpreter: string[]
importStrategy: string importStrategy: string
showNotifications: string showNotifications: string
formatter: boolean
inlayHint: boolean
inlayHints: {
parameterNames: "all" | "literals" | "none"
suppressWhenArgumentMatchesName: boolean
}
} }
export function getExtensionSettings( export function getExtensionSettings(
@@ -80,7 +86,13 @@ export async function getWorkspaceSettings(
importStrategy: config.get<string>(`importStrategy`) ?? "useBundled", importStrategy: config.get<string>(`importStrategy`) ?? "useBundled",
showNotifications: config.get<string>(`showNotifications`) ?? "off", showNotifications: config.get<string>(`showNotifications`) ?? "off",
formatter: config.get<boolean>(`formatter`) ?? true, formatter: config.get<boolean>(`formatter`) ?? true,
inlayHint: config.get<boolean>(`inlayHint`) ?? true inlayHint: config.get<boolean>(`inlayHint`) ?? true,
inlayHints: {
parameterNames:
config.get<"all" | "literals" | "none">(`inlayHints.parameterNames`) ?? "all",
suppressWhenArgumentMatchesName:
config.get<boolean>(`inlayHints.suppressWhenArgumentMatchesName`) ?? true
}
} }
return workspaceSetting return workspaceSetting
} }
@@ -113,7 +125,13 @@ export async function getGlobalSettings(
importStrategy: getGlobalValue<string>(config, "importStrategy", "useBundled"), importStrategy: getGlobalValue<string>(config, "importStrategy", "useBundled"),
showNotifications: getGlobalValue<string>(config, "showNotifications", "off"), showNotifications: getGlobalValue<string>(config, "showNotifications", "off"),
formatter: config.get<boolean>(`formatter`) ?? true, formatter: config.get<boolean>(`formatter`) ?? true,
inlayHint: config.get<boolean>(`inlayHint`) ?? true inlayHint: config.get<boolean>(`inlayHint`) ?? true,
inlayHints: {
parameterNames:
config.get<"all" | "literals" | "none">(`inlayHints.parameterNames`) ?? "all",
suppressWhenArgumentMatchesName:
config.get<boolean>(`inlayHints.suppressWhenArgumentMatchesName`) ?? true
}
} }
return setting return setting
} }
@@ -129,7 +147,9 @@ export function checkIfConfigurationChanged(
`${namespace}.importStrategy`, `${namespace}.importStrategy`,
`${namespace}.showNotifications`, `${namespace}.showNotifications`,
`${namespace}.formatter`, `${namespace}.formatter`,
`${namespace}.inlayHint` `${namespace}.inlayHint`,
`${namespace}.inlayHints.parameterNames`,
`${namespace}.inlayHints.suppressWhenArgumentMatchesName`
] ]
const changed = settings.map((s) => e.affectsConfiguration(s)) const changed = settings.map((s) => e.affectsConfiguration(s))
return changed.includes(true) return changed.includes(true)
+83 -17
View File
@@ -13,7 +13,8 @@ import {
isFirstLineMachine, isFirstLineMachine,
diagnosticHandler, diagnosticHandler,
cdlDocumentSymbolProvider, cdlDocumentSymbolProvider,
defDocumentSymbolProvider defDocumentSymbolProvider,
definitionCdlEventHandler
} from "./common/handlers" } from "./common/handlers"
import { registerLogger, traceError, traceLog, traceVerbose } from "./common/log/logging" import { registerLogger, traceError, traceLog, traceVerbose } from "./common/log/logging"
import { import {
@@ -23,7 +24,7 @@ import {
onDidChangePythonInterpreter, onDidChangePythonInterpreter,
resolveInterpreter resolveInterpreter
} from "./common/python" } from "./common/python"
import { restartServer } from "./common/server" import { disposeServerResources, restartServer } from "./common/server"
import { checkIfConfigurationChanged, getInterpreterFromSetting } from "./common/settings" import { checkIfConfigurationChanged, getInterpreterFromSetting } from "./common/settings"
import { loadServerDefaults } from "./common/setup" import { loadServerDefaults } from "./common/setup"
import { getLSClientTraceLevel } from "./common/utilities" import { getLSClientTraceLevel } from "./common/utilities"
@@ -37,6 +38,7 @@ export async function activate(context: vscode.ExtensionContext) {
const serverInfo = loadServerDefaults() const serverInfo = loadServerDefaults()
const serverName = serverInfo.name const serverName = serverInfo.name
const serverId = serverInfo.module const serverId = serverInfo.module
const pythonDebugMode = process.env.USE_DEBUGPY?.toLowerCase() === "true"
// Setup logging // Setup logging
const outputChannel = createOutputChannel(serverName) const outputChannel = createOutputChannel(serverName)
@@ -100,10 +102,15 @@ export async function activate(context: vscode.ExtensionContext) {
return runServerQueue return runServerQueue
} }
if (!pythonDebugMode) {
context.subscriptions.push(
onDidChangePythonInterpreter(async () => {
await runServer()
})
)
}
context.subscriptions.push( context.subscriptions.push(
onDidChangePythonInterpreter(async () => {
await runServer()
}),
onDidChangeConfiguration(async (e: vscode.ConfigurationChangeEvent) => { onDidChangeConfiguration(async (e: vscode.ConfigurationChangeEvent) => {
if (checkIfConfigurationChanged(e, serverId)) { if (checkIfConfigurationChanged(e, serverId)) {
await runServer() await runServer()
@@ -115,6 +122,15 @@ export async function activate(context: vscode.ExtensionContext) {
) )
setImmediate(async () => { 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) const interpreter = getInterpreterFromSetting(serverId)
if (interpreter === undefined || interpreter.length === 0) { if (interpreter === undefined || interpreter.length === 0) {
traceLog(`Python extension loading`) traceLog(`Python extension loading`)
@@ -177,6 +193,16 @@ export async function activate(context: vscode.ExtensionContext) {
) )
context.subscriptions.push(hoverCdlProvider) 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( const formatDefProvider = vscode.languages.registerDocumentFormattingEditProvider(
{ scheme: "file", language: "def" }, { scheme: "file", language: "def" },
{ {
@@ -220,33 +246,73 @@ export async function activate(context: vscode.ExtensionContext) {
const diagnosticCollectionDef = vscode.languages.createDiagnosticCollection("def") const diagnosticCollectionDef = vscode.languages.createDiagnosticCollection("def")
context.subscriptions.push(diagnosticCollectionCdl, diagnosticCollectionDef) context.subscriptions.push(diagnosticCollectionCdl, diagnosticCollectionDef)
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>()
const updateDiagnostics = (document: vscode.TextDocument) => {
if (document.languageId === "cdl") {
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
} else if (document.languageId === "def") {
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
}
}
const scheduleDiagnostics = (document: vscode.TextDocument) => {
const key = document.uri.toString()
const previous = diagnosticTimers.get(key)
if (previous !== undefined) {
clearTimeout(previous)
}
diagnosticTimers.set(
key,
setTimeout(() => {
diagnosticTimers.delete(key)
updateDiagnostics(document)
}, 120)
)
}
// Check if the first line of the CDL file contains "MACHINE" // Check if the first line of the CDL file contains "MACHINE"
context.subscriptions.push( context.subscriptions.push(
vscode.workspace.onDidOpenTextDocument((document) => { vscode.workspace.onDidOpenTextDocument((document) => {
if (document.languageId === "cdl" || document.languageId === "def") { if (document.languageId === "cdl" || document.languageId === "def") {
if (document.languageId === "cdl") { updateDiagnostics(document)
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
} else if (document.languageId === "def") {
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
}
} }
}), }),
vscode.workspace.onDidChangeTextDocument((event) => { vscode.workspace.onDidChangeTextDocument((event) => {
const document = event.document const document = event.document
if (document.languageId === "cdl" || document.languageId === "def") { if (document.languageId === "cdl" || document.languageId === "def") {
if (document.languageId === "cdl") { scheduleDiagnostics(document)
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
} else if (document.languageId === "def") {
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
}
} }
}) }),
vscode.workspace.onDidCloseTextDocument((document) => {
const key = document.uri.toString()
const timer = diagnosticTimers.get(key)
if (timer !== undefined) {
clearTimeout(timer)
diagnosticTimers.delete(key)
}
diagnosticCollectionCdl.delete(document.uri)
diagnosticCollectionDef.delete(document.uri)
}),
{
dispose() {
for (const timer of diagnosticTimers.values()) {
clearTimeout(timer)
}
diagnosticTimers.clear()
}
}
) )
for (const document of vscode.workspace.textDocuments) {
if (document.languageId === "cdl" || document.languageId === "def") {
updateDiagnostics(document)
}
}
} }
export function deactivate(): Thenable<void> | undefined { export function deactivate(): Thenable<void> | undefined {
if (!client) { if (!client) {
disposeServerResources()
return undefined return undefined
} }
return client.stop() return client.stop().finally(disposeServerResources)
} }
+4 -2
View File
@@ -1,19 +1,21 @@
const esbuild = require("esbuild") const esbuild = require("esbuild")
const path = require("path")
const production = process.argv.includes("--production") const production = process.argv.includes("--production")
const watch = process.argv.includes("--watch") const watch = process.argv.includes("--watch")
async function main() { async function main() {
const ctx = await esbuild.context({ const ctx = await esbuild.context({
absWorkingDir: __dirname,
entryPoints: ["client/src/extension.ts"], entryPoints: ["client/src/extension.ts"],
bundle: true, bundle: true,
format: "cjs", format: "cjs",
minify: production, minify: production,
sourcemap: !production, sourcemap: !production,
sourcesContent: false, sourcesContent: !production,
platform: "node", platform: "node",
// outdir: "out", // outdir: "out",
outfile: "./dist/extension.js", outfile: path.join(__dirname, "dist", "extension.js"),
external: ["vscode"], external: ["vscode"],
logLevel: "silent", logLevel: "silent",
plugins: [ plugins: [
+27 -1
View File
@@ -2,7 +2,7 @@
"name": "nx-post-support", "name": "nx-post-support",
"displayName": "NX Postprocessor 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", "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.201", "version": "2026.8.100",
"publisher": "Christoph", "publisher": "Christoph",
"icon": "images/nx-1.png", "icon": "images/nx-1.png",
"extensionDependencies": [ "extensionDependencies": [
@@ -96,6 +96,26 @@
"default": true, "default": true,
"description": "Use the Inlay Hints in from `NX Postprocessor Support`" "description": "Use the Inlay Hints in from `NX Postprocessor Support`"
}, },
"nx-post-support.inlayHints.parameterNames": {
"type": "string",
"default": "all",
"enum": [
"all",
"literals",
"none"
],
"enumDescriptions": [
"Show parameter name hints for all arguments.",
"Show parameter name hints only for literal arguments.",
"Do not show parameter name hints."
],
"description": "Controls which TCL procedure arguments receive parameter name hints."
},
"nx-post-support.inlayHints.suppressWhenArgumentMatchesName": {
"type": "boolean",
"default": true,
"description": "Hide a parameter hint when a variable argument already has the same name, for example `output` in `my_proc $output`."
},
"nx-post-support.importStrategy": { "nx-post-support.importStrategy": {
"default": "useBundled", "default": "useBundled",
"description": "Defines where `NX Postprocessor Support` is imported from.", "description": "Defines where `NX Postprocessor Support` is imported from.",
@@ -120,10 +140,16 @@
"type": "array" "type": "array"
} }
} }
},
"configurationDefaults": {
"[tcl]": {
"editor.inlayHints.maximumLength": 0
}
} }
}, },
"scripts": { "scripts": {
"compile": "node esbuild.js --production", "compile": "node esbuild.js --production",
"compile:debug": "node esbuild.js",
"watch": "node esbuild.js --watch", "watch": "node esbuild.js --watch",
"package": "node esbuild.js --production" "package": "node esbuild.js --production"
}, },
+47 -24
View File
@@ -6,6 +6,7 @@ import os
import pathlib import pathlib
import runpy import runpy
import sys import sys
import time
def update_sys_path(path_to_add: str) -> None: 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) sys.path.append(path_to_add)
# Ensure debugger is loaded before we load anything else, to debug initialization. def _debug_endpoint() -> tuple[str, int]:
debugger_path = os.getenv("DEBUGPY_PATH", None) host = os.getenv("NXPS_DEBUG_HOST", "127.0.0.1")
if debugger_path: raw_port = os.getenv("NXPS_DEBUG_PORT", "5678")
if debugger_path.endswith("debugpy"): 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) debugger_path = os.fspath(pathlib.Path(debugger_path).parent)
update_sys_path(debugger_path) update_sys_path(debugger_path)
@@ -25,25 +59,14 @@ if debugger_path:
# pylint: disable=wrong-import-position,import-error # pylint: disable=wrong-import-position,import-error
import debugpy import debugpy
# 5678 is the default port, If you need to change it update it here host, port = _debug_endpoint()
# and in launch.json. print(f"debugpy: waiting for VS Code at {host}:{port}", file=sys.stderr)
# Connecting requires the "Python debug server" listener (launch.json) to be _connect_debugger(debugpy, host, port)
# up first. If it isn't (e.g. wrong launch config was used), don't crash the print("debugpy: VS Code attached; starting language server", file=sys.stderr)
# 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,
)
# This will ensure that execution is paused as soon as the debugger server_path = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py")
# connects to VS Code. If you don't want to pause here comment this runpy.run_path(server_path, run_name="__main__")
# line and set breakpoints as appropriate.
# debugpy.breakpoint()
SERVER_PATH = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py")
# NOTE: Set breakpoint in `lsp_server.py` before continuing. if __name__ == "__main__":
runpy.run_path(SERVER_PATH, run_name="__main__") main()
+396 -166
View File
@@ -5,15 +5,15 @@
from __future__ import annotations from __future__ import annotations
import json import json
import operator
import os import os
import pathlib import pathlib
import re import re
import sys import sys
import threading import threading
from collections import ChainMap
from typing import Any, Optional
import operator
from functools import reduce from functools import reduce
from typing import Any, Optional
# ********************************************************** # **********************************************************
@@ -40,15 +40,29 @@ update_sys_path(
# pylint: disable=wrong-import-position,import-error # pylint: disable=wrong-import-position,import-error
import lsp_jsonrpc as jsonrpc import lsp_jsonrpc as jsonrpc
import lsprotocol.types as lsp import lsprotocol.types as lsp
from pygls import uris, workspace
from common.load_data import standard_items 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 lsp_tclserver import TclLanguageServer from lsp_tclserver import TclLanguageServer
from pygls import uris, workspace
from pygls.workspace.text_document import TextDocument
from tools.folding_ranges import build_folding_ranges
from tools.inlay_hint import (
InlayHintGenerator,
build_builtin_inlay_signatures,
)
from tools.navigation import (
SymbolIdentity,
definition_identities,
matching_occurrences,
symbol_at_position,
workspace_symbols,
)
from tools.semantic_tokens import (
TOKEN_TYPE_INDEX,
TOKEN_TYPES,
TokenModifier,
_Highlighter,
)
from tools.signature_help import build_signature_help
WORKSPACE_SETTINGS = {} WORKSPACE_SETTINGS = {}
GLOBAL_SETTINGS = {} GLOBAL_SETTINGS = {}
@@ -59,6 +73,29 @@ LSP_SERVER = TclLanguageServer(
name="NX Postprocessor Support", version="0.0.1", max_workers=MAX_WORKERS 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}
STATIC_COMPLETION_ITEMS = tuple(
standard_items.tcl_keyword_list
+ standard_items.nx_procs
+ standard_items.nx_variables
)
STATIC_COMPLETION_KEYS = frozenset(
(item.label, getattr(item, "kind", None)) for item in STATIC_COMPLETION_ITEMS
)
BUILTIN_INLAY_SIGNATURES = build_builtin_inlay_signatures(
standard_items.json_data.get("MOM_procs", [])
)
BUILTIN_HOVER_ITEMS = {}
for _hover_item in (
standard_items.json_data.get("MOM_procs", [])
+ standard_items.json_data.get("mom_variables", [])
):
BUILTIN_HOVER_ITEMS.setdefault(_hover_item.get("label"), _hover_item)
# ********************************************************** # **********************************************************
# Tool specific code goes below this. # Tool specific code goes below this.
# ********************************************************** # **********************************************************
@@ -77,28 +114,100 @@ LSP_SERVER = TclLanguageServer(
def did_open(params: lsp.DidOpenTextDocumentParams) -> None: def did_open(params: lsp.DidOpenTextDocumentParams) -> None:
"""LSP handler for textDocument/didOpen request.""" """LSP handler for textDocument/didOpen request."""
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
LSP_SERVER.compute_diagnostics(document) LSP_SERVER.clear_cache_for_uri(document.uri)
# Also update custom completion and proc docs for this file LSP_SERVER.analyze_document_now(document)
LSP_SERVER.update_poco_completion_for_file(document)
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_SAVE) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_SAVE)
def did_save(params: lsp.DidSaveTextDocumentParams) -> None: def did_save(params: lsp.DidSaveTextDocumentParams) -> None:
"""LSP handler for textDocument/didSave request.""" """LSP handler for textDocument/didSave request."""
_ = LSP_SERVER.workspace.get_text_document(params.text_document.uri) document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
LSP_SERVER.analyze_document_now(document)
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CLOSE) @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.""" """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) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CHANGE)
def did_change(params: lsp.DidChangeTextDocumentParams) -> None: def did_change(params: lsp.DidChangeTextDocumentParams) -> None:
"""LSP handler for textDocument/didChange request""" """LSP handler for textDocument/didChange request"""
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
LSP_SERVER.compute_diagnostics(document) LSP_SERVER.clear_cache_for_uri(document.uri)
LSP_SERVER.update_poco_completion_for_file(document) LSP_SERVER.schedule_document_analysis(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_SERVER.feature(
@@ -111,13 +220,20 @@ def did_change(params: lsp.DidChangeTextDocumentParams) -> None:
) )
def document_diagnostic(params: lsp.DocumentDiagnosticParams): def document_diagnostic(params: lsp.DocumentDiagnosticParams):
"""Return diagnostics for the requested document""" """Return diagnostics for the requested document"""
was_cached = True uri = params.text_document.uri
if (uri := params.text_document.uri) not in LSP_SERVER.diagnostics: doc = LSP_SERVER.workspace.get_text_document(uri)
was_cached = False diagnostic_state = LSP_SERVER.diagnostic_snapshot(uri)
doc = LSP_SERVER.workspace.get_text_document(uri) was_cached = (
diagnostic_state is not None and diagnostic_state[0] == doc.version
)
if not was_cached:
LSP_SERVER.compute_diagnostics(doc) 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}" result_id = f"{uri}@{version}"
if was_cached and result_id == params.previous_result_id: if was_cached and result_id == params.previous_result_id:
@@ -128,23 +244,15 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams):
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION)
def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
from tools.variable_index import build_variable_index
from tools.completion_items import BUILTIN_VAR_LABELS from tools.completion_items import BUILTIN_VAR_LABELS
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
# Base items workspace_items = LSP_SERVER.completion_items_snapshot()
poco = [item for items in LSP_SERVER.poco_completion.values() for item in items]
base_items = (
standard_items.tcl_keyword_list
+ standard_items.nx_procs
+ standard_items.nx_variables
+ poco
)
# Build variable index from current document
tree = LSP_SERVER.get_tree(doc) tree = LSP_SERVER.get_tree(doc)
globals_set, procs_locals, proc_ranges = build_variable_index(doc.source, tree) globals_set, procs_locals, proc_ranges = LSP_SERVER.variable_index_for_document(
doc, tree
)
# Always include globals (excluding built-ins) # Always include globals (excluding built-ins)
dynamic_items = [] dynamic_items = []
@@ -169,19 +277,47 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
) )
break break
# Merge with de-duplication for variables only # Merge with de-duplication. Each file keeps its complete index, so a proc
merged: list[lsp.CompletionItem] = [] # declared in multiple files must only appear once in the completion list.
seen_var_labels: set[str] = set() merged: list[lsp.CompletionItem] = list(STATIC_COMPLETION_ITEMS)
for it in base_items + dynamic_items: seen_items: set[tuple[str, lsp.CompletionItemKind | None]] = set(
if getattr(it, "kind", None) == lsp.CompletionItemKind.Variable: STATIC_COMPLETION_KEYS
if it.label in seen_var_labels: )
continue for it in (*workspace_items, *dynamic_items):
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) merged.append(it)
return lsp.CompletionList(is_incomplete=False, items=merged) 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)
custom_signatures, custom_docs = LSP_SERVER.proc_metadata_snapshot(
document.path
)
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) # @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
# def document_symbols(params: lsp.DocumentSymbolParams): # def document_symbols(params: lsp.DocumentSymbolParams):
# doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) # doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
@@ -201,21 +337,37 @@ def document_symbols(params: lsp.DocumentSymbolParams):
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_INLAY_HINT) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_INLAY_HINT)
def inlay_hints(params: lsp.InlayHintParams): def inlay_hints(params: lsp.InlayHintParams):
if not GLOBAL_SETTINGS.get("inlayHint", False):
return []
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
settings = _get_settings_by_document(document)
if not settings.get("inlayHint", False):
return []
inlay_settings = settings.get("inlayHints", {})
parameter_names = inlay_settings.get("parameterNames", "all")
if parameter_names == "none":
return []
# Reuse cached AST # Reuse cached AST
tree = LSP_SERVER.get_tree(document) tree = LSP_SERVER.get_tree(document)
# Merge proc signatures across files and traverse once # Built-in NX procedures are the fallback. Workspace procedures replace them,
merged_signatures = {} # and a declaration in the current file wins over duplicate workspace names.
for sigs in LSP_SERVER.proc_signatures.values(): custom_signatures = LSP_SERVER.custom_inlay_signatures_snapshot(
merged_signatures.update(sigs) document.path
)
signatures = ChainMap(custom_signatures, BUILTIN_INLAY_SIGNATURES)
generator = InlayHintGenerator(merged_signatures) generator = InlayHintGenerator(
tree.accept(generator, recurse=True) document.source,
return generator.hints signatures,
source_lines=LSP_SERVER.get_lines(document),
requested_range=params.range,
parameter_names=parameter_names,
suppress_when_argument_matches_name=inlay_settings.get(
"suppressWhenArgumentMatchesName", True
),
)
return generator.generate(tree)
@LSP_SERVER.feature( @LSP_SERVER.feature(
@@ -230,7 +382,7 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
data = [] data = []
plugins = [] plugins = []
hl = _Highlighter(plugins, LSP_SERVER.poco_completion) hl = _Highlighter(plugins, LSP_SERVER.custom_function_names_snapshot())
# Reuse cached AST # Reuse cached AST
tree = LSP_SERVER.get_tree(document) tree = LSP_SERVER.get_tree(document)
@@ -243,7 +395,7 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
token.line, token.line,
token.offset, token.offset,
token.length, token.length,
TOKEN_TYPES.index(token.tok_type), TOKEN_TYPE_INDEX[token.tok_type],
reduce(operator.or_, token.tok_modifiers, 0), reduce(operator.or_, token.tok_modifiers, 0),
] ]
) )
@@ -265,14 +417,14 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
col = params.position.character col = params.position.character
try: try:
line = document.lines[pos.line] line = LSP_SERVER.get_lines(document)[pos.line]
except IndexError: except IndexError:
return None return None
# Do not show hover for proc name in its declaration # Do not show hover for proc name in its declaration
from tools.proc_docs import is_proc_declaration_position from tools.proc_docs import is_proc_declaration_line
if is_proc_declaration_position(document.source, pos.line, pos.character): if is_proc_declaration_line(line, pos.character):
return None return None
# Identify the token under the cursor # Identify the token under the cursor
@@ -284,11 +436,7 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
return None return None
# 1) If token is a known MOM proc/variable, return built-in hover # 1) If token is a known MOM proc/variable, return built-in hover
command = token match = BUILTIN_HOVER_ITEMS.get(token)
data = standard_items.json_data
all_items = data.get("MOM_procs", []) + data.get("mom_variables", [])
match = next((item for item in all_items if item["label"] == command), None)
if match and match.get("kind") == "function": if match and match.get("kind") == "function":
label = match.get("label", "") label = match.get("label", "")
parameters = match.get("parameters", []) parameters = match.get("parameters", [])
@@ -321,13 +469,10 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
# 2) Otherwise, check if the token is a custom proc and show its preceding doc block # 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 # Build a merged map of proc -> docs gathered during initialization and updates
proc_docs: dict[str, str] = {} proc_doc = LSP_SERVER.proc_documentation(token, document.path)
for file_docs in LSP_SERVER.proc_docs.values(): if proc_doc is not None:
proc_docs.update(file_docs)
if token in proc_docs:
return lsp.Hover( return lsp.Hover(
lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=proc_docs[token]) lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=proc_doc)
) )
return None return None
@@ -335,73 +480,153 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION)
def goto_definition(params: lsp.DefinitionParams): def goto_definition(params: lsp.DefinitionParams):
"""Provide go-to-definition locations for Tcl procs. """Resolve Tcl proc and variable definitions through the symbol index."""
context = _navigation_context(params.text_document.uri, params.position)
Strategy: if context is None:
- 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:
return None return None
# Identify token under cursor indexes, definitions, _, identity = context
token = None locations = [
for m in re.finditer(r"\b\w+\b", line): lsp.Location(uri=index.uri, range=occurrence.range)
if m.start() <= pos.character <= m.end(): for index, occurrence in matching_occurrences(
token = m.group(0) identity, indexes, definitions
break )
if not token: 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 or LSP_SERVER.index_update_pending(filepath):
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 return None
# Helper to search a single source text for a proc declaration definitions = definition_identities(indexes)
def find_decl_in_source(source_text: str, uri: str) -> Optional[lsp.Location]: result = symbol_at_position(index, position, definitions)
lines = source_text.split("\n") if result is None:
pattern = re.compile(r"^\s*proc\s+" + re.escape(token) + r"\b") return None
for i, ln in enumerate(lines): occurrence, identity = result
m = pattern.match(ln) return indexes, definitions, occurrence, identity
if m:
start_char = ln.find(token)
if start_char < 0: def _sorted_locations(locations: list[lsp.Location]) -> list[lsp.Location]:
start_char = max(m.end() - len(token), 0) return sorted(
start = lsp.Position(i, start_char) locations,
end = lsp.Position(i, start_char + len(token)) key=lambda location: (
return lsp.Location(uri=uri, range=lsp.Range(start=start, end=end)) 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 return None
# 1) Search in current document indexes, definitions, occurrence, identity = context
loc = find_decl_in_source(doc.source, doc.uri) if not _is_renamable(identity, indexes, definitions):
if loc: return None
return loc 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: @LSP_SERVER.feature(
uri = pathlib.Path(fp).as_uri() lsp.TEXT_DOCUMENT_RENAME,
# Try to get from workspace if available; else read from disk lsp.RenameOptions(prepare_provider=True),
try: )
other_doc = LSP_SERVER.workspace.get_text_document(uri) def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None:
source = other_doc.source if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", params.new_name):
except Exception: return None
try:
source = pathlib.Path(fp).read_text(encoding="utf-8")
except Exception:
continue
loc = find_decl_in_source(source, uri)
if loc:
return loc
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 +701,9 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
legend=semantic_tokens_legend, full=True, range=False legend=semantic_tokens_legend, full=True, range=False
), ),
definition_provider=True, definition_provider=True,
references_provider=True,
rename_provider=lsp.RenameOptions(prepare_provider=True),
workspace_symbol_provider=True,
) )
) )
@@ -486,48 +714,43 @@ def initialized(_params: lsp.InitializedParams):
def index_workspace(): def index_workspace():
try: 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...") log_to_output("Background indexing started...")
psc_files = get_all_psc_files(pathlib.Path(root)) root_path = pathlib.Path(root)
for psc_file in psc_files: skipped_directories = {
poco_files = read_psc_file(psc_file) ".git",
for sourced_layer in poco_files: ".nox",
completion.reset() ".venv",
try: "dist",
file_root = pathlib.Path(root).joinpath( "node_modules",
sourced_layer.subfolder if sourced_layer.subfolder else "" "out",
) }
for tcl_file in sourced_layer.files: tcl_files = (
filepath = pathlib.Path(file_root).joinpath( path
f"{tcl_file}.tcl" for path in root_path.rglob("*.tcl")
) if not any(
if not filepath.exists(): part.casefold() in skipped_directories
continue for part in path.relative_to(root_path).parts[:-1]
completion.reset() )
document = LSP_SERVER.workspace.get_text_document( )
filepath.as_uri() for filepath in sorted(tcl_files, key=lambda path: str(path).casefold()):
) try:
tree = LSP_SERVER.parser.parse(document.source) document = TextDocument(
tree.accept(completion, recurse=True) uri=filepath.as_uri(), language_id="tcl"
remove_existing_items( )
completion.custom_functions, LSP_SERVER.poco_completion LSP_SERVER.update_poco_completion_for_file(
) document,
LSP_SERVER.poco_completion[str(filepath)] = ( cache_tree=False,
completion.custom_functions require_file_exists=True,
) )
remove_shared_keys( except Exception as error:
LSP_SERVER.proc_signatures, completion.proc_signatures log_to_output(f"Fehler beim Parsen von {filepath}: {error}")
)
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}")
log_to_output("Background indexing completed.") log_to_output("Background indexing completed.")
except Exception as e: except Exception as e:
log_to_output(f"Background indexing failed: {e}") log_to_output(f"Background indexing failed: {e}")
@@ -556,6 +779,13 @@ def _get_global_defaults():
"showNotifications": GLOBAL_SETTINGS.get("showNotifications", "off"), "showNotifications": GLOBAL_SETTINGS.get("showNotifications", "off"),
"formatter": GLOBAL_SETTINGS.get("formatter", True), "formatter": GLOBAL_SETTINGS.get("formatter", True),
"inlayHint": GLOBAL_SETTINGS.get("inlayHint", True), "inlayHint": GLOBAL_SETTINGS.get("inlayHint", True),
"inlayHints": GLOBAL_SETTINGS.get(
"inlayHints",
{
"parameterNames": "all",
"suppressWhenArgumentMatchesName": True,
},
),
} }
+559 -64
View File
@@ -1,20 +1,26 @@
import logging import logging
import os
import pathlib import pathlib
import threading
from typing import List, Optional, Tuple 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 tools.proc_docs import build_proc_docs
import lsprotocol.types as lsp
from plugins.poco_plugin import commands
from pygls import server, uris
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.inlay_hint import InlayHintSignature, build_custom_inlay_signatures
from tools.navigation import FileSymbolIndex, build_file_symbol_index
from tools.proc_docs import build_proc_docs
from tools.variable_index import ProcRange, build_variable_index
DIAGNOSTIC_SOURCE = "nx-post-support" DIAGNOSTIC_SOURCE = "nx-post-support"
LOGGER = logging.getLogger(__name__)
class TclLanguageServer(server.LanguageServer): class TclLanguageServer(server.LanguageServer):
@@ -27,62 +33,530 @@ class TclLanguageServer(server.LanguageServer):
self.poco_completion: dict = {} self.poco_completion: dict = {}
self.proc_signatures: dict = {} self.proc_signatures: dict = {}
self.proc_docs: dict = {} self.proc_docs: dict = {}
self.navigation_indexes: dict[str, FileSymbolIndex] = {}
self.variable_indexes: dict[
str,
tuple[
int | None,
tuple[set[str], dict[str, set[str]], list[ProcRange]],
],
] = {}
# Cache: (uri, version) -> (tree, violations) # Cache: (uri, version) -> (tree, violations)
self._ast_cache = {} self._ast_cache = {}
self._line_cache: dict[tuple[str, int | None], tuple[str, ...]] = {}
self._parser_lock = threading.RLock()
self._index_lock = threading.RLock()
self._index_tokens: dict[str, int] = {}
self._index_versions: dict[str, int | None] = {}
self._committed_index_versions: dict[str, int | None] = {}
self._next_index_token = 0
self._diagnostic_tokens: dict[str, int] = {}
self._next_diagnostic_token = 0
self._index_generation = 0
self._workspace_completion_cache: tuple[int, tuple] = (-1, ())
self._custom_function_names_cache: tuple[int, frozenset[str]] = (
-1,
frozenset(),
)
self._proc_metadata_cache: dict[
str, tuple[int, dict[str, list[str]], dict[str, str]]
] = {}
self._custom_inlay_cache: dict[
str, tuple[int, dict[str, InlayHintSignature]]
] = {}
self._analysis_lock = threading.RLock()
self._analysis_timers: dict[str, threading.Timer] = {}
self._analysis_tokens: dict[str, int] = {}
self._next_analysis_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): def get_tree(self, document: TextDocument):
key = (document.uri, document.version) key = (document.uri, document.version)
cached = self._ast_cache.get(key) with self._parser_lock:
if cached: cached = self._ast_cache.get(key)
return cached[0] if cached is not None:
# Parse and cache return cached[0]
self.parser.violations = [] tree, violations = self._parse_source(document.source)
tree = self.parser.parse(document.source) self._ast_cache[key] = (tree, violations)
violations = list(self.parser.violations) return tree
self._ast_cache[key] = (tree, violations)
return tree
def get_tree_and_violations(self, document: TextDocument): def get_tree_and_violations(self, document: TextDocument):
key = (document.uri, document.version) key = (document.uri, document.version)
cached = self._ast_cache.get(key) with self._parser_lock:
if cached: cached = self._ast_cache.get(key)
return cached if cached is not None:
# Parse and cache return cached
self.parser.violations = [] tree, violations = self._parse_source(document.source)
tree = self.parser.parse(document.source) self._ast_cache[key] = (tree, violations)
violations = list(self.parser.violations) return tree, violations
self._ast_cache[key] = (tree, violations)
return tree, violations def get_lines(self, document: TextDocument) -> tuple[str, ...]:
"""Return split source lines once per document version."""
key = (document.uri, document.version)
with self._parser_lock:
lines = self._line_cache.get(key)
if lines is None:
lines = tuple(document.source.splitlines())
self._line_cache[key] = lines
return lines
def clear_cache_for_uri(self, uri: str): def clear_cache_for_uri(self, uri: str):
to_delete = [k for k in self._ast_cache.keys() if k[0] == uri] with self._parser_lock:
for k in to_delete: to_delete = [key for key in self._ast_cache if key[0] == uri]
del self._ast_cache[k] for key in to_delete:
del self._ast_cache[key]
for key in list(self._line_cache):
if key[0] == uri:
del self._line_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 _invalidate_workspace_caches_locked(self) -> None:
"""Invalidate request-level aggregates after an index mutation."""
self._index_generation += 1
self._workspace_completion_cache = (-1, ())
self._custom_function_names_cache = (-1, frozenset())
self._proc_metadata_cache.clear()
self._custom_inlay_cache.clear()
def completion_items_snapshot(self) -> tuple:
"""Return de-duplicated workspace completion items, cached by generation."""
with self._index_lock:
generation, items = self._workspace_completion_cache
if generation == self._index_generation:
return items
merged = []
seen = set()
for path_items in self.poco_completion.values():
for item in path_items:
key = (item.label, getattr(item, "kind", None))
if key in seen:
continue
seen.add(key)
merged.append(item)
items = tuple(merged)
self._workspace_completion_cache = (self._index_generation, items)
return items
def custom_function_names_snapshot(self) -> frozenset[str]:
"""Return custom completion labels for semantic highlighting."""
with self._index_lock:
generation, names = self._custom_function_names_cache
if generation == self._index_generation:
return names
names = frozenset(
item.label
for path_items in self.poco_completion.values()
for item in path_items
)
self._custom_function_names_cache = (self._index_generation, names)
return names
def proc_metadata_snapshot(
self, current_path: pathlib.Path | str
) -> tuple[dict[str, list[str]], dict[str, str]]:
"""Return merged proc metadata, preferring declarations in the active file."""
normalized_current = self._normalized_path(current_path)
with self._index_lock:
cached = self._proc_metadata_cache.get(normalized_current)
if cached is not None and cached[0] == self._index_generation:
return cached[1], cached[2]
signatures: dict[str, list[str]] = {}
docs: dict[str, str] = {}
signature_paths = sorted(
self.proc_signatures,
key=lambda path: self._normalized_path(path).casefold(),
)
doc_paths = sorted(
self.proc_docs,
key=lambda path: self._normalized_path(path).casefold(),
)
for path in signature_paths:
if self._normalized_path(path) != normalized_current:
signatures.update(self.proc_signatures[path])
for path in signature_paths:
if self._normalized_path(path) == normalized_current:
signatures.update(self.proc_signatures[path])
for path in doc_paths:
if self._normalized_path(path) != normalized_current:
docs.update(self.proc_docs[path])
for path in doc_paths:
if self._normalized_path(path) == normalized_current:
docs.update(self.proc_docs[path])
cached_value = (self._index_generation, signatures, docs)
self._proc_metadata_cache[normalized_current] = cached_value
return signatures, docs
def proc_documentation(
self, name: str, current_path: pathlib.Path | str
) -> str | None:
_, docs = self.proc_metadata_snapshot(current_path)
return docs.get(name)
def custom_inlay_signatures_snapshot(
self, current_path: pathlib.Path | str
) -> dict[str, InlayHintSignature]:
"""Return custom inlay signatures cached until the workspace index changes."""
normalized_current = self._normalized_path(current_path)
with self._index_lock:
cached = self._custom_inlay_cache.get(normalized_current)
if cached is not None and cached[0] == self._index_generation:
return cached[1]
signatures = build_custom_inlay_signatures(
self.proc_signatures,
self.proc_docs,
self.navigation_indexes,
os.fspath(current_path),
)
self._custom_inlay_cache[normalized_current] = (
self._index_generation,
signatures,
)
return signatures
def variable_index_for_document(
self, document: TextDocument, tree=None
) -> tuple[set[str], dict[str, set[str]], list[ProcRange]]:
"""Return the per-version variable index used by completion requests."""
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
with self._index_lock:
cached = self.variable_indexes.get(filepath)
if cached is not None and cached[0] == document.version:
return cached[1]
if tree is None:
tree = self.get_tree(document)
variable_index = build_variable_index(document.source, tree)
with self._index_lock:
cached = self.variable_indexes.get(filepath)
if (
cached is None
or cached[0] is None
or document.version is None
or cached[0] <= document.version
):
self.variable_indexes[filepath] = (document.version, variable_index)
return variable_index
return cached[1]
def index_is_current(self, document: TextDocument) -> bool:
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
with self._index_lock:
return (
filepath in self._committed_index_versions
and self._committed_index_versions[filepath] == document.version
)
def index_update_pending(self, filepath: pathlib.Path | str) -> bool:
filepath = os.fspath(filepath)
with self._index_lock:
return (
filepath not in self._committed_index_versions
or self._index_versions.get(filepath)
!= self._committed_index_versions[filepath]
)
def _cancel_document_analysis(self, uri: str) -> None:
with self._analysis_lock:
timer = self._analysis_timers.pop(uri, None)
self._analysis_tokens.pop(uri, None)
if timer is not None:
timer.cancel()
def cancel_analysis_under_uri(self, uri: str) -> None:
"""Cancel delayed analysis for a closed/deleted file or folder."""
try:
target = pathlib.Path(uris.to_fs_path(uri))
except (TypeError, ValueError):
self._cancel_document_analysis(uri)
return
with self._analysis_lock:
matching_uris = []
for pending_uri in self._analysis_timers:
try:
pending_path = pathlib.Path(uris.to_fs_path(pending_uri))
except (TypeError, ValueError):
continue
if self._is_same_or_child(pending_path, target):
matching_uris.append(pending_uri)
timers = [self._analysis_timers.pop(key) for key in matching_uris]
for key in matching_uris:
self._analysis_tokens.pop(key, None)
for timer in timers:
timer.cancel()
def _invalidate_document_work(self, document: TextDocument) -> None:
"""Prevent older diagnostic/index work from committing after a new edit."""
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
with self._index_lock:
self._next_diagnostic_token += 1
self._diagnostic_tokens[document.uri] = self._next_diagnostic_token
self._next_index_token += 1
self._index_tokens[filepath] = self._next_index_token
self._index_versions[filepath] = document.version
def schedule_document_analysis(
self, document: TextDocument, delay_seconds: float = 0.15
) -> None:
"""Coalesce rapid edits and analyze only the latest immutable snapshot."""
snapshot = TextDocument(
uri=document.uri,
source=document.source,
version=document.version,
language_id=document.language_id,
)
self._invalidate_document_work(snapshot)
with self._analysis_lock:
previous = self._analysis_timers.pop(snapshot.uri, None)
if previous is not None:
previous.cancel()
self._next_analysis_token += 1
token = self._next_analysis_token
self._analysis_tokens[snapshot.uri] = token
def analyze() -> None:
with self._analysis_lock:
if self._analysis_tokens.get(snapshot.uri) != token:
return
try:
diagnostic_state = self.diagnostic_snapshot(snapshot.uri)
if (
diagnostic_state is None
or diagnostic_state[0] != snapshot.version
):
self.compute_diagnostics(snapshot)
with self._analysis_lock:
if self._analysis_tokens.get(snapshot.uri) != token:
return
if not self.index_is_current(snapshot):
self.update_poco_completion_for_file(snapshot)
except Exception:
LOGGER.exception("Delayed analysis failed for %s", snapshot.uri)
finally:
with self._analysis_lock:
if self._analysis_tokens.get(snapshot.uri) == token:
self._analysis_tokens.pop(snapshot.uri, None)
self._analysis_timers.pop(snapshot.uri, None)
timer = threading.Timer(delay_seconds, analyze)
timer.daemon = True
self._analysis_timers[snapshot.uri] = timer
timer.start()
def analyze_document_now(self, document: TextDocument) -> None:
"""Cancel delayed work and synchronously analyze the current document."""
self._cancel_document_analysis(document.uri)
self._invalidate_document_work(document)
diagnostic_state = self.diagnostic_snapshot(document.uri)
if diagnostic_state is None or diagnostic_state[0] != document.version:
self.compute_diagnostics(document)
if not self.index_is_current(document):
self.update_poco_completion_for_file(document)
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)
self.variable_indexes.pop(filepath, None)
self._committed_index_versions.pop(filepath, None)
self._invalidate_workspace_caches_locked()
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.variable_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."""
self.cancel_analysis_under_uri(uri)
target = pathlib.Path(uris.to_fs_path(uri))
with self._index_lock:
index_changed = False
for store in (
self.poco_completion,
self.proc_signatures,
self.proc_docs,
self.navigation_indexes,
self.variable_indexes,
self._index_tokens,
self._index_versions,
self._committed_index_versions,
):
for path in list(store):
if self._is_same_or_child(path, target):
del store[path]
index_changed = True
if index_changed:
self._invalidate_workspace_caches_locked()
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:
cached_keys = set(self._ast_cache)
cached_keys.update(self._line_cache)
for key in cached_keys:
try:
cached_path = pathlib.Path(uris.to_fs_path(key[0]))
except (TypeError, ValueError):
continue
if self._is_same_or_child(cached_path, target):
self._ast_cache.pop(key, None)
self._line_cache.pop(key, None)
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""" """Update poco_completion for a specific file when it changes"""
filepath = str(pathlib.Path(uris.to_fs_path(document.uri))) 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 collector = CompletionCollector()
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()
try: try:
tree = self.get_tree(document) tree = (
tree.accept(completion, recurse=True) self.get_tree(document)
remove_existing_items(completion.custom_functions, self.poco_completion) if cache_tree
self.poco_completion[filepath] = completion.custom_functions else self.parse_source(document.source)
remove_shared_keys(self.proc_signatures, completion.proc_signatures) )
self.proc_signatures[filepath] = completion.proc_signatures tree.accept(collector, recurse=True)
self.proc_docs[filepath] = build_proc_docs(tree, document.source) docs = build_proc_docs(tree, document.source)
navigation_index = build_file_symbol_index(
filepath, document.uri, tree
)
variable_index = build_variable_index(document.source, tree)
except Exception as e: except Exception as e:
logging.debug(f"Error parsing {filepath}: {e}") LOGGER.debug("Error parsing %s: %s", 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
self.variable_indexes[filepath] = (document.version, variable_index)
self._committed_index_versions[filepath] = document.version
self._invalidate_workspace_caches_locked()
return True
def format( def format(
self, self,
@@ -107,17 +581,19 @@ class TclLanguageServer(server.LanguageServer):
), ),
) )
if range is not None: with self._parser_lock:
start, end = range if range is not None:
return formatter.format_partial(document.source[start:end], self.parser) 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( def linter(
self, self,
document: TextDocument, document: TextDocument,
) -> List[Violation]: ) -> 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(): for checker in checks.get_checkers():
violations += checker.check(document.source, tree) violations += checker.check(document.source, tree)
return violations return violations
@@ -144,8 +620,12 @@ class TclLanguageServer(server.LanguageServer):
for violation in violations: for violation in violations:
message = violation.message message = violation.message
severity = lsp.DiagnosticSeverity.Warning severity = lsp.DiagnosticSeverity.Warning
start = lsp.Position(line=violation.start[0] - 1, character=violation.start[1] - 1) start = lsp.Position(
end = lsp.Position(line=violation.end[0] - 1, character=violation.end[1] - 1) 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( diagnostics.append(
lsp.Diagnostic( lsp.Diagnostic(
@@ -166,12 +646,27 @@ class TclLanguageServer(server.LanguageServer):
return self.lint(document) return self.lint(document)
def compute_diagnostics(self, document: TextDocument): def compute_diagnostics(self, document: TextDocument):
# `None` sentinel ensures that `diagnostics` gets updated if the URI is not with self._index_lock:
# present. self._next_diagnostic_token += 1
_, previous = self.diagnostics.get(document, (0, None)) token = self._next_diagnostic_token
self._diagnostic_tokens[document.uri] = token
diagnostics = self._compute_diagnostics(document) diagnostics = self._compute_diagnostics(document)
# Only update if the list has changed with self._index_lock:
if previous != diagnostics: if self._diagnostic_tokens.get(document.uri) != token:
self.diagnostics[document.uri] = (document.version, diagnostics) 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)
+10 -41
View File
@@ -1,8 +1,9 @@
from tclint.syntax_tree import Visitor, Command, BareWord, List
import lsprotocol.types as lsp import lsprotocol.types as lsp
from common.load_data import standard_items from common.load_data import standard_items
from tclint.syntax_tree import BareWord, Command, List, Visitor
BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables} BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables}
BUILTIN_PROC_LABELS = {ci.label for ci in standard_items.nx_procs}
class CompletionItems: class CompletionItems:
@@ -18,10 +19,11 @@ class CompletionItems:
self._custom_functions.append(value) self._custom_functions.append(value)
class _Completion(Visitor): class CompletionCollector(Visitor):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._custom_functions: list[lsp.CompletionItem] = [] self._custom_functions: list[lsp.CompletionItem] = []
self._custom_function_keys: set[tuple[str, lsp.CompletionItemKind | None]] = set()
self._proc_signatures = {} self._proc_signatures = {}
@property @property
@@ -32,14 +34,13 @@ class _Completion(Visitor):
def proc_signatures(self): def proc_signatures(self):
return self._proc_signatures return self._proc_signatures
def reset(self):
self._custom_functions = []
self._proc_signatures = {}
def _append_unique(self, item: lsp.CompletionItem): def _append_unique(self, item: lsp.CompletionItem):
# Avoid duplicate labels within the same file scan # Avoid duplicate labels within the same file scan
if not any(ci.label == item.label for ci in self._custom_functions): key = (item.label, item.kind)
self._custom_functions.append(item) if key in self._custom_function_keys:
return
self._custom_function_keys.add(key)
self._custom_functions.append(item)
def visit_command(self, command: Command): def visit_command(self, command: Command):
routine = command.routine routine = command.routine
@@ -50,7 +51,7 @@ class _Completion(Visitor):
if not getattr(first_arg, "value", None): if not getattr(first_arg, "value", None):
return return
if any(item.label == first_arg.value for item in standard_items.nx_procs): if first_arg.value in BUILTIN_PROC_LABELS:
return return
# Record proc name as a completion item # Record proc name as a completion item
@@ -90,35 +91,3 @@ class _Completion(Visitor):
clean_name = base_name[2:] # remove leading '::' for completion display clean_name = base_name[2:] # remove leading '::' for completion display
if clean_name not in BUILTIN_VAR_LABELS: if clean_name not in BUILTIN_VAR_LABELS:
self._append_unique(lsp.CompletionItem(label=clean_name, kind=lsp.CompletionItemKind.Variable)) 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()
+252 -16
View File
@@ -1,29 +1,265 @@
from __future__ import annotations
import os
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any
import lsprotocol.types as lsp import lsprotocol.types as lsp
from tclint.syntax_tree import Visitor, Command from tclint.syntax_tree import Command, VarSub, Visitor
from tools.navigation import FileSymbolIndex
@dataclass(frozen=True)
class InlayHintParameter:
name: str
documentation: str | None = None
variadic: bool = False
@dataclass(frozen=True)
class InlayHintSignature:
parameters: tuple[InlayHintParameter, ...]
display_label: str
documentation: str | None = None
location: lsp.Location | None = None
def _normalized_path(path: str) -> str:
return os.path.normcase(os.path.abspath(path))
def _definition_locations(
index: FileSymbolIndex | None,
) -> dict[str, lsp.Location]:
if index is None:
return {}
locations = {}
for occurrence in index.occurrences:
if (
occurrence.is_definition
and occurrence.identity.kind == "proc"
):
locations[occurrence.placeholder] = lsp.Location(
uri=index.uri, range=occurrence.range
)
return locations
def build_custom_inlay_signatures(
signatures_by_path: dict[str, dict[str, list[str]]],
docs_by_path: dict[str, dict[str, str]],
indexes_by_path: dict[str, FileSymbolIndex],
current_path: str,
) -> dict[str, InlayHintSignature]:
"""Merge workspace signatures deterministically, preferring the current file."""
current_normalized = _normalized_path(current_path)
paths = sorted(
signatures_by_path, key=lambda path: _normalized_path(path).casefold()
)
paths.sort(key=lambda path: _normalized_path(path) == current_normalized)
result: dict[str, InlayHintSignature] = {}
for path in paths:
docs = docs_by_path.get(path, {})
definition_locations = _definition_locations(indexes_by_path.get(path))
for proc_name, parameter_names in signatures_by_path[path].items():
parameters = tuple(
InlayHintParameter(
name=parameter_name,
variadic=(
parameter_name == "args"
and parameter_index == len(parameter_names) - 1
),
)
for parameter_index, parameter_name in enumerate(parameter_names)
)
result[proc_name] = InlayHintSignature(
parameters=parameters,
display_label=" ".join([proc_name, *parameter_names]),
documentation=docs.get(proc_name),
location=definition_locations.get(
proc_name.removeprefix("::").rsplit("::", 1)[-1]
),
)
return result
def _is_builtin_variadic(item: dict[str, Any], parameter_name: str, index: int) -> bool:
parameters = item.get("parameters", [])
if index != len(parameters) - 1:
return False
if "..." in parameter_name or "" in parameter_name:
return True
format_label = item.get("format", "")
return (
f"<{parameter_name}>+" in format_label or f"[{parameter_name}]+" in format_label
)
def _builtin_parameter_label(parameter_name: str) -> str:
if "..." not in parameter_name and "" not in parameter_name:
return parameter_name.strip("<>[]")
first_name = parameter_name.split()[0].strip("<>[]")
return re.sub(r"(?:_?1)$", "", first_name) or first_name
def build_builtin_inlay_signatures(
items: list[dict[str, Any]],
) -> dict[str, InlayHintSignature]:
result = {}
for item in items:
proc_name = item.get("label")
if not proc_name:
continue
parameters = tuple(
InlayHintParameter(
name=_builtin_parameter_label(parameter.get("name", "")),
documentation=parameter.get("desc") or None,
variadic=_is_builtin_variadic(
item, parameter.get("name", ""), parameter_index
),
)
for parameter_index, parameter in enumerate(item.get("parameters", []))
if parameter.get("name")
)
result[proc_name] = InlayHintSignature(
parameters=parameters,
display_label=item.get("format") or proc_name,
documentation=item.get("description") or None,
)
return result
def _position_in_range(
position: lsp.Position, requested_range: lsp.Range | None
) -> bool:
if requested_range is None:
return True
value = (position.line, position.character)
start = (requested_range.start.line, requested_range.start.character)
end = (requested_range.end.line, requested_range.end.character)
return start <= value < end
class InlayHintGenerator(Visitor): class InlayHintGenerator(Visitor):
def __init__(self, proc_signatures): def __init__(
self,
source: str,
proc_signatures: Mapping[str, InlayHintSignature],
*,
source_lines: Sequence[str] | None = None,
requested_range: lsp.Range | None = None,
parameter_names: str = "all",
suppress_when_argument_matches_name: bool = True,
):
self.source_lines = (
source_lines if source_lines is not None else source.splitlines()
)
self.proc_signatures = proc_signatures self.proc_signatures = proc_signatures
self.hints = [] self.requested_range = requested_range
self.parameter_names = parameter_names
self.suppress_when_argument_matches_name = suppress_when_argument_matches_name
self.hints: list[lsp.InlayHint] = []
def _node_intersects_requested_range(self, node) -> bool:
if self.requested_range is None:
return True
start = getattr(node, "pos", None)
end = getattr(node, "end_pos", None)
if start is None or end is None:
return True
node_start_line = start[0] - 1
node_end_line = end[0] - 1
return not (
node_end_line < self.requested_range.start.line
or node_start_line > self.requested_range.end.line
)
def generate(self, tree) -> list[lsp.InlayHint]:
"""Walk only syntax-tree branches overlapping the requested editor range."""
self.hints.clear()
def walk(node) -> None:
if not self._node_intersects_requested_range(node):
return
if isinstance(node, Command):
self.visit_command(node)
for child in getattr(node, "children", []):
walk(child)
walk(tree)
return self.hints
def _position(self, line: int, column: int) -> lsp.Position:
line_index = line - 1
character_index = column - 1
if 0 <= line_index < len(self.source_lines):
prefix = self.source_lines[line_index][:character_index]
character_index = len(prefix.encode("utf-16-le")) // 2
return lsp.Position(line=line_index, character=character_index)
@staticmethod
def _parameter_for_argument(
signature: InlayHintSignature, argument_index: int
) -> InlayHintParameter | None:
if argument_index < len(signature.parameters):
return signature.parameters[argument_index]
if signature.parameters and signature.parameters[-1].variadic:
return signature.parameters[-1]
return None
def _should_show(self, argument, parameter: InlayHintParameter) -> bool:
if self.parameter_names == "none":
return False
if self.parameter_names == "literals" and isinstance(argument, VarSub):
return False
if not self.suppress_when_argument_matches_name or not isinstance(
argument, VarSub
):
return True
return getattr(argument, "value", None) != parameter.name
@staticmethod
def _tooltip(signature: InlayHintSignature) -> lsp.MarkupContent:
value = f"`{signature.display_label}`"
if signature.documentation:
value += f"\n\n{signature.documentation}"
return lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=value)
def visit_command(self, command: Command): def visit_command(self, command: Command):
name = getattr(command.routine, "contents", None) name = getattr(command.routine, "contents", None)
if name not in self.proc_signatures: signature = self.proc_signatures.get(name)
if signature is None or self.parameter_names == "none":
return return
param_names = self.proc_signatures[name] for argument_index, argument in enumerate(command.args):
for idx, arg in enumerate(command.args): parameter = self._parameter_for_argument(signature, argument_index)
if idx >= len(param_names): if parameter is None:
break break
param_name = param_names[idx] if not argument.pos or not self._should_show(argument, parameter):
continue
if arg.pos: line, column = argument.pos
line, col = arg.pos position = self._position(line, column)
self.hints.append( if not _position_in_range(position, self.requested_range):
lsp.InlayHint( continue
position=lsp.Position(line=line - 1, character=col - 1),
label=f"{param_name}:", label = lsp.InlayHintLabelPart(
kind=lsp.InlayHintKind.Parameter, value=f"{parameter.name}:",
) tooltip=parameter.documentation,
location=signature.location,
)
self.hints.append(
lsp.InlayHint(
position=position,
label=[label],
kind=lsp.InlayHintKind.Parameter,
tooltip=self._tooltip(signature),
) )
)
+519
View File
@@ -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())
+17 -41
View File
@@ -1,8 +1,9 @@
import re import re
from typing import Dict, List from typing import Dict, List
from tclint.syntax_tree import Visitor, Command from tclint.syntax_tree import Command, Visitor
from tools.parser import CustomParser
PROC_DECLARATION_RE = re.compile(r"^\s*proc\s+([^\s\{]+)")
def _strip_comment_prefix(line: str) -> str: def _strip_comment_prefix(line: str) -> str:
@@ -135,43 +136,18 @@ def build_proc_docs(tree, source_text: str) -> Dict[str, str]:
return extractor.docs return extractor.docs
def is_proc_declaration_position(source_text: str, line_zero_based: int, char_zero_based: int) -> bool: def is_proc_declaration_line(line: str, char_zero_based: int) -> bool:
"""Return True if the position is on the proc name on this source line."""
match = PROC_DECLARATION_RE.match(line)
return bool(match and match.start(1) <= char_zero_based <= match.end(1))
def is_proc_declaration_position(
source_text: str, line_zero_based: int, char_zero_based: int
) -> bool:
"""Return True if the position is on a proc name within its declaration.""" """Return True if the position is on a proc name within its declaration."""
parser = CustomParser() try:
tree = parser.parse(source_text) line = source_text.splitlines()[line_zero_based]
except IndexError:
# Walk commands to find 'proc' declarations and check if position intersects the name arg return False
class _DeclFinder(Visitor): return is_proc_declaration_line(line, char_zero_based)
def __init__(self):
self.is_decl = False
def visit_command(self, command: Command):
if self.is_decl:
return
routine = getattr(command.routine, "contents", None)
if routine != "proc" or not command.args:
return
name_node = command.args[0]
if not hasattr(name_node, "pos"):
return
# Calculate range for the name token
try:
start_line, start_col = name_node.pos
end_line, end_col = getattr(name_node, "end_pos", name_node.pos)
except Exception:
return
if start_line - 1 == line_zero_based:
length = 0
if hasattr(name_node, "value") and name_node.value is not None:
length = len(name_node.value)
elif hasattr(name_node, "contents") and name_node.contents is not None:
length = len(name_node.contents)
if length:
start_c = start_col - 1
end_c = start_c + length
if start_c <= char_zero_based <= end_c:
self.is_decl = True
finder = _DeclFinder()
tree.accept(finder, recurse=True)
return finder.is_decl
+17 -9
View File
@@ -1,17 +1,17 @@
import enum import enum
from typing import List from typing import List
from tclint.syntax_tree import Visitor, QuotedWord, Command, BareWord
from tclint.commands.plugins import PluginManager
import attrs import attrs
from common.load_data import standard_items from common.load_data import standard_items
import lsprotocol.types as lsp from tclint.commands.plugins import PluginManager
from tclint.syntax_tree import BareWord, Command, QuotedWord, Visitor
# Constructing a PluginManager scans entry points, and get_commands() rebuilds # Constructing a PluginManager scans entry points, and get_commands() rebuilds
# the builtin command set on every call. Semantic tokens are requested often, so # the builtin command set on every call. Semantic tokens are requested often, so
# cache the manager and the resolved commands per plugin set. # cache the manager and the resolved commands per plugin set.
_PLUGIN_MANAGER = None _PLUGIN_MANAGER = None
_COMMANDS_CACHE = {} _COMMANDS_CACHE = {}
_STANDARD_PROC_NAMES = frozenset(item.label for item in standard_items.nx_procs)
def _load_commands(plugins): def _load_commands(plugins):
@@ -60,13 +60,23 @@ TOKEN_TYPES = [
"string", "string",
"parameter", "parameter",
] ]
TOKEN_TYPE_INDEX = {}
for _token_index, _token_name in enumerate(TOKEN_TYPES):
TOKEN_TYPE_INDEX.setdefault(_token_name, _token_index)
class _Highlighter(Visitor): class _Highlighter(Visitor):
def __init__(self, plugins, custom_functions: dict[str : list[lsp.CompletionItem]]): def __init__(self, plugins, custom_functions):
self._commands = _load_commands(plugins) self._commands = _load_commands(plugins)
self._tokens = [] self._tokens = []
self.custom_functions = custom_functions if isinstance(custom_functions, dict):
self._custom_function_names = frozenset(
item.label
for items in custom_functions.values()
for item in items
)
else:
self._custom_function_names = frozenset(custom_functions)
def _append_token(self, position, length: int, tok_type: str, modifiers: List[TokenModifier] | None = None): def _append_token(self, position, length: int, tok_type: str, modifiers: List[TokenModifier] | None = None):
if position is None or length <= 0: if position is None or length <= 0:
@@ -129,9 +139,7 @@ class _Highlighter(Visitor):
# Highlight functions (custom or standard) when used as the routine # Highlight functions (custom or standard) when used as the routine
name = getattr(routine, "contents", None) name = getattr(routine, "contents", None)
if name: if name:
in_custom = any(item.label == name for items in self.custom_functions.values() for item in items) if name in self._custom_function_names or name in _STANDARD_PROC_NAMES:
in_standard = any(item.label == name for item in standard_items.nx_procs)
if in_custom or in_standard:
line, col = routine.contents_pos line, col = routine.contents_pos
self._append_token((line - 1, col - 1), len(name), "function", []) self._append_token((line - 1, col - 1), len(name), "function", [])
+173
View File
@@ -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,269 @@
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 lsp_server
import lsprotocol.types as lsp # type: ignore
from lsp_tclserver import TclLanguageServer
from pygls.workspace.text_document import TextDocument
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)
server.get_lines(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)
assert all(key[0] != document.uri for key in server._line_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
def test_rapid_changes_analyze_only_latest_snapshot(tmp_path: Path, monkeypatch):
server = _server()
path = tmp_path / "debounced.tcl"
first = _document(path, "set value 1", version=1)
latest = _document(path, "set value 2", version=2)
calls = []
completed = Event()
monkeypatch.setattr(
server,
"compute_diagnostics",
lambda document: calls.append(("diagnostics", document.version)),
)
def record_index(document):
calls.append(("index", document.version))
completed.set()
return True
monkeypatch.setattr(server, "update_poco_completion_for_file", record_index)
server.schedule_document_analysis(first, delay_seconds=0.05)
server.schedule_document_analysis(latest, delay_seconds=0.05)
assert completed.wait(timeout=2)
assert calls == [("diagnostics", 2), ("index", 2)]
def test_variable_and_workspace_request_caches_are_reused(tmp_path: Path):
server = _server()
document = _document(
tmp_path / "cached.tcl",
"proc cached_proc {argument} { set local_value $argument }",
)
assert server.update_poco_completion_for_file(document)
first_variables = server.variable_index_for_document(document)
second_variables = server.variable_index_for_document(document)
first_completions = server.completion_items_snapshot()
second_completions = server.completion_items_snapshot()
first_names = server.custom_function_names_snapshot()
second_names = server.custom_function_names_snapshot()
assert first_variables is second_variables
assert first_completions is second_completions
assert first_names is second_names
assert "cached_proc" in first_names
@@ -0,0 +1,209 @@
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 tools.inlay_hint import (
InlayHintGenerator,
InlayHintParameter,
InlayHintSignature,
build_builtin_inlay_signatures,
build_custom_inlay_signatures,
)
from tools.navigation import build_file_symbol_index
from tools.parser import CustomParser
def _signature(*names: str, variadic: bool = False) -> InlayHintSignature:
parameters = tuple(
InlayHintParameter(
name=name,
variadic=variadic and index == len(names) - 1,
)
for index, name in enumerate(names)
)
return InlayHintSignature(
parameters=parameters,
display_label=" ".join(["test_proc", *names]),
)
def _generate(
source: str,
signatures: dict[str, InlayHintSignature],
**options,
) -> list[lsp.InlayHint]:
tree = CustomParser().parse(source)
generator = InlayHintGenerator(source, signatures, **options)
return generator.generate(tree)
def _labels(hints: list[lsp.InlayHint]) -> list[str]:
labels = []
for hint in hints:
if isinstance(hint.label, str):
labels.append(hint.label)
else:
labels.append("".join(part.value for part in hint.label))
return labels
def test_many_parameters_are_returned_without_server_side_truncation():
names = tuple(f"parameter_{index}" for index in range(10))
source = "test_proc " + " ".join(str(index) for index in range(10))
hints = _generate(source, {"test_proc": _signature(*names)})
assert _labels(hints) == [f"{name}:" for name in names]
assert all("" not in label and "..." not in label for label in _labels(hints))
def test_only_hints_inside_requested_range_are_returned():
source = "test_proc first\nset spacer 1\ntest_proc second"
requested_range = lsp.Range(
start=lsp.Position(line=2, character=0),
end=lsp.Position(line=3, character=0),
)
hints = _generate(
source,
{"test_proc": _signature("value")},
requested_range=requested_range,
)
assert len(hints) == 1
assert hints[0].position.line == 2
def test_range_walk_keeps_nested_commands_inside_proc_body():
source = "proc wrapper {} {\n test_proc nested\n}"
requested_range = lsp.Range(
start=lsp.Position(line=1, character=0),
end=lsp.Position(line=2, character=0),
)
hints = _generate(
source,
{"test_proc": _signature("value")},
requested_range=requested_range,
)
assert _labels(hints) == ["value:"]
assert hints[0].position.line == 1
def test_matching_variable_name_can_be_suppressed():
source = "test_proc $value $other"
signature = _signature("value", "result")
suppressed = _generate(source, {"test_proc": signature})
visible = _generate(
source,
{"test_proc": signature},
suppress_when_argument_matches_name=False,
)
assert _labels(suppressed) == ["result:"]
assert _labels(visible) == ["value:", "result:"]
def test_literal_mode_hides_variable_argument_hints():
source = 'test_proc $value "literal"'
hints = _generate(
source,
{"test_proc": _signature("first", "second")},
parameter_names="literals",
)
assert _labels(hints) == ["second:"]
def test_variadic_parameter_labels_every_remaining_argument():
hints = _generate(
"test_proc first second third fourth",
{"test_proc": _signature("required", "args", variadic=True)},
)
assert _labels(hints) == ["required:", "args:", "args:", "args:"]
def test_builtin_signature_has_variadic_hints_and_parameter_documentation():
signatures = build_builtin_inlay_signatures(
[
{
"label": "MOM_force",
"description": "Controls address output.",
"format": "MOM_force <mode> <address_1 ... address_n>",
"parameters": [
{"name": "mode", "desc": "Output mode."},
{
"name": "address_1 ... address_n",
"desc": "Output addresses.",
},
],
}
]
)
hints = _generate("MOM_force Always X Y", signatures)
assert _labels(hints) == ["mode:", "address:", "address:"]
assert hints[1].label[0].tooltip == "Output addresses." # type: ignore[index]
assert hints[0].tooltip.value.endswith("Controls address output.") # type: ignore[union-attr]
def test_current_file_signature_and_definition_location_take_priority(tmp_path: Path):
other_path = tmp_path / "other.tcl"
current_path = tmp_path / "current.tcl"
other_source = "proc shared {from_other} { return $from_other }"
current_source = "proc shared {from_current args} { return $from_current }"
parser = CustomParser()
other_tree = parser.parse(other_source)
current_tree = parser.parse(current_source)
indexes = {
str(other_path): build_file_symbol_index(
str(other_path), other_path.as_uri(), other_tree
),
str(current_path): build_file_symbol_index(
str(current_path), current_path.as_uri(), current_tree
),
}
signatures = build_custom_inlay_signatures(
{
str(current_path): {"shared": ["from_current", "args"]},
str(other_path): {"shared": ["from_other"]},
},
{
str(current_path): {"shared": "Current documentation."},
str(other_path): {"shared": "Other documentation."},
},
indexes,
str(current_path),
)
signature = signatures["shared"]
assert [parameter.name for parameter in signature.parameters] == [
"from_current",
"args",
]
assert signature.parameters[-1].variadic
assert signature.documentation == "Current documentation."
assert signature.location is not None
assert signature.location.uri == current_path.as_uri()
def test_positions_use_lsp_utf16_offsets():
source = 'test_proc "😀" second'
hints = _generate(
source,
{"test_proc": _signature("first", "second")},
)
assert hints[1].position.character == source.index("second") + 1
@@ -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
+3
View File
@@ -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 { LIB_GE_command_buffer_edit_replace MOM_end_of_program_LIB END_OF_PROGRAM @END_OF_PROG {
MOM_do_template "end_of_program_rewind" MOM_do_template "end_of_program_rewind"
} EndOfProgramRewind } EndOfProgramRewind
SERVICE_remove_file "test"