Compare commits
10
Commits
2026.6.201
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ccd2d26a | ||
|
|
af195a577b | ||
|
|
ecb50be2b8 | ||
|
|
61d4785775 | ||
|
|
541704f45a | ||
|
|
33cf282b0a | ||
|
|
35a4357551 | ||
|
|
f5bd79f067 | ||
|
|
a39aee1b9d | ||
|
|
4ca1b0cce9 |
Vendored
+35
-12
@@ -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": "",
|
||||||
|
|||||||
Vendored
+57
@@ -0,0 +1,57 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$WorkspaceRoot,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DebugRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function ConvertFrom-ExtendedWindowsPath {
|
||||||
|
param([string]$Path)
|
||||||
|
|
||||||
|
if ($Path.StartsWith("\\?\UNC\", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
return "\\" + $Path.Substring(8)
|
||||||
|
}
|
||||||
|
if ($Path.StartsWith("\\?\", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
return $Path.Substring(4)
|
||||||
|
}
|
||||||
|
return $Path
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspacePath = ConvertFrom-ExtendedWindowsPath $WorkspaceRoot
|
||||||
|
$workspaceItem = Get-Item -LiteralPath $workspacePath
|
||||||
|
if (-not $workspaceItem.PSIsContainer) {
|
||||||
|
throw "Workspace root is not a directory: $workspacePath"
|
||||||
|
}
|
||||||
|
$workspacePath = $workspaceItem.FullName
|
||||||
|
|
||||||
|
$debugPath = ConvertFrom-ExtendedWindowsPath $DebugRoot
|
||||||
|
$tempPath = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd("\")
|
||||||
|
$debugParent = [System.IO.Path]::GetFullPath((Split-Path -Parent $debugPath)).TrimEnd("\")
|
||||||
|
if (-not $debugParent.Equals($tempPath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Debug alias must be located directly below the user temp directory: $debugPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $debugPath) {
|
||||||
|
$debugItem = Get-Item -LiteralPath $debugPath -Force
|
||||||
|
if ($debugItem.LinkType -ne "Junction") {
|
||||||
|
throw "Debug alias exists but is not a junction: $debugPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentTarget = (Get-Item -LiteralPath $debugItem.Target).FullName
|
||||||
|
if (-not $currentTarget.Equals($workspacePath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
# Removing a junction removes only the link, never the target directory.
|
||||||
|
Remove-Item -LiteralPath $debugPath -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $debugPath)) {
|
||||||
|
New-Item -ItemType Junction -Path $debugPath -Target $workspacePath | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "Debug extension path: $debugPath -> $workspacePath"
|
||||||
|
& npm.cmd --prefix $workspacePath run compile:debug
|
||||||
|
exit $LASTEXITCODE
|
||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "NX Post Support: Compile Debug",
|
||||||
|
"type": "process",
|
||||||
|
"command": "powershell.exe",
|
||||||
|
"args": [
|
||||||
|
"-NoLogo",
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-Command",
|
||||||
|
"& { param([string]$WorkspaceRoot, [string]$DebugRoot); $scriptRoot = $WorkspaceRoot; if ($scriptRoot.StartsWith('\\\\?\\')) { $scriptRoot = $scriptRoot.Substring(4) }; & (Join-Path $scriptRoot '.vscode\\prepare-debug.ps1') -WorkspaceRoot $WorkspaceRoot -DebugRoot $DebugRoot; exit $LASTEXITCODE }",
|
||||||
|
"${workspaceFolder}",
|
||||||
|
"${env:TEMP}\\nx-post-support-vscode-debug"
|
||||||
|
],
|
||||||
|
"problemMatcher": [],
|
||||||
|
"presentation": {
|
||||||
|
"reveal": "always",
|
||||||
|
"panel": "dedicated",
|
||||||
|
"clear": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,3 +1,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Generated
+22
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
+50
-18
@@ -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,16 +119,22 @@ 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, {
|
try {
|
||||||
|
const newLSClient = await createServer(
|
||||||
|
workspaceSetting,
|
||||||
|
serverId,
|
||||||
|
serverName,
|
||||||
|
outputChannel,
|
||||||
|
{
|
||||||
settings: await getExtensionSettings(serverId, true),
|
settings: await getExtensionSettings(serverId, true),
|
||||||
globalSettings: await getGlobalSettings(serverId, false)
|
globalSettings: await getGlobalSettings(serverId, false)
|
||||||
})
|
}
|
||||||
|
)
|
||||||
traceInfo(`Server: Start requested.`)
|
traceInfo(`Server: Start requested.`)
|
||||||
_disposables.push(
|
_disposables.push(
|
||||||
newLSClient.onDidChangeState((e) => {
|
newLSClient.onDidChangeState((e) => {
|
||||||
@@ -118,14 +151,13 @@ export async function restartServer(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
try {
|
|
||||||
await newLSClient.start()
|
await newLSClient.start()
|
||||||
} catch (ex) {
|
|
||||||
traceError(`Server: Start failed: ${ex}`)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const level = getLSClientTraceLevel(outputChannel.logLevel, env.logLevel)
|
const level = getLSClientTraceLevel(outputChannel.logLevel, env.logLevel)
|
||||||
await newLSClient.setTrace(level)
|
await newLSClient.setTrace(level)
|
||||||
return newLSClient
|
return newLSClient
|
||||||
|
} catch (ex) {
|
||||||
|
traceError(`Server: Start failed: ${ex}`)
|
||||||
|
disposeServerResources()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+79
-13
@@ -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(
|
context.subscriptions.push(
|
||||||
onDidChangePythonInterpreter(async () => {
|
onDidChangePythonInterpreter(async () => {
|
||||||
await runServer()
|
await runServer()
|
||||||
}),
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.subscriptions.push(
|
||||||
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)
|
||||||
|
|
||||||
// Check if the first line of the CDL file contains "MACHINE"
|
const diagnosticTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
context.subscriptions.push(
|
const updateDiagnostics = (document: vscode.TextDocument) => {
|
||||||
vscode.workspace.onDidOpenTextDocument((document) => {
|
|
||||||
if (document.languageId === "cdl" || document.languageId === "def") {
|
|
||||||
if (document.languageId === "cdl") {
|
if (document.languageId === "cdl") {
|
||||||
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
|
diagnosticCollectionCdl.set(document.uri, diagnosticHandler(document))
|
||||||
} else if (document.languageId === "def") {
|
} else if (document.languageId === "def") {
|
||||||
diagnosticCollectionDef.set(document.uri, diagnosticHandler(document))
|
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"
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.workspace.onDidOpenTextDocument((document) => {
|
||||||
|
if (document.languageId === "cdl" || document.languageId === "def") {
|
||||||
|
updateDiagnostics(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
@@ -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
@@ -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.200",
|
"version": "2026.8.200",
|
||||||
"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
@@ -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()
|
||||||
|
|||||||
+389
-159
@@ -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:
|
|
||||||
was_cached = False
|
|
||||||
doc = LSP_SERVER.workspace.get_text_document(uri)
|
doc = LSP_SERVER.workspace.get_text_document(uri)
|
||||||
|
diagnostic_state = LSP_SERVER.diagnostic_snapshot(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:
|
)
|
||||||
|
for it in (*workspace_items, *dynamic_items):
|
||||||
|
key = (it.label, getattr(it, "kind", None))
|
||||||
|
if key in seen_items:
|
||||||
continue
|
continue
|
||||||
seen_var_labels.add(it.label)
|
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")
|
context = _navigation_context(params.text_document.uri, params.position)
|
||||||
except Exception:
|
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
|
continue
|
||||||
loc = find_decl_in_source(source, uri)
|
seen.add(key)
|
||||||
if loc:
|
changes.setdefault(index.uri, []).append(
|
||||||
return loc
|
lsp.TextEdit(range=occurrence.range, new_text=params.new_name)
|
||||||
|
)
|
||||||
|
|
||||||
return None
|
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
|
|
||||||
log_to_output("Background indexing started...")
|
|
||||||
psc_files = get_all_psc_files(pathlib.Path(root))
|
|
||||||
for psc_file in psc_files:
|
|
||||||
poco_files = read_psc_file(psc_file)
|
|
||||||
for sourced_layer in poco_files:
|
|
||||||
completion.reset()
|
|
||||||
try:
|
try:
|
||||||
file_root = pathlib.Path(root).joinpath(
|
root = LSP_SERVER.workspace.root_path
|
||||||
sourced_layer.subfolder if sourced_layer.subfolder else ""
|
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...")
|
||||||
|
root_path = pathlib.Path(root)
|
||||||
|
skipped_directories = {
|
||||||
|
".git",
|
||||||
|
".nox",
|
||||||
|
".venv",
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
"out",
|
||||||
|
}
|
||||||
|
tcl_files = (
|
||||||
|
path
|
||||||
|
for path in root_path.rglob("*.tcl")
|
||||||
|
if not any(
|
||||||
|
part.casefold() in skipped_directories
|
||||||
|
for part in path.relative_to(root_path).parts[:-1]
|
||||||
)
|
)
|
||||||
for tcl_file in sourced_layer.files:
|
|
||||||
filepath = pathlib.Path(file_root).joinpath(
|
|
||||||
f"{tcl_file}.tcl"
|
|
||||||
)
|
)
|
||||||
if not filepath.exists():
|
for filepath in sorted(tcl_files, key=lambda path: str(path).casefold()):
|
||||||
continue
|
try:
|
||||||
completion.reset()
|
document = TextDocument(
|
||||||
document = LSP_SERVER.workspace.get_text_document(
|
uri=filepath.as_uri(), language_id="tcl"
|
||||||
filepath.as_uri()
|
|
||||||
)
|
)
|
||||||
tree = LSP_SERVER.parser.parse(document.source)
|
LSP_SERVER.update_poco_completion_for_file(
|
||||||
tree.accept(completion, recurse=True)
|
document,
|
||||||
remove_existing_items(
|
cache_tree=False,
|
||||||
completion.custom_functions, LSP_SERVER.poco_completion
|
require_file_exists=True,
|
||||||
)
|
)
|
||||||
LSP_SERVER.poco_completion[str(filepath)] = (
|
except Exception as error:
|
||||||
completion.custom_functions
|
log_to_output(f"Fehler beim Parsen von {filepath}: {error}")
|
||||||
)
|
|
||||||
remove_shared_keys(
|
|
||||||
LSP_SERVER.proc_signatures, completion.proc_signatures
|
|
||||||
)
|
|
||||||
LSP_SERVER.proc_signatures[str(filepath)] = (
|
|
||||||
completion.proc_signatures
|
|
||||||
)
|
|
||||||
from tools.proc_docs import build_proc_docs
|
|
||||||
|
|
||||||
LSP_SERVER.proc_docs[str(filepath)] = build_proc_docs(
|
|
||||||
tree, document.source
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
log_to_output(f"Fehler beim Parsen von {filepath}: {e}")
|
|
||||||
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,
|
||||||
|
},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+547
-52
@@ -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)
|
||||||
|
with self._parser_lock:
|
||||||
cached = self._ast_cache.get(key)
|
cached = self._ast_cache.get(key)
|
||||||
if cached:
|
if cached is not None:
|
||||||
return cached[0]
|
return cached[0]
|
||||||
# Parse and cache
|
tree, violations = self._parse_source(document.source)
|
||||||
self.parser.violations = []
|
|
||||||
tree = self.parser.parse(document.source)
|
|
||||||
violations = list(self.parser.violations)
|
|
||||||
self._ast_cache[key] = (tree, violations)
|
self._ast_cache[key] = (tree, violations)
|
||||||
return tree
|
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)
|
||||||
|
with self._parser_lock:
|
||||||
cached = self._ast_cache.get(key)
|
cached = self._ast_cache.get(key)
|
||||||
if cached:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
# Parse and cache
|
tree, violations = self._parse_source(document.source)
|
||||||
self.parser.violations = []
|
|
||||||
tree = self.parser.parse(document.source)
|
|
||||||
violations = list(self.parser.violations)
|
|
||||||
self._ast_cache[key] = (tree, violations)
|
self._ast_cache[key] = (tree, violations)
|
||||||
return tree, violations
|
return tree, violations
|
||||||
|
|
||||||
def clear_cache_for_uri(self, uri: str):
|
def get_lines(self, document: TextDocument) -> tuple[str, ...]:
|
||||||
to_delete = [k for k in self._ast_cache.keys() if k[0] == uri]
|
"""Return split source lines once per document version."""
|
||||||
for k in to_delete:
|
key = (document.uri, document.version)
|
||||||
del self._ast_cache[k]
|
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 update_poco_completion_for_file(self, document: TextDocument):
|
def clear_cache_for_uri(self, uri: str):
|
||||||
|
with self._parser_lock:
|
||||||
|
to_delete = [key for key in self._ast_cache if key[0] == uri]
|
||||||
|
for key in to_delete:
|
||||||
|
del self._ast_cache[key]
|
||||||
|
for key in list(self._line_cache):
|
||||||
|
if key[0] == uri:
|
||||||
|
del self._line_cache[key]
|
||||||
|
|
||||||
|
@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,6 +581,7 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with self._parser_lock:
|
||||||
if range is not None:
|
if range is not None:
|
||||||
start, end = range
|
start, end = range
|
||||||
return formatter.format_partial(document.source[start:end], self.parser)
|
return formatter.format_partial(document.source[start:end], self.parser)
|
||||||
@@ -117,7 +592,8 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
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:
|
||||||
|
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)
|
self.diagnostics[document.uri] = (document.version, diagnostics)
|
||||||
|
|||||||
@@ -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,13 +34,12 @@ 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)
|
||||||
|
if key in self._custom_function_keys:
|
||||||
|
return
|
||||||
|
self._custom_function_keys.add(key)
|
||||||
self._custom_functions.append(item)
|
self._custom_functions.append(item)
|
||||||
|
|
||||||
def visit_command(self, command: Command):
|
def visit_command(self, command: Command):
|
||||||
@@ -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()
|
|
||||||
|
|||||||
+248
-12
@@ -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)
|
||||||
|
if not _position_in_range(position, self.requested_range):
|
||||||
|
continue
|
||||||
|
|
||||||
|
label = lsp.InlayHintLabelPart(
|
||||||
|
value=f"{parameter.name}:",
|
||||||
|
tooltip=parameter.documentation,
|
||||||
|
location=signature.location,
|
||||||
|
)
|
||||||
self.hints.append(
|
self.hints.append(
|
||||||
lsp.InlayHint(
|
lsp.InlayHint(
|
||||||
position=lsp.Position(line=line - 1, character=col - 1),
|
position=position,
|
||||||
label=f"{param_name}:",
|
label=[label],
|
||||||
kind=lsp.InlayHintKind.Parameter,
|
kind=lsp.InlayHintKind.Parameter,
|
||||||
|
tooltip=self._tooltip(signature),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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()
|
|
||||||
tree = parser.parse(source_text)
|
|
||||||
|
|
||||||
# Walk commands to find 'proc' declarations and check if position intersects the name arg
|
|
||||||
class _DeclFinder(Visitor):
|
|
||||||
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:
|
try:
|
||||||
start_line, start_col = name_node.pos
|
line = source_text.splitlines()[line_zero_based]
|
||||||
end_line, end_col = getattr(name_node, "end_pos", name_node.pos)
|
except IndexError:
|
||||||
except Exception:
|
return False
|
||||||
return
|
return is_proc_declaration_line(line, char_zero_based)
|
||||||
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
|
|
||||||
|
|||||||
@@ -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", [])
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user