Compare commits

..
62 Commits
Author SHA1 Message Date
Christoph 068cf2d1c3 Update README.md
/ build_and_publish (release) Successful in 42s
2025-08-15 07:33:26 +00:00
Christoph ad18a3dcf3 Update README.md 2025-08-15 07:32:58 +00:00
Christoph Brandau bd4b17825c add: Outline support for DEF and CDL files 2025-08-15 07:29:01 +02:00
Christoph Brandau 5d6182e91f double value MOM_add_to_block_buffer rename to MOM_add_to_line_buffer 2025-08-15 07:18:38 +02:00
Christoph Brandau 63e9c429bd update ReadMe 2025-08-14 07:57:40 +02:00
Christoph 7fdafe484e Update version to 2025.8.300 2025-08-12 11:26:06 +00:00
Christoph Brandau 5cba6763f1 Merge branch 'new_features'
/ build_and_publish (release) Successful in 36s
2025-08-12 13:21:27 +02:00
Christoph Brandau e20bb85ac6 fix: wrong highligthing 2025-08-12 13:18:08 +02:00
Christoph 98a0b2197d Update version to 2025.8.202 2025-08-12 09:38:34 +00:00
Christoph Brandau 579847b56d fix formatter
/ build_and_publish (release) Successful in 33s
2025-08-12 11:35:54 +02:00
Christoph Brandau 618d6b478b fix: formatter when \ was deleting 2025-08-12 11:28:10 +02:00
Christoph Brandau 480978d6bb Merge branch 'new_features' of https://git.cbsk-tech.de/Christoph/nx_post_support into new_features
/ build_and_publish (release) Failing after 31s
2025-08-12 11:27:20 +02:00
Christoph Brandau 04798328bd fix: formatter 2025-08-12 11:27:14 +02:00
Christoph Brandau ae835089b6 fix: formatter when \ was deleting 2025-08-12 11:26:52 +02:00
Christoph 0ed1e887fc Update version to 2025.9.100 2025-08-12 07:21:23 +00:00
Christoph Brandau 12b7b62aa5 add variable indexing keep track of postion
/ build_and_publish (release) Successful in 39s
2025-08-12 09:07:04 +02:00
Christoph Brandau 7263b6f530 optimze Server 2025-08-12 07:48:05 +02:00
Christoph Brandau c0635e48ea Merge branch 'new_features' of https://git.cbsk-tech.de/Christoph/nx_post_support into new_features 2025-08-12 07:15:21 +02:00
Christoph Brandau 8fb98d2f40 refacxtor the code 2025-08-12 07:15:18 +02:00
Christoph f24b280edf mod: Keywords 2025-08-11 22:06:49 +02:00
Christoph b61cff3b3b Update version to 2025.9.4 2025-08-11 19:46:20 +00:00
Christoph cf62044d3e Update version to 2025.8.201 2025-08-11 19:37:32 +00:00
Christoph feba88212f fix: some highlight error
/ build_and_publish (release) Successful in 29s
2025-08-11 21:36:04 +02:00
Christoph aeebb5c964 fix: some highlight error
/ build_and_publish (release) Successful in 38s
2025-08-11 21:35:14 +02:00
Christoph b141818251 Update version to 2025.9.3 2025-08-11 16:41:00 +00:00
Christoph Brandau 72f894b05d add goto
/ build_and_publish (release) Successful in 31s
2025-08-11 18:40:06 +02:00
Christoph Brandau 0a26cb5c0f add outline to lsp 2025-08-11 18:28:23 +02:00
Christoph b3cac5c292 Update version to 2025.9.2 2025-08-11 15:20:46 +00:00
Christoph Brandau ef558c3aa3 update pipline
/ build_and_publish (release) Successful in 28s
2025-08-11 17:19:47 +02:00
Christoph Brandau 9d5ef23162 update pipline
/ build_and_publish (release) Failing after 26s
2025-08-11 17:16:27 +02:00
Christoph f659db1147 Update package.json 2025-08-11 15:12:55 +00:00
Christoph Brandau c25bf79427 update pipline
/ build_and_publish (release) Failing after 32s
2025-08-11 17:00:14 +02:00
Christoph b059a75205 Update version to 2025.9.001 2025-08-11 14:24:17 +00:00
Christoph a90e15514b Update .gitea/workflows/build_and_puplish.yml
/ build_and_publish (release) Failing after 31s
2025-08-11 14:23:08 +00:00
Christoph Brandau 2c2579319d update hover feature
/ build_and_publish (release) Failing after 27s
2025-08-11 16:20:14 +02:00
Christoph Brandau b38fab1428 Merge branch 'new_features' of https://git.cbsk-tech.de/Christoph/nx_post_support into new_features 2025-08-11 16:05:58 +02:00
Christoph Brandau bc745622b9 add first proc doc 2025-08-11 15:59:09 +02:00
Christoph Brandau 0bb4ef731e add first proc doc 2025-08-11 10:15:58 +02:00
Christoph 27e2b3bf6e Merge pull request 'update' (#25) from tcl_language_support into main
/ build_and_publish (release) Successful in 33s
Reviewed-on: #25
2025-08-09 21:35:09 +00:00
Christoph f55c3ba904 Update version to 2025.8.200 2025-08-09 12:09:07 +00:00
Christoph cb430a5553 Update version to 2025.8.2
/ build_and_publish (release) Successful in 33s
2025-08-09 12:06:50 +00:00
Christoph 637a28ad82 Update version to 2025.08.1
/ build_and_publish (release) Successful in 43s
2025-08-09 11:57:08 +00:00
Christoph 499df9b51d Update README.md
/ build_and_publish (release) Successful in 35s
2025-08-09 11:55:59 +00:00
Christoph Brandau 31ccae9a40 update 2025-08-08 09:18:22 +02:00
Christoph 9b373e85b7 Update version to 2025.08.101 2025-08-07 05:35:45 +00:00
Christoph 0c5f4ea6fb Merge pull request 'tcl_language_support' (#24) from tcl_language_support into main
/ build_and_publish (release) Successful in 47s
Reviewed-on: #24
2025-08-07 05:34:22 +00:00
Christoph Brandau 793b35f39f Merge branch 'tcl_language_support' of https://git.cbsk-tech.de/Christoph/nx_post_support into tcl_language_support 2025-08-07 07:27:09 +02:00
Christoph Brandau 3780fc27dc mod Readme 2025-08-07 07:27:08 +02:00
Christoph 6541830ef6 Update version to 2025.8.100 2025-08-06 20:10:08 +00:00
Christoph 509b13e99f add icon
/ build_and_publish (release) Successful in 46s
2025-08-06 22:08:39 +02:00
Christoph 396cfdc4ec Update version to 0.5.0 2025-08-06 18:22:29 +00:00
Christoph ccd22e4343 Update .gitea/workflows/build_and_puplish.yml
/ build_and_publish (release) Successful in 41s
2025-08-06 18:21:06 +00:00
Christoph 7d83ae1012 Update package.json 2025-08-06 18:19:22 +00:00
Christoph 029eaebd57 Update version to 0.5.0-beta.1 2025-08-06 18:13:14 +00:00
Christoph 2a2db8f217 Merge pull request 'tcl_language_support' (#23) from tcl_language_support into main
/ build_and_publish (push) Failing after 34s
Reviewed-on: #23
2025-08-06 18:09:48 +00:00
Christoph 8be9aae05d update pipline 2025-08-06 20:09:15 +02:00
Christoph Brandau 6bded783a1 add functions to completion 2025-08-06 15:26:53 +02:00
Christoph f5c61267e3 read all procs from psc file 2025-08-05 21:45:22 +02:00
Christoph Brandau b680c01989 update file sourcing 2025-08-05 18:18:17 +02:00
Christoph 1abf89fe56 load all files for completion 2025-08-04 21:11:20 +02:00
Christoph Brandau 6a051a6dee del test file 2025-08-04 16:06:50 +02:00
Christoph d22d897d23 Update version to 0.4.14 2025-08-04 12:52:21 +00:00
26 changed files with 1859 additions and 532 deletions
+32 -7
View File
@@ -1,13 +1,15 @@
on:
push:
tags:
- "*"
release:
types: [published]
jobs:
build_and_publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- name: Checkout branch from release
uses: actions/checkout@v4
with:
ref: ${{ github.event.release.target_commitish }}
fetch-depth: 0
- name: GitHub Tag Name example
run: |
echo "Tag name from GITHUB_REF_NAME: $GITHUB_REF_NAME"
@@ -33,9 +35,32 @@ jobs:
git config user.name "${{ vars.USERNAME_GIT }}"
git config user.email "${{ vars.EMAIL_GIT }}"
git add package.json
git commit -m "Update version to ${{ github.ref_name }}"
git push origin HEAD:main
- name: Publish to Visual Studio Marketplace
git commit -m "Update version to ${{ github.ref_name }}" || echo "No changes to commit."
# Get current branch; if detached, resolve to a remote branch that contains this commit
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [[ "$BRANCH" == "HEAD" || -z "$BRANCH" ]]; then
# If target_commitish is a branch, use it; if its a SHA, find a branch that contains it
CANDIDATE='${{ github.event.release.target_commitish }}'
if [[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]]; then
BRANCH="$(git branch -r --contains "$GITHUB_SHA" | sed -n 's|.*origin/||p' | head -n1)"
else
BRANCH="$CANDIDATE"
fi
fi
if [[ -z "$BRANCH" ]]; then
echo "Could not determine branch for push."; git branch -a; exit 1
fi
git push origin HEAD:refs/heads/$BRANCH
- name: Publish to Visual Studio Marketplace (Pre-Release)
if: ${{ github.event.release.prerelease }}
run: vsce publish --pre-release
env:
VSCE_PAT: ${{ secrets.VSCODE_MARKETPALCE }}
- name: Publish to Visual Studio Marketplace (Stable)
if: ${{ !github.event.release.prerelease }}
run: vsce publish
env:
VSCE_PAT: ${{ secrets.VSCODE_MARKETPALCE }}
+2 -1
View File
@@ -2,6 +2,7 @@
"ruff.configuration": {
"lint": {
"extend-ignore": ["F821", "E402"]
}
},
"line-length": 200
}
}
+49
View File
@@ -0,0 +1,49 @@
# NX Postprocessor Support
A comprehensive VS Code extension providing language support for NX CAM postprocessor development, including CDL, TCL, and DEF files.
## Features
- **Syntax Highlighting** - Full syntax highlighting for CDL, TCL, and DEF files
- **Code Formatting** - Automatic code formatting for all supported file types
- **Multi-language Support** - Supports NX CDL, TCL, and DEF file formats
- **Intelligent Code Analysis** - Linting and error detection for postprocessor code
- **Auto-completion** - Context-aware code completion for faster development
## Supported File Types
- `.cdl` - NX User Defined Event File
- `.tcl` - NX TCL postprocessor files
- `.def` - NX Definition files
## Installation
1. Install from the VS Code Marketplace
2. Open any `.cdl`, `.tcl`, or `.def` file
3. The extension will automatically activate and provide language support
## Configuration
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.formatter` - Enable/disable the TCL formatter (default: false)
- `nx-post-support.inlayHint` - Enable/disable inlay Hints (default: true)
## Usage
Simply open any supported file type and enjoy:
- Syntax highlighting
- Error detection and linting
- Code completion
- Code formatting (Format Document command)
- Hover information
## Contributing
This extension is actively maintained. For issues or feature requests, please visit our [repository](https://git.cbsk-tech.de/Christoph/nx_post_support.git).
## License
MIT License - see LICENSE file for details.
+177
View File
@@ -271,3 +271,180 @@ export function tclDocumentSymbolProvider(document: vscode.TextDocument): vscode
return collectSymbols(rootScope)
}
export function cdlDocumentSymbolProvider(document: vscode.TextDocument): vscode.DocumentSymbol[] {
const lines = document.getText().split("\n")
interface Scope {
symbol: vscode.DocumentSymbol
startLine: number
}
const root = new vscode.DocumentSymbol(
"root",
"",
vscode.SymbolKind.Namespace,
new vscode.Range(0, 0, lines.length, 0),
new vscode.Range(0, 0, 0, 0)
)
root.children = []
const stack: Scope[] = [{ symbol: root, startLine: 0 }]
let pending: { name: string; kind: vscode.SymbolKind; startLine: number } | null = null
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const trimmed = line.trim()
const createSymbol = () => {
if (!pending) {
return
}
const startRange = new vscode.Range(
pending.startLine,
0,
pending.startLine,
lines[pending.startLine].length
)
const symbol = new vscode.DocumentSymbol(
pending.name,
"",
pending.kind,
startRange,
startRange
)
symbol.children = []
stack[stack.length - 1].symbol.children?.push(symbol)
stack.push({ symbol, startLine: pending.startLine })
pending = null
}
if (pending && trimmed.includes("{")) {
createSymbol()
}
if (trimmed.startsWith("}")) {
const scope = stack.pop()
if (scope) {
scope.symbol.range = new vscode.Range(scope.startLine, 0, i, line.length)
}
continue
}
let match
if ((match = /^\s*EVENT\s+(\w+)/.exec(line))) {
pending = {
name: match[1],
kind: vscode.SymbolKind.Event,
startLine: i
}
if (line.includes("{")) {
createSymbol()
}
} else if ((match = /^\s*PARAM\s+(\w+)/.exec(line))) {
pending = {
name: match[1],
kind: vscode.SymbolKind.Field,
startLine: i
}
if (line.includes("{")) {
createSymbol()
}
}
}
return root.children || []
}
export function defDocumentSymbolProvider(document: vscode.TextDocument): vscode.DocumentSymbol[] {
const lines = document.getText().split("\n")
interface Scope {
symbol: vscode.DocumentSymbol
startLine: number
}
const root = new vscode.DocumentSymbol(
"root",
"",
vscode.SymbolKind.Namespace,
new vscode.Range(0, 0, lines.length, 0),
new vscode.Range(0, 0, 0, 0)
)
root.children = []
const stack: Scope[] = [{ symbol: root, startLine: 0 }]
let pending: { name: string; kind: vscode.SymbolKind; startLine: number } | null = null
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const trimmed = line.trim()
const createSymbol = () => {
if (!pending) {
return
}
const startRange = new vscode.Range(
pending.startLine,
0,
pending.startLine,
lines[pending.startLine].length
)
const symbol = new vscode.DocumentSymbol(
pending.name,
"",
pending.kind,
startRange,
startRange
)
symbol.children = []
stack[stack.length - 1].symbol.children?.push(symbol)
stack.push({ symbol, startLine: pending.startLine })
pending = null
}
if (pending && trimmed.includes("{")) {
createSymbol()
}
if (trimmed.startsWith("}")) {
const scope = stack.pop()
if (scope) {
scope.symbol.range = new vscode.Range(scope.startLine, 0, i, line.length)
}
continue
}
let match
if ((match = /^\s*FORMATTING\b/.exec(line))) {
pending = {
name: "FORMATTING",
kind: vscode.SymbolKind.Module,
startLine: i
}
if (line.includes("{")) {
createSymbol()
}
} else if ((match = /^\s*ADDRESS\s+(\w+)/.exec(line))) {
pending = {
name: match[1],
kind: vscode.SymbolKind.Field,
startLine: i
}
if (line.includes("{")) {
createSymbol()
}
} else if ((match = /^\s*BLOCK_TEMPLATE\s+(\w+)/.exec(line))) {
pending = {
name: match[1],
kind: vscode.SymbolKind.Function,
startLine: i
}
if (line.includes("{")) {
createSymbol()
}
}
}
return root.children || []
}
+6 -3
View File
@@ -79,7 +79,8 @@ export async function getWorkspaceSettings(
interpreter: resolveVariables(interpreter, workspace),
importStrategy: config.get<string>(`importStrategy`) ?? "useBundled",
showNotifications: config.get<string>(`showNotifications`) ?? "off",
formatter: config.get<boolean>(`formatter`) ?? true
formatter: config.get<boolean>(`formatter`) ?? true,
inlayHint: config.get<boolean>(`inlayHint`) ?? true
}
return workspaceSetting
}
@@ -111,7 +112,8 @@ export async function getGlobalSettings(
interpreter: interpreter,
importStrategy: getGlobalValue<string>(config, "importStrategy", "useBundled"),
showNotifications: getGlobalValue<string>(config, "showNotifications", "off"),
formatter: config.get<boolean>(`formatter`) ?? true
formatter: config.get<boolean>(`formatter`) ?? true,
inlayHint: config.get<boolean>(`inlayHint`) ?? true
}
return setting
}
@@ -126,7 +128,8 @@ export function checkIfConfigurationChanged(
`${namespace}.interpreter`,
`${namespace}.importStrategy`,
`${namespace}.showNotifications`,
`${namespace}.formatter`
`${namespace}.formatter`,
`${namespace}.inlayHint`
]
const changed = settings.map((s) => e.affectsConfiguration(s))
return changed.includes(true)
+21 -5
View File
@@ -12,7 +12,8 @@ import {
formatDefFile,
isFirstLineMachine,
diagnosticHandler,
tclDocumentSymbolProvider
cdlDocumentSymbolProvider,
defDocumentSymbolProvider
} from "./common/handlers"
import { registerLogger, traceError, traceLog, traceVerbose } from "./common/log/logging"
import {
@@ -179,11 +180,26 @@ export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(formatDefProvider)
const tclOutlineProvider = vscode.languages.registerDocumentSymbolProvider(
{ scheme: "file", language: "tcl" },
{ provideDocumentSymbols: tclDocumentSymbolProvider }
const cdlSymbolProvider = vscode.languages.registerDocumentSymbolProvider(
{ scheme: "file", language: "cdl" },
{
provideDocumentSymbols(document: vscode.TextDocument) {
return cdlDocumentSymbolProvider(document)
}
}
)
context.subscriptions.push(tclOutlineProvider)
context.subscriptions.push(cdlSymbolProvider)
// Outline now provided by the language server (Document Symbols). Removing TS provider.
const defSymbolProvider = vscode.languages.registerDocumentSymbolProvider(
{ scheme: "file", language: "def" },
{
provideDocumentSymbols(document: vscode.TextDocument) {
return defDocumentSymbolProvider(document)
}
}
)
context.subscriptions.push(defSymbolProvider)
// Diagnostics collection
const diagnosticCollectionCdl = vscode.languages.createDiagnosticCollection("cdl")
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+11 -2
View File
@@ -2,8 +2,9 @@
"name": "nx-post-support",
"displayName": "NX Postprocessor Support",
"description": "",
"version": "0.4.13",
"version": "2025.8.300",
"publisher": "Christoph",
"icon": "images/nx-1.png",
"serverInfo": {
"name": "NX Postprocessor Support",
"module": "nx-post-support"
@@ -16,7 +17,10 @@
"keywords": [
"cdl",
"NX CDL",
"UDE"
"UDE",
"Postprocessor",
"NX CAM",
"Siemens NX"
],
"engines": {
"vscode": "^1.96.0"
@@ -84,6 +88,11 @@
"default": false,
"description": "Use the TCL formatter from `NX Postprocessor Support`"
},
"nx-post-support.inlayHint": {
"type": "boolean",
"default": true,
"description": "Use the Inlay Hints in from `NX Postprocessor Support`"
},
"nx-post-support.importStrategy": {
"default": "useBundled",
"description": "Defines where `NX Postprocessor Support` is imported from.",
+1 -1
View File
@@ -32,7 +32,7 @@ if debugger_path:
# This will ensure that execution is paused as soon as the debugger
# connects to VS Code. If you don't want to pause here comment this
# line and set breakpoints as appropriate.
debugpy.breakpoint()
# debugpy.breakpoint()
SERVER_PATH = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py")
# NOTE: Set breakpoint in `lsp_server.py` before continuing.
+5 -1
View File
@@ -71,6 +71,10 @@
{
"label": "expr",
"kind": "keyword"
},
{
"label": "return",
"kind": "keyword"
}
],
"MOM_procs": [
@@ -188,7 +192,7 @@
]
},
{
"label": "MOM_add_to_block_buffer",
"label": "MOM_add_to_line_buffer",
"kind": "function",
"description": "Depending on the <start|end> attribute specified, this extension will add <value(s)> to either the line's start buffer or the line's end buffer. Each time that this extension is called, it adds to the specified buffer. When the contents of the output buffer are sent to the output file, the contents of the line's start buffer will precede it on the line and the contents of the end buffer will go on the same line after it. The line start buffer, line end buffer, and output buffer are all cleared once they have been written to the output file.",
"format": "MOM_add_to_line_buffer <start|end> <value>+",
+231 -188
View File
@@ -9,7 +9,9 @@ import os
import pathlib
import re
import sys
from typing import Any, List, Optional, Tuple
import threading
from typing import Any, Optional
import operator
from functools import reduce
@@ -38,142 +40,26 @@ update_sys_path(
# pylint: disable=wrong-import-position,import-error
import lsp_jsonrpc as jsonrpc
import lsprotocol.types as lsp
from pygls import server, uris, workspace
from pygls.workspace.text_document import TextDocument
from pygls import uris, workspace
from common.load_data import standard_items
from tclint.lexer import TclSyntaxError
from tclint.format import Formatter, FormatterOpts
from tclint.violations import Violation
from plugins.poco_plugin import commands
from tools import checks, parser
from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier
from tools.completion_items import completion
from tools.completion_items import completion, remove_existing_items, remove_shared_keys
from tools.inlay_hint import InlayHintGenerator
DIAGNOSTIC_SOURCE = "nx-post-support"
class TclLanguageServer(server.LanguageServer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parser = parser.CustomParser()
for command in commands:
self.parser._commands.update(command)
self.diagnostics = {}
def format(
self,
document: TextDocument,
options: lsp.FormattingOptions,
range: Optional[Tuple[int, int]] = None,
):
# parser = Parser(command_plugins=["nx_plugins.poco_plugin.py"])
# parser._commands.update(commands)
indent = "\t" if not options.insert_spaces else " " * options.tab_size
formatter = Formatter(
FormatterOpts(
indent=indent,
spaces_in_braces=False,
max_blank_lines=500,
indent_namespace_eval=True,
),
)
if range is not None:
start, end = range
return formatter.format_partial(document.source[start:end], self.parser)
return formatter.format_top(document.source, self.parser)
def linter(
self,
document: TextDocument,
) -> List[Violation]:
violations = []
self.parser.violations = []
tree = self.parser.parse(document.source)
violations += self.parser.violations
# log_to_output(tree.pretty(2))
for checker in checks.get_checkers():
violations += checker.check(document.source, tree)
return violations
def lint(self, document: TextDocument):
diagnostics = []
try:
violations = self.linter(document)
except TclSyntaxError as e:
return [
lsp.Diagnostic(
message=str(e),
severity=lsp.DiagnosticSeverity.Error,
range=lsp.Range(
start=lsp.Position(e.start[0] - 1, e.start[1] - 1),
end=lsp.Position(e.end[0] - 1, e.end[1] - 1),
),
code="syntax error",
source=DIAGNOSTIC_SOURCE,
)
]
for violation in violations:
message = violation.message
severity = lsp.DiagnosticSeverity.Warning
start = lsp.Position(
line=violation.start[0] - 1, character=violation.start[1] - 1
)
end = lsp.Position(
line=violation.end[0] - 1, character=violation.end[1] - 1
)
diagnostics.append(
lsp.Diagnostic(
message=message,
severity=severity,
range=lsp.Range(
start=start,
end=end,
),
code=violation.id,
source=DIAGNOSTIC_SOURCE,
)
)
return diagnostics
def _compute_diagnostics(self, document: TextDocument) -> List[lsp.Diagnostic]:
return self.lint(document)
def compute_diagnostics(self, document: TextDocument):
# `None` sentinel ensures that `diagnostics` gets updated if the URI is not
# present.
_, previous = self.diagnostics.get(document, (0, None))
diagnostics = self._compute_diagnostics(document)
# Only update if the list has changed
if previous != diagnostics:
self.diagnostics[document.uri] = (document.version, diagnostics)
from tools.file_sourcing import get_all_psc_files, read_psc_file
from lsp_tclserver import TclLanguageServer
WORKSPACE_SETTINGS = {}
GLOBAL_SETTINGS = {}
RUNNER = pathlib.Path(__file__).parent / "lsp_runner.py"
MAX_WORKERS = 5
LSP_SERVER = TclLanguageServer(
name="NX Postprocessor Support", version="0.0.1", max_workers=MAX_WORKERS
)
LSP_SERVER = TclLanguageServer(name="NX Postprocessor Support", version="0.0.1", max_workers=MAX_WORKERS)
# **********************************************************
# Tool specific code goes below this.
# **********************************************************
TOOL_MODULE = "nx-post-support"
TOOL_DISPLAY = "NX Postprocessor Support"
TOOL_ARGS = [] # default arguments always passed to your tool.
# Delete "Linting features" section if your tool is NOT a linter.
# **********************************************************
@@ -189,9 +75,8 @@ def did_open(params: lsp.DidOpenTextDocumentParams) -> None:
"""LSP handler for textDocument/didOpen request."""
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
LSP_SERVER.compute_diagnostics(document)
completion.reset()
tree = LSP_SERVER.parser.parse(document.source)
tree.accept(completion, recurse=True)
# Also update custom completion and proc docs for this file
LSP_SERVER.update_poco_completion_for_file(document)
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_SAVE)
@@ -201,7 +86,7 @@ def did_save(params: lsp.DidSaveTextDocumentParams) -> None:
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CLOSE)
def did_close(params: lsp.DidCloseTextDocumentParams) -> None:
def did_close(_: lsp.DidCloseTextDocumentParams) -> None:
"""LSP handler for textDocument/didClose request."""
@@ -210,9 +95,7 @@ def did_change(params: lsp.DidChangeTextDocumentParams) -> None:
"""LSP handler for textDocument/didChange request"""
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
LSP_SERVER.compute_diagnostics(document)
completion.reset()
tree = LSP_SERVER.parser.parse(document.source)
tree.accept(completion, recurse=True)
LSP_SERVER.update_poco_completion_for_file(document)
@LSP_SERVER.feature(
@@ -241,33 +124,82 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams):
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION)
def on_completion(params: lsp.CompletionParams) -> list[lsp.CompletionItem]:
_ = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
from tools.variable_index import build_variable_index
from tools.completion_items import BUILTIN_VAR_LABELS
items = (
standard_items.tcl_keyword_list
+ standard_items.nx_procs
+ standard_items.nx_variables
+ completion.custom_functions
)
return lsp.CompletionList(is_incomplete=False, items=items)
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
# Base items
poco = [item for items in LSP_SERVER.poco_completion.values() for item in items]
base_items = standard_items.tcl_keyword_list + standard_items.nx_procs + standard_items.nx_variables + poco
# Build variable index from current document
globals_set, procs_locals, proc_ranges = build_variable_index(doc.source)
# Always include globals (excluding built-ins)
dynamic_items = []
for name in sorted(globals_set):
if name not in BUILTIN_VAR_LABELS:
dynamic_items.append(lsp.CompletionItem(label=name, kind=lsp.CompletionItemKind.Variable))
# Include proc-local variables when cursor is inside that proc
pos = params.position
if pos is not None:
for pr in proc_ranges:
if pr.start_line <= pos.line <= (pr.end_line or pr.start_line):
for name in sorted(procs_locals.get(pr.name, set())):
# Exclude built-ins and globals to avoid duplication
if name not in BUILTIN_VAR_LABELS and name not in globals_set:
dynamic_items.append(lsp.CompletionItem(label=name, kind=lsp.CompletionItemKind.Variable))
break
# Merge with de-duplication for variables only
merged: list[lsp.CompletionItem] = []
seen_var_labels: set[str] = set()
for it in base_items + dynamic_items:
if getattr(it, "kind", None) == lsp.CompletionItemKind.Variable:
if it.label in seen_var_labels:
continue
seen_var_labels.add(it.label)
merged.append(it)
return lsp.CompletionList(is_incomplete=False, items=merged)
# @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
# def document_symbols(params: lsp.DocumentSymbolParams):
# doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
# return []
# ast = LSP_SERVER.parser.parse(doc.source)
# symbols = LSP_SERVER.extract_tcl_symbols(ast)
# return symbols
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL)
def document_symbols(params: lsp.DocumentSymbolParams):
from tools.document_symbols import build_document_symbols
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
return build_document_symbols(doc.source)
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_INLAY_HINT)
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)
tree = LSP_SERVER.parser.parse(document.source)
# collect Inlay Hints
generator = InlayHintGenerator(completion.proc_signatures)
# Reuse cached AST
tree = LSP_SERVER.get_tree(document)
# Merge proc signatures across files and traverse once
merged_signatures = {}
for sigs in LSP_SERVER.proc_signatures.values():
merged_signatures.update(sigs)
generator = InlayHintGenerator(merged_signatures)
tree.accept(generator, recurse=True)
return generator.hints
@@ -283,9 +215,10 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
data = []
plugins = []
hl = _Highlighter(plugins, log_to_output=log_to_output)
hl = _Highlighter(plugins, LSP_SERVER.poco_completion)
tree = LSP_SERVER.parser.parse(document.source)
# Reuse cached AST
tree = LSP_SERVER.get_tree(document)
tree.accept(hl, recurse=True)
tokens = hl.tokens()
@@ -294,7 +227,7 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
[
token.line,
token.offset,
token.lenght,
token.length,
TOKEN_TYPES.index(token.tok_type),
reduce(operator.or_, token.tok_modifiers, 0),
]
@@ -314,6 +247,13 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
except IndexError:
return None
# Do not show hover for proc name in its declaration
from tools.proc_docs import is_proc_declaration_position
if is_proc_declaration_position(document.source, pos.line, pos.character):
return None
# Identify the token under the cursor
for m in re.finditer(r"\b\w+\b", line):
if m.start() <= col <= m.end():
token = m.group(0)
@@ -321,51 +261,121 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
else:
return None
# 1) If token is a known MOM proc/variable, return built-in hover
command = 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 not match:
return None
if not match.get("kind") == "function":
return None
label = match.get("label", "")
parameters = match.get("parameters", [])
param_lines = (
"\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_"
)
example_data = match.get("example", [])
example_md = "\n".join(f"{line}" for line in example_data)
returns_data = match.get("returns", ["None"])
returns_md = "\n".join(f"- {line}" for line in returns_data)
doc_md = f"""\
if match and match.get("kind") == "function":
label = match.get("label", "")
parameters = match.get("parameters", [])
param_lines = "\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_"
example_data = match.get("example", [])
example_md = "\n".join(f"{line}" for line in example_data)
returns_data = match.get("returns", ["None"])
returns_md = "\n".join(f"- {line}" for line in returns_data)
doc_md = f"""\
### 📘 {label}
**Purpose**
**Purpose**
{match.get("description", "No description available.")}
**Format**
**Format**
`{match.get("format", label)}`
**Parameters**
**Parameters**
{param_lines}
**Return value**
**Return value**
{returns_md}
**Example**
**Example**
```tcl
{example_md}"""
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=doc_md))
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=doc_md))
# 2) Otherwise, check if the token is a custom proc and show its preceding doc block
# Build a merged map of proc -> docs gathered during initialization and updates
proc_docs: dict[str, str] = {}
for file_docs in LSP_SERVER.proc_docs.values():
proc_docs.update(file_docs)
if token in proc_docs:
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=proc_docs[token]))
return None
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION)
def goto_definition(params: lsp.DefinitionParams):
"""Provide go-to-definition locations for Tcl procs.
Strategy:
- Find the token under the cursor.
- If it matches a custom proc collected in proc_signatures, locate its declaration
by searching the current document first, then other indexed files.
- Return a Location pointing to the proc name in its declaration line.
"""
doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
pos = params.position
try:
line = doc.lines[pos.line]
except IndexError:
return None
# Identify token under cursor
token = None
for m in re.finditer(r"\b\w+\b", line):
if m.start() <= pos.character <= m.end():
token = m.group(0)
break
if not token:
return None
# Helper to search a single source text for a proc declaration
def find_decl_in_source(source_text: str, uri: str) -> Optional[lsp.Location]:
lines = source_text.split("\n")
pattern = re.compile(r"^\s*proc\s+" + re.escape(token) + r"\b")
for i, ln in enumerate(lines):
m = pattern.match(ln)
if m:
start_char = ln.find(token)
if start_char < 0:
start_char = max(m.end() - len(token), 0)
start = lsp.Position(i, start_char)
end = lsp.Position(i, start_char + len(token))
return lsp.Location(uri=uri, range=lsp.Range(start=start, end=end))
return None
# 1) Search in current document
loc = find_decl_in_source(doc.source, doc.uri)
if loc:
return loc
# 2) Search in indexed files from proc_signatures
# Build list of candidate files that declare this token as a proc
candidate_files: list[str] = []
for file_path, procs in LSP_SERVER.proc_signatures.items():
if token in procs:
candidate_files.append(file_path)
for fp in candidate_files:
uri = pathlib.Path(fp).as_uri()
# Try to get from workspace if available; else read from disk
try:
other_doc = LSP_SERVER.workspace.get_text_document(uri)
source = other_doc.source
except Exception:
try:
source = pathlib.Path(fp).read_text(encoding="utf-8")
except Exception:
continue
loc = find_decl_in_source(source, uri)
if loc:
return loc
return None
# **********************************************************
@@ -406,7 +416,7 @@ def formatting(params: lsp.DocumentFormattingParams) -> list[lsp.TextEdit] | Non
# Required Language Server Initialization and Exit handlers.
# **********************************************************
@LSP_SERVER.feature(lsp.WORKSPACE_DID_CHANGE_CONFIGURATION)
def did_change_configuration(params: lsp.DidChangeConfigurationParams):
def did_change_configuration(_: lsp.DidChangeConfigurationParams):
"""LSP Handler for Config Changes"""
@@ -422,26 +432,60 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult:
settings = params.initialization_options["settings"]
_update_workspace_settings(settings)
log_to_output(
f"Settings used to run Server:\r\n{json.dumps(settings, indent=4, ensure_ascii=False)}\r\n"
)
log_to_output(
f"Global settings:\r\n{json.dumps(GLOBAL_SETTINGS, indent=4, ensure_ascii=False)}\r\n"
)
log_to_output(f"Settings used to run Server:\r\n{json.dumps(settings, indent=4, ensure_ascii=False)}\r\n")
log_to_output(f"Global settings:\r\n{json.dumps(GLOBAL_SETTINGS, indent=4, ensure_ascii=False)}\r\n")
semantic_tokens_legend = lsp.SemanticTokensLegend(
token_types=TOKEN_TYPES,
token_modifiers=TokenModifier,
token_modifiers=[m.name for m in TokenModifier],
)
return lsp.InitializeResult(
capabilities=lsp.ServerCapabilities(
document_formatting_provider=GLOBAL_SETTINGS.get("formatter", True),
semantic_tokens_provider=lsp.SemanticTokensOptions(
legend=semantic_tokens_legend, full=True, range=False
),
semantic_tokens_provider=lsp.SemanticTokensOptions(legend=semantic_tokens_legend, full=True, range=False),
definition_provider=True,
)
)
@LSP_SERVER.feature(lsp.INITIALIZED)
def initialized(_params: lsp.InitializedParams):
"""Kick off background indexing to avoid blocking initialization."""
def index_workspace():
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:
file_root = pathlib.Path(root).joinpath(sourced_layer.subfolder if sourced_layer.subfolder else "")
for tcl_file in sourced_layer.files:
filepath = pathlib.Path(file_root).joinpath(f"{tcl_file}.tcl")
if not filepath.exists():
continue
completion.reset()
document = LSP_SERVER.workspace.get_text_document(filepath.as_uri())
tree = LSP_SERVER.parser.parse(document.source)
tree.accept(completion, recurse=True)
remove_existing_items(completion.custom_functions, LSP_SERVER.poco_completion)
LSP_SERVER.poco_completion[str(filepath)] = completion.custom_functions
remove_shared_keys(LSP_SERVER.proc_signatures, completion.proc_signatures)
LSP_SERVER.proc_signatures[str(filepath)] = completion.proc_signatures
from tools.proc_docs import build_proc_docs
LSP_SERVER.proc_docs[str(filepath)] = build_proc_docs(tree, document.source)
except Exception as e:
log_to_output(f"Fehler beim Parsen von {filepath}: {e}")
log_to_output("Background indexing completed.")
except Exception as e:
log_to_output(f"Background indexing failed: {e}")
threading.Thread(target=index_workspace, name="nxps-indexer", daemon=True).start()
@LSP_SERVER.feature(lsp.EXIT)
def on_exit(_params: Optional[Any] = None) -> None:
"""Handle clean up on exit."""
@@ -462,6 +506,7 @@ def _get_global_defaults():
"importStrategy": GLOBAL_SETTINGS.get("importStrategy", "useBundled"),
"showNotifications": GLOBAL_SETTINGS.get("showNotifications", "off"),
"formatter": GLOBAL_SETTINGS.get("formatter", True),
"inlayHint": GLOBAL_SETTINGS.get("inlayHint", True),
}
@@ -533,9 +578,7 @@ def _get_settings_by_document(document: workspace.Document | None):
# *****************************************************
# Logging and notification.
# *****************************************************
def log_to_output(
message: str, msg_type: lsp.MessageType = lsp.MessageType.Log
) -> None:
def log_to_output(message: str, msg_type: lsp.MessageType = lsp.MessageType.Log) -> None:
LSP_SERVER.show_message_log(message, msg_type)
+173
View File
@@ -0,0 +1,173 @@
import logging
import pathlib
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
DIAGNOSTIC_SOURCE = "nx-post-support"
class TclLanguageServer(server.LanguageServer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parser = parser.CustomParser()
for command in commands:
self.parser._commands.update(command)
self.diagnostics = {}
self.poco_completion: dict = {}
self.proc_signatures: dict = {}
self.proc_docs: dict = {}
# Cache: (uri, version) -> (tree, violations)
self._ast_cache = {}
def get_tree(self, document: TextDocument):
key = (document.uri, document.version)
cached = self._ast_cache.get(key)
if cached:
return cached[0]
# Parse and cache
self.parser.violations = []
tree = self.parser.parse(document.source)
violations = list(self.parser.violations)
self._ast_cache[key] = (tree, violations)
return tree
def get_tree_and_violations(self, document: TextDocument):
key = (document.uri, document.version)
cached = self._ast_cache.get(key)
if cached:
return cached
# Parse and cache
self.parser.violations = []
tree = self.parser.parse(document.source)
violations = list(self.parser.violations)
self._ast_cache[key] = (tree, violations)
return tree, violations
def clear_cache_for_uri(self, uri: str):
to_delete = [k for k in self._ast_cache.keys() if k[0] == uri]
for k in to_delete:
del self._ast_cache[k]
def update_poco_completion_for_file(self, document: TextDocument):
"""Update poco_completion for a specific file when it changes"""
filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
# Remove existing completion items for this file
if filepath in self.poco_completion:
del self.poco_completion[filepath]
if filepath in self.proc_signatures:
del self.proc_signatures[filepath]
if filepath in self.proc_docs:
del self.proc_docs[filepath]
# Parse and extract new completion items
completion.reset()
try:
tree = self.get_tree(document)
tree.accept(completion, recurse=True)
remove_existing_items(completion.custom_functions, self.poco_completion)
self.poco_completion[filepath] = completion.custom_functions
remove_shared_keys(self.proc_signatures, completion.proc_signatures)
self.proc_signatures[filepath] = completion.proc_signatures
self.proc_docs[filepath] = build_proc_docs(tree, document.source)
except Exception as e:
logging.debug(f"Error parsing {filepath}: {e}")
def format(
self,
document: TextDocument,
options: lsp.FormattingOptions,
range: Optional[Tuple[int, int]] = None,
):
# parser = Parser(command_plugins=["nx_plugins.poco_plugin.py"])
# parser._commands.update(commands)
indent = "\t" if not options.insert_spaces else " " * options.tab_size
formatter = Formatter(
FormatterOpts(
indent=indent,
spaces_in_braces=False,
max_blank_lines=500,
indent_namespace_eval=True,
),
)
if range is not None:
start, end = range
return formatter.format_partial(document.source[start:end], self.parser)
return formatter.format_top(document.source, self.parser)
def linter(
self,
document: TextDocument,
) -> List[Violation]:
tree, violations = self.get_tree_and_violations(document)
for checker in checks.get_checkers():
violations += checker.check(document.source, tree)
return violations
def lint(self, document: TextDocument):
diagnostics = []
try:
violations = self.linter(document)
except TclSyntaxError as e:
return [
lsp.Diagnostic(
message=str(e),
severity=lsp.DiagnosticSeverity.Error,
range=lsp.Range(
start=lsp.Position(e.start[0] - 1, e.start[1] - 1),
end=lsp.Position(e.end[0] - 1, e.end[1] - 1),
),
code="syntax error",
source=DIAGNOSTIC_SOURCE,
)
]
for violation in violations:
message = violation.message
severity = lsp.DiagnosticSeverity.Warning
start = lsp.Position(line=violation.start[0] - 1, character=violation.start[1] - 1)
end = lsp.Position(line=violation.end[0] - 1, character=violation.end[1] - 1)
diagnostics.append(
lsp.Diagnostic(
message=message,
severity=severity,
range=lsp.Range(
start=start,
end=end,
),
code=violation.id,
source=DIAGNOSTIC_SOURCE,
)
)
return diagnostics
def _compute_diagnostics(self, document: TextDocument) -> List[lsp.Diagnostic]:
return self.lint(document)
def compute_diagnostics(self, document: TextDocument):
# `None` sentinel ensures that `diagnostics` gets updated if the URI is not
# present.
_, previous = self.diagnostics.get(document, (0, None))
diagnostics = self._compute_diagnostics(document)
# Only update if the list has changed
if previous != diagnostics:
self.diagnostics[document.uri] = (document.version, diagnostics)
-78
View File
@@ -1,78 +0,0 @@
from tclint.parser import Parser as tcLintParser
from tclint.lexer import Lexer as tclingLexer
def main():
parser = tcLintParser(True)
tree = parser.parse("""puts hello
proc myProc {arg {arg7 0}} {}
set myVar 123
puts puts
LIB_SPF_prepend MOM_strt Start_Lib {
set somthing 1
set more 2
} myTag
LIB_SPF_prepend MOM_strt Start_Lib {
proc test {} {
puts "Hello"
}
set somthing 1
set someting 3
} myTag""")
print(tree.pretty(2))
def lexer_test():
lexer = tclingLexer()
tree = lexer.input("""
if {$oem(custom_clamp_4th) == 1 && $oem(custom_clamp_5th) == 1 \\
&& $oem(status_clamp_4th) == "off" && $oem(status_clamp_5th) == "off"}""")
# print("Lexing input:\n", code)
# print("\nTokens:\n" + "-" * 30)
while lexer.type() is not None:
tok_type = lexer.type()
tok_value = lexer.value()
tok_pos = lexer.pos()
print(f"Type: {tok_type:20} | Value: {repr(tok_value):20} | Pos: {tok_pos}")
lexer.next()
def test_1():
from tclint.lexer import Lexer, TOK_BACKSLASH_NEWLINE
code = "expr {1 == 2 \\\n&& 3 == 4}"
lexer = Lexer()
lexer.input(code)
while lexer.type() is not None:
print(
f"Type: {lexer.type():<20} | Value: {lexer.value()!r} | Pos: {lexer.pos()}"
)
lexer.next()
class NodeVisitor:
def visit_script(self, node):
for stmt in node.statements:
stmt.accept(self)
def visit_proc(self, node):
print(f"Proc: {node.name}, args={node.args}")
def visit_set(self, node):
print(f"Set: {node.varname} = {node.value}")
def visit_namespace(self, node):
print(f"Namespace: {node.name}")
def visit_command(self, node):
print(f"Command: {node.name} {node.args}")
if __name__ == "__main__":
test_1()
+64 -8
View File
@@ -1,5 +1,8 @@
from tclint.syntax_tree import Visitor, Command, BareWord, List
import lsprotocol.types as lsp
from common.load_data import standard_items
BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables}
class CompletionItems:
@@ -33,19 +36,25 @@ class _Completion(Visitor):
self._custom_functions = []
self._proc_signatures = {}
def _append_unique(self, item: lsp.CompletionItem):
# Avoid duplicate labels within the same file scan
if not any(ci.label == item.label for ci in self._custom_functions):
self._custom_functions.append(item)
def visit_command(self, command: Command):
routine = command.routine
# Collect custom proc names and their signatures
if routine.contents == "proc" and command.args:
first_arg = command.args[0]
if hasattr(first_arg, "value") and not any(
item.label == first_arg.value for item in self._custom_functions
):
self._custom_functions.append(
lsp.CompletionItem(
label=first_arg.value, kind=lsp.CompletionItemKind.Function
)
)
if not getattr(first_arg, "value", None):
return
if any(item.label == first_arg.value for item in standard_items.nx_procs):
return
# Record proc name as a completion item
self._append_unique(lsp.CompletionItem(label=first_arg.value, kind=lsp.CompletionItemKind.Function))
if len(command.args) < 2:
return
@@ -64,5 +73,52 @@ class _Completion(Visitor):
self._proc_signatures[first_arg.value] = param_names
# Collect global variables declared with: global var1 var2 ...
elif routine.contents == "global" and command.args:
for arg in command.args:
if isinstance(arg, BareWord) and getattr(arg, "value", None):
if arg.value not in BUILTIN_VAR_LABELS:
self._append_unique(lsp.CompletionItem(label=arg.value, kind=lsp.CompletionItemKind.Variable))
# Collect variables set with explicit global namespace: set ::var_name ...
elif routine.contents == "set" and command.args:
first = command.args[0]
if isinstance(first, BareWord) and getattr(first, "value", None):
var_name = first.value
if var_name.startswith("::"):
base_name = var_name.split("(", 1)[0]
clean_name = base_name[2:] # remove leading '::' for completion display
if clean_name not in BUILTIN_VAR_LABELS:
self._append_unique(lsp.CompletionItem(label=clean_name, kind=lsp.CompletionItemKind.Variable))
def remove_existing_items(items: list[lsp.CompletionItem], store: dict) -> None:
"""
Entfernt alle CompletionItems aus dem store, deren label in der items-Liste vorkommt.
Änderungen erfolgen in-place.
"""
labels_to_remove = {item.label for item in items}
for key in list(store.keys()):
filtered = [ci for ci in store[key] if ci.label not in labels_to_remove]
if filtered:
store[key] = filtered
else:
del store[key]
def remove_shared_keys(nested_dict: dict[str, dict[str, list]], flat_dict: dict[str, list]) -> None:
"""
Entfernt alle Keys aus nested_dict[file][func], wenn func auch in flat_dict vorhanden ist.
Änderungen erfolgen in-place.
"""
for file_path, func_dict in list(nested_dict.items()):
for func_name in list(func_dict.keys()):
if func_name in flat_dict:
del nested_dict[file_path][func_name]
if not nested_dict[file_path]:
del nested_dict[file_path]
completion = _Completion()
+189
View File
@@ -0,0 +1,189 @@
from __future__ import annotations
import re
import lsprotocol.types as lsp
# Precompiled regex patterns for performance
NS_RE = re.compile(r"^\s*namespace\s+eval\s+([^\s\{]+)")
PROC_RE = re.compile(r"^\s*proc\s+([^\s\{]+)\s+\{.*\}\s+\{")
SET_RE = re.compile(r"^\s*set\s+([^\s\}]+)")
EVENT_START_RE = re.compile(r"^\s*LIB_GE_command_buffer_edit_(prepend|append|insert|replace)\b")
EVENT_CLOSE_INLINE_RE = re.compile(r"^\s*\}\s*(\S+)\s*.*$")
EVENT_NAME_LINE_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\b")
def build_document_symbols(source: str) -> list[lsp.DocumentSymbol]:
"""Parse a Tcl document source and return DocumentSymbols.
This mirrors the previous inline implementation in lsp_server, but is extracted
for readability and reuse.
"""
lines = source.split("\n")
class Scope:
def __init__(self, name: str, symbol: lsp.DocumentSymbol, start_line: int):
self.name = name
self.symbol = symbol
self.start_line = start_line
self.brace_count = 0
root_symbol = lsp.DocumentSymbol(
name="root",
detail="",
kind=lsp.SymbolKind.Namespace,
range=lsp.Range(start=lsp.Position(0, 0), end=lsp.Position(len(lines), 0)),
selection_range=lsp.Range(start=lsp.Position(0, 0), end=lsp.Position(0, 0)),
children=[],
)
scope_stack: list[Scope] = [Scope("", root_symbol, 0)]
pending_event: dict | None = None
for i, line in enumerate(lines):
# If inside an event, update its brace count for this line
if pending_event is not None:
pe_open = line.count("{")
pe_close = line.count("}")
pending_event["brace_count"] = pending_event.get("brace_count", 1) + pe_open - pe_close
# If this line closed the outer event block, finalize the event
if pending_event["brace_count"] <= 0:
# Try inline name on the same line
close_inline = EVENT_CLOSE_INLINE_RE.match(line)
if close_inline:
event_name = close_inline.group(1)
name_line_index = i
else:
# Look ahead to next non-empty line for the name
j = i + 1
while j < len(lines) and lines[j].strip() == "":
j += 1
event_name = None
name_line_index = i
if j < len(lines):
name_line = lines[j]
name_match = EVENT_NAME_LINE_RE.match(name_line)
if name_match:
event_name = name_match.group(1)
name_line_index = j
if event_name:
start_line = pending_event["start"]
edit_type = pending_event["edit_type"]
children = pending_event.get("children", [])
detail = f"Event ({edit_type})"
event_symbol = lsp.DocumentSymbol(
name=event_name,
detail=detail,
kind=lsp.SymbolKind.Event,
range=lsp.Range(start=lsp.Position(start_line, 0), end=lsp.Position(name_line_index, len(lines[name_line_index]))),
selection_range=lsp.Range(start=lsp.Position(name_line_index, 0), end=lsp.Position(name_line_index, len(lines[name_line_index]))),
children=children or [],
)
if scope_stack[-1].symbol.children is None:
scope_stack[-1].symbol.children = []
scope_stack[-1].symbol.children.append(event_symbol)
# Clear event tracking and continue
pending_event = None
continue
ns_match = NS_RE.match(line)
proc_match = PROC_RE.match(line)
set_match = SET_RE.match(line)
# Namespace
if ns_match:
ns_name = ns_match.group(1)
start = lsp.Position(i, 0)
end = lsp.Position(i, len(line))
sel_start_char = line.find(ns_name)
sel_end_char = sel_start_char + len(ns_name) if sel_start_char >= 0 else len(line)
ns_symbol = lsp.DocumentSymbol(
name=ns_name,
detail="Namespace",
kind=lsp.SymbolKind.Namespace,
range=lsp.Range(start=start, end=end),
selection_range=lsp.Range(
start=lsp.Position(i, max(sel_start_char, 0)),
end=lsp.Position(i, max(sel_end_char, 0)),
),
children=[],
)
scope = Scope(ns_name, ns_symbol, i)
if scope_stack[-1].symbol.children is None:
scope_stack[-1].symbol.children = []
scope_stack[-1].symbol.children.append(ns_symbol)
scope_stack.append(scope)
# Proc
elif proc_match:
proc_name = proc_match.group(1)
start = lsp.Position(i, 0)
end = lsp.Position(i, len(line))
sel_start_char = line.find(proc_name)
sel_end_char = sel_start_char + len(proc_name) if sel_start_char >= 0 else len(line)
proc_symbol = lsp.DocumentSymbol(
name=proc_name,
detail="Procedure",
kind=lsp.SymbolKind.Function,
range=lsp.Range(start=start, end=end),
selection_range=lsp.Range(
start=lsp.Position(i, max(sel_start_char, 0)),
end=lsp.Position(i, max(sel_end_char, 0)),
),
children=[],
)
scope = Scope(proc_name, proc_symbol, i)
if scope_stack[-1].symbol.children is None:
scope_stack[-1].symbol.children = []
scope_stack[-1].symbol.children.append(proc_symbol)
scope_stack.append(scope)
# set variable
elif set_match:
var_name = set_match.group(1)
start = lsp.Position(i, 0)
end = lsp.Position(i, len(line))
sel_start_char = line.find(var_name)
sel_end_char = sel_start_char + len(var_name) if sel_start_char >= 0 else len(line)
var_symbol = lsp.DocumentSymbol(
name=var_name,
detail="Variable",
kind=lsp.SymbolKind.Variable,
range=lsp.Range(start=start, end=end),
selection_range=lsp.Range(
start=lsp.Position(i, max(sel_start_char, 0)),
end=lsp.Position(i, max(sel_end_char, 0)),
),
children=None,
)
# Attach to current scope or pending event as child
if pending_event is not None:
pending_event["children"].append(var_symbol)
else:
if scope_stack[-1].symbol.children is None:
scope_stack[-1].symbol.children = []
scope_stack[-1].symbol.children.append(var_symbol)
# Event start (buffer edit)
m = EVENT_START_RE.match(line)
if m:
edit_type = m.group(1)
pending_event = {"start": i, "edit_type": edit_type, "children": []}
continue
# Brace balancing for scopes (namespace/proc)
open_count = line.count("{")
close_count = line.count("}")
scope_stack[-1].brace_count += open_count - close_count
# Close finished scopes
while len(scope_stack) > 1 and scope_stack[-1].brace_count <= 0:
finished = scope_stack.pop()
# Set the full range from startLine to current line
finished.symbol.range = lsp.Range(
start=lsp.Position(finished.start_line, 0),
end=lsp.Position(i, len(line)),
)
# Return top-level children
return root_symbol.children
+51
View File
@@ -0,0 +1,51 @@
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import List, Optional
from pathlib import Path
@dataclass
class SourcedFile:
layer_name: str
subfolder: Optional[str]
files: List[str]
def read_psc_file(psc_file: Path) -> List[SourcedFile]:
tree = ET.parse(psc_file)
root = tree.getroot()
layers = root.findall(".//Layer")
layer_info_list: List[SourcedFile] = []
for layer in layers:
layer_name = layer.attrib.get("Name")
subfolder = layer.attrib.get("SubFolder")
# Scripts-Filenames
scripts = layer.find("Scripts")
script_names = []
if scripts is not None:
for filename in scripts.findall("Filename"):
name = filename.attrib.get("Name")
if name:
script_names.append(name)
layer_info_list.append(
SourcedFile(layer_name=layer_name, subfolder=subfolder, files=script_names)
)
return layer_info_list
def get_all_psc_files(root_path: Path) -> list[Path]:
return [path for path in root_path.rglob("*.psc")]
if __name__ == "__main__":
test = get_all_psc_files(
Path(
r"H:\janus-engineering-customers\KSB_Frankenthal\custom\library\machine\installed_machines\ksb_pe_grob_g550_sone\postprocessor"
)
)
print(test)
for pp in test:
read_psc_file(pp)
+31
View File
@@ -0,0 +1,31 @@
from tclint.format import Formatter as BaseFormatter
from typing import List
class NxFormatter(BaseFormatter):
"""
Custom formatter that inherits from tclint's Formatter but preserves explicit
line continuations (\\) inside braced expressions when they span multiple lines.
This avoids generating syntax errors in environments that require a trailing
backslash for multi-line expressions (e.g., certain NX Post interpreters),
while leaving all other formatting behavior unchanged.
"""
def format_braced_expression(self, expr) -> List[str]: # type: ignore[override]
# This method mirrors BaseFormatter.format_braced_expression but inserts
# a line continuation (" \") between continuation lines similar to
# BaseFormatter.format_expression.
formatted = [""]
for child in expr.children:
lines = self.format(child)
formatted[-1] += lines[0]
for line in lines[1:]:
# add continuation on the previous line; keep next line at the same level
formatted[-1] += " \\" # keep explicit continuation
formatted += [line]
if expr.pos[0] == expr.end_pos[0]:
return self._brace(formatted)
return ["{"] + self._indent(formatted, self.opts.indent) + ["}"]
+177
View File
@@ -0,0 +1,177 @@
import re
from typing import Dict, List
from tclint.syntax_tree import Visitor, Command
from tools.parser import CustomParser
def _strip_comment_prefix(line: str) -> str:
"""Strip leading '# ' or '#' from a line."""
if line.lstrip().startswith("#"):
# remove up to one leading '#' and one optional following space
return re.sub(r"^\s*#\s?", "", line)
return line
def extract_doc_block_above(lines: List[str], start_line_index: int) -> str | None:
"""
Extract a contiguous block of line comments immediately above the given line index.
- lines: document split into lines
- start_line_index: 0-based index of the line where the proc command starts
Returns the cleaned documentation text or None if no comment block found.
"""
i = start_line_index - 1
if i < 0:
return None
doc_lines: List[str] = []
# Skip trailing empty lines directly above
while i >= 0 and lines[i].strip() == "":
i -= 1
# Collect contiguous comment lines going upwards
while i >= 0 and lines[i].lstrip().startswith("#"):
doc_lines.append(lines[i])
i -= 1
if not doc_lines:
return None
# Reverse to original order and strip comment prefixes
doc_lines.reverse()
cleaned = [_strip_comment_prefix(line_text) for line_text in doc_lines]
# Simple tag -> markdown conversions for nicer rendering
md_lines: List[str] = []
tag_map = {
"<Documentation>": "### Documentation",
"<Arguments>": "### Arguments",
"<Returnvalue>": "### Return value",
"<Example>": "### Example",
"<Internal Documentation>": "### Internal Documentation",
"<Internal Example>": "### Internal Example",
}
in_example = False
code_block_open = False
def close_code_block_if_open():
nonlocal code_block_open
if code_block_open:
md_lines.append("```")
code_block_open = False
for line in cleaned:
stripped = line.strip()
# Convert tags to headings and manage example sections
if stripped in tag_map:
# If we hit any new tag, close a pending code block
close_code_block_if_open()
heading = tag_map[stripped]
md_lines.append(heading)
in_example = heading in ("### Example", "### Internal Example")
continue
if in_example:
low = stripped.lower()
if low.startswith("code:"):
# Open code block if needed and append the code content
code_text = line.split(":", 1)[1].strip()
if not code_block_open:
md_lines.append("```tcl")
code_block_open = True
md_lines.append(code_text)
continue
# Keep name/desc lines as regular text outside code
if low.startswith("name:") or low.startswith("desc:"):
md_lines.append(line)
continue
md_lines.append(line)
# Close any dangling code fence at the end of the block
close_code_block_if_open()
return "\n".join(md_lines).rstrip()
class ProcDocExtractor(Visitor):
"""Visitor that collects documentation blocks above proc declarations."""
def __init__(self, source_text: str):
super().__init__()
self._lines = source_text.split("\n")
self.docs: Dict[str, str] = {}
def visit_command(self, command: Command):
routine = getattr(command.routine, "contents", None)
if routine != "proc":
return
if not command.args:
return
name_node = command.args[0]
proc_name = getattr(name_node, "contents", None)
if not proc_name:
return
# Prefer line of the 'proc' keyword; fallback to the name node
pos = getattr(command.routine, "pos", None) or getattr(name_node, "pos", None)
if not pos:
return
line_idx = pos[0] - 1 # 0-based
block = extract_doc_block_above(self._lines, line_idx)
if block:
self.docs[proc_name] = block
def build_proc_docs(tree, source_text: str) -> Dict[str, str]:
"""Build a mapping of proc name -> markdown doc from a parsed tree and source text."""
extractor = ProcDocExtractor(source_text)
tree.accept(extractor, recurse=True)
return extractor.docs
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."""
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:
start_line, start_col = name_node.pos
end_line, end_col = getattr(name_node, "end_pos", name_node.pos)
except Exception:
return
if start_line - 1 == line_zero_based:
length = 0
if hasattr(name_node, "value") and name_node.value is not None:
length = len(name_node.value)
elif hasattr(name_node, "contents") and name_node.contents is not None:
length = len(name_node.contents)
if length:
start_c = start_col - 1
end_c = start_c + length
if start_c <= char_zero_based <= end_c:
self.is_decl = True
finder = _DeclFinder()
tree.accept(finder, recurse=True)
return finder.is_decl
+25 -24
View File
@@ -4,7 +4,7 @@ from tclint.syntax_tree import Visitor, QuotedWord, Command, BareWord
from tclint.commands import get_commands
import attrs
from common.load_data import standard_items
from tools.completion_items import completion
import lsprotocol.types as lsp
class TokenModifier(enum.IntFlag):
@@ -25,6 +25,11 @@ class Token:
tok_type: str = ""
tok_modifiers: List[TokenModifier] = attrs.field(factory=list)
@property
def length(self) -> int:
"""Compatibility alias for misspelled 'lenght' field."""
return self.lenght
TOKEN_TYPES = [
"keyword",
@@ -40,10 +45,10 @@ TOKEN_TYPES = [
class _Highlighter(Visitor):
def __init__(self, plugins, log_to_output):
def __init__(self, plugins, custom_functions: dict[str : list[lsp.CompletionItem]]):
self._commands = get_commands(plugins)
self._tokens = []
self.log_to_output = log_to_output
self.custom_functions = custom_functions
def _get_token_info(self, node):
"""Hilfsmethode um Token-Informationen aus verschiedenen Node-Typen zu extrahieren."""
@@ -58,11 +63,7 @@ class _Highlighter(Visitor):
# CompoundBareWord: versuche erstes Segment
if hasattr(node, "children") and node.children:
first_segment = node.children[0]
if (
hasattr(first_segment, "value")
and first_segment.value is not None
and hasattr(first_segment, "pos")
):
if hasattr(first_segment, "value") and first_segment.value is not None and hasattr(first_segment, "pos"):
line, col = first_segment.pos
return (line - 1, col - 1), len(first_segment.value)
@@ -84,17 +85,23 @@ class _Highlighter(Visitor):
pass
def visit_bare_word(self, word: BareWord):
if any(item.label == word.value for item in standard_items.nx_procs) or any(
item.label == word.value for item in completion.custom_functions
):
line, col = word.pos
self._tokens.append(
(((line - 1, col - 1), len(word.value), "function", []))
)
# Intentionally do not classify bare words as functions here.
# Function highlighting is handled in visit_command for the routine only,
# using completion items (custom functions) as the source of truth.
return
def visit_command(self, command: Command):
routine = command.routine
# Highlight functions (custom or standard) when used as the routine
name = getattr(routine, "contents", None)
if name:
in_custom = any(item.label == name for items in self.custom_functions.values() for item in items)
in_standard = any(item.label == name for item in standard_items.nx_procs)
if in_custom or in_standard:
line, col = routine.contents_pos
self._tokens.append((((line - 1, col - 1), len(name), "function", [])))
if routine.contents == "puts":
line, col = routine.contents_pos
self._tokens.append(
@@ -158,9 +165,7 @@ class _Highlighter(Visitor):
# Parameter mit Default-Wert ist meist eine List (z.B. {arg default})
elif hasattr(child, "children") and len(child.children) >= 1:
name_node = child.children[0]
if hasattr(name_node, "value") and hasattr(
name_node, "pos"
):
if hasattr(name_node, "value") and hasattr(name_node, "pos"):
line, col = name_node.pos
self._tokens.append(
(
@@ -174,9 +179,7 @@ class _Highlighter(Visitor):
first_arg = command.args[1]
if hasattr(first_arg, "pos") and first_arg.value is not None:
line, col = first_arg.pos
self._tokens.append(
(((line - 1, col - 1), len(first_arg.value), "class", []))
)
self._tokens.append((((line - 1, col - 1), len(first_arg.value), "class", [])))
def tokens(self) -> list[Token]:
"""Encode tokens as described in
@@ -185,9 +188,7 @@ class _Highlighter(Visitor):
tokens = []
last_line = 0
last_col = 0
for (line, col), length, tok_type, tok_modifier in sorted(
self._tokens, key=lambda x: x[0]
):
for (line, col), length, tok_type, tok_modifier in sorted(self._tokens, key=lambda x: x[0]):
line_delta = line - last_line
col_delta = col
if line == last_line:
+46
View File
@@ -0,0 +1,46 @@
import logging
from collections import defaultdict
from typing import List, DefaultDict, Union
from tclint.syntax_tree import Visitor, Command, CommandSub, Node, Script
class SymbolTable:
"""Holds a symbol table (links symbols to nodes)."""
def __init__(self):
self.proc_def: DefaultDict[str, list[Node]] = defaultdict(list)
def add_proc_definition(self, command: Command) -> None:
"""Add definition of procedure"""
# command holds the "proc" keyword, so the proc name is 1st argument
proc_name_node = command.args[0]
proc_name = proc_name_node.contents
if not proc_name:
return
logging.debug(
f"Definition of proc '{proc_name}' at {proc_name_node._pos_str()}"
)
self.proc_def[proc_name].append(proc_name_node)
def lookup_proc_definitions(self, symbol_text: str) -> List[Node]:
"""Lookup definitions of the procedure pointed at by node"""
if symbol_text is None or symbol_text not in self.proc_def:
return []
return self.proc_def[symbol_text]
class SymbolTableBuilder(Visitor):
"""Builds a symbol table."""
def __init__(self):
self.table = SymbolTable()
def build(self, tree: Union[CommandSub, Script]) -> SymbolTable:
"""Run the builder visitor through the syntax tree, building a table."""
tree.accept(self, recurse=True)
return self.table
def visit_command(self, command: Command) -> None:
if command.routine.contents == "proc":
self.table.add_proc_definition(command)
+100
View File
@@ -0,0 +1,100 @@
import re
from dataclasses import dataclass
from typing import Dict, Set, List, Tuple
# Reuse patterns similar to document_symbols
NS_RE = re.compile(r"^\s*namespace\s+eval\s+([^\s\{]+)")
PROC_RE = re.compile(r"^\s*proc\s+([^\s\{]+)\s+\{.*\}\s+\{")
SET_RE = re.compile(r"^\s*set\s+([^\s\}]+)")
@dataclass
class ProcRange:
name: str
start_line: int
end_line: int | None = None
def build_variable_index(source: str) -> tuple[Set[str], Dict[str, Set[str]], List[ProcRange]]:
"""
Parse Tcl source text and build:
- globals: set of variable names considered global suggestions
- procs: mapping proc_name -> set of local variable names (set without :: inside that proc)
- proc_ranges: list of ProcRange (name, start_line, end_line)
Rules:
- set ::var -> global var suggestion (strip leading :: and any array index "(")
- set var without :: at top level (not in namespace/proc) -> global suggestion
- set var without :: inside proc -> local to that proc
- set var inside namespace (no ::) is ignored for global suggestions
"""
lines = source.split("\n")
class Scope:
def __init__(self, name: str, kind: str, start_line: int):
self.name = name
self.kind = kind # "namespace" or "proc" or "root"
self.start_line = start_line
self.brace_count = 0
globals_set: Set[str] = set()
procs: Dict[str, Set[str]] = {}
proc_ranges: List[ProcRange] = []
scope_stack: List[Scope] = [Scope("", "root", 0)]
for i, line in enumerate(lines):
ns_match = NS_RE.match(line)
proc_match = PROC_RE.match(line)
set_match = SET_RE.match(line)
# Namespace scope
if ns_match:
scope_stack.append(Scope(ns_match.group(1), "namespace", i))
# Proc scope
elif proc_match:
pname = proc_match.group(1)
scope_stack.append(Scope(pname, "proc", i))
proc_ranges.append(ProcRange(name=pname, start_line=i, end_line=None))
# Track set statements
if set_match:
raw_name = set_match.group(1)
# Normalize array names and leading ::
base = raw_name.split("(", 1)[0]
if base.startswith("::"):
clean = base[2:]
globals_set.add(clean)
else:
top = scope_stack[-1]
if top.kind == "root":
globals_set.add(base)
elif top.kind == "proc":
procs.setdefault(top.name, set()).add(base)
else:
# inside namespace without :: -> ignore for globals
pass
# Brace balancing for current top scope
open_count = line.count("{")
close_count = line.count("}")
scope_stack[-1].brace_count += open_count - close_count
# Close finished scopes
while len(scope_stack) > 1 and scope_stack[-1].brace_count <= 0:
finished = scope_stack.pop()
if finished.kind == "proc":
# Update the last matching proc range end_line
for pr in reversed(proc_ranges):
if pr.name == finished.name and pr.end_line is None:
pr.end_line = i
break
# Finalize any unterminated proc ranges
for pr in proc_ranges:
if pr.end_line is None:
pr.end_line = len(lines) - 1
return globals_set, procs, proc_ranges
@@ -0,0 +1,115 @@
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 lsp_server import LSP_SERVER, document_symbols # type: ignore
def test_document_symbols_namespace_proc_set_hierarchy(tmp_path: Path):
source_lines = [
"set top_var 1",
"namespace eval myns {",
" set ns_var 2",
" proc add {a b} {",
" set sum [expr {$a + $b}]",
" return $sum",
" }",
"}",
"proc top_proc {} {",
" set x 3",
"}",
]
source = "\n".join(source_lines)
uri = Path(tmp_path / "sym.tcl").as_uri()
# Put a text document into the workspace
LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source))
# Request document symbols
params = lsp.DocumentSymbolParams(text_document=lsp.TextDocumentIdentifier(uri=uri))
symbols = document_symbols(params)
# Expect at least 2 top-level children: root contains 'set top_var' (variable) and 'namespace myns' and 'proc top_proc'
names_kinds = {(s.name, s.kind) for s in symbols}
assert ("root", lsp.SymbolKind.Namespace) not in names_kinds # root should not be included itself
# Find namespace symbol
ns = next(s for s in symbols if s.name == "myns")
assert ns.kind == lsp.SymbolKind.Namespace
assert ns.children is not None
# Inside namespace: has variable and proc
child_names = {c.name for c in ns.children}
assert "ns_var" in child_names
assert "add" in child_names
# top-level variable and proc also present
top_names = {s.name for s in symbols}
assert "top_var" in top_names
assert "top_proc" in top_names
# Check that proc add has no children (we're not extracting params as children here)
add = next(c for c in ns.children if c.name == "add")
assert add.kind == lsp.SymbolKind.Function
assert add.children == []
def test_buffer_edit_events_are_symbols_with_type_and_name(tmp_path: Path):
source_lines = [
"LIB_GE_command_buffer_edit_insert LIB_ROTARY_positioning_first_move_pos ROTARY_POSITIONING_FIRST_MOVE_POS {",
" MOM_enable_address Z M_coolant_off D M_coolant_1 M_coolant_2 H_pressure",
"}",
" Coolant after @DECOMPOSEZUL",
"",
"LIB_GE_command_buffer_edit_append MOM_start_of_path_LIB MOM_start_of_path_LIB_ENTRY_end {",
" MOM_force once M_coolant_1 M_coolant_2 H_pressure",
"}",
" force_coolant",
]
source = "\n".join(source_lines)
uri = Path(tmp_path / "events.tcl").as_uri()
LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source))
params = lsp.DocumentSymbolParams(text_document=lsp.TextDocumentIdentifier(uri=uri))
symbols = document_symbols(params)
# Find event symbols
events = [s for s in symbols if s.kind == lsp.SymbolKind.Event]
assert events, "Expected at least one event symbol"
# Verify names and details
names = [e.name for e in events]
assert "Coolant" in names or "force_coolant" in names
for e in events:
assert e.detail.startswith("Event (")
def test_event_children_include_set_variable(tmp_path: Path):
source_lines = [
"LIB_GE_command_buffer_edit_append MOM_rapid_move_LIB MOM_rapid_move_LIB_ENTRY_start {",
" if {[info exists ::mom_lift_off_output] && $::kapp_vars(retract_start) == 0} {",
" kapp_retract_subpgm",
" }",
" set ::kapp_vars(retract_start) 0",
"}",
" KappRetractSubPgm",
]
source = "\n".join(source_lines)
uri = Path(tmp_path / "event_children.tcl").as_uri()
LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source))
params = lsp.DocumentSymbolParams(text_document=lsp.TextDocumentIdentifier(uri=uri))
symbols = document_symbols(params)
events = [s for s in symbols if s.kind == lsp.SymbolKind.Event and s.name == "KappRetractSubPgm"]
assert events, "Expected event symbol for KappRetractSubPgm"
ev = events[0]
assert ev.children is not None
# Ensure the set variable is a child of the event
child_names = {c.name for c in ev.children}
assert "::kapp_vars(retract_start)" in child_names
@@ -0,0 +1,116 @@
import sys
from pathlib import Path
# Ensure server/src is on the path for imports
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 lsp_server import LSP_SERVER, goto_definition # type: ignore
def _loc_to_tuple(loc: lsp.Location) -> tuple[str, int, int, int, int]:
"""Helper to normalize Location into a tuple for easy asserts."""
return (
loc.uri,
loc.range.start.line,
loc.range.start.character,
loc.range.end.line,
loc.range.end.character,
)
def _extract_first_location(result) -> lsp.Location | None:
if result is None:
return None
if isinstance(result, list):
return result[0] if result else None
return result
def test_goto_definition_same_file(tmp_path: Path):
source_lines = [
"proc add {a b} {",
" return [expr {$a + $b}]",
"}",
"",
"set x [add 1 2]",
]
source = "\n".join(source_lines)
uri = Path(tmp_path / "same.tcl").as_uri()
# Put a text document into the workspace
LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source)
)
# Position on the word 'add' in the last line
line_idx = 4
char_idx = source_lines[line_idx].index("add") + 1 # somewhere inside token
params = lsp.DefinitionParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=lsp.Position(line=line_idx, character=char_idx),
)
result = goto_definition(params)
loc = _extract_first_location(result)
assert loc is not None
assert loc.uri == uri
# Definition should be on line 0 at the token 'add'
start = loc.range.start
end = loc.range.end
assert start.line == 0
assert end.line == 0
assert source_lines[0][start.character : end.character] == "add"
def test_goto_definition_cross_file(tmp_path: Path):
# File A declares the proc
a_lines = [
"proc myproc {arg} {",
" return $arg",
"}",
]
a_src = "\n".join(a_lines)
a_path = tmp_path / "a.tcl"
a_uri = a_path.as_uri()
LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(uri=a_uri, language_id="tcl", version=1, text=a_src)
)
# Update indices for file A so proc_signatures gets populated
doc_a = LSP_SERVER.workspace.get_text_document(a_uri)
LSP_SERVER.update_poco_completion_for_file(doc_a)
# File B calls the proc
b_lines = [
"set y [myproc 42]",
]
b_src = "\n".join(b_lines)
b_uri = (tmp_path / "b.tcl").as_uri()
LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(uri=b_uri, language_id="tcl", version=1, text=b_src)
)
call_line = 0
call_char = b_lines[0].index("myproc") + 2
params = lsp.DefinitionParams(
text_document=lsp.TextDocumentIdentifier(uri=b_uri),
position=lsp.Position(line=call_line, character=call_char),
)
result = goto_definition(params)
loc = _extract_first_location(result)
assert loc is not None
assert loc.uri == a_uri
start = loc.range.start
end = loc.range.end
assert start.line == 0
assert a_lines[start.line][start.character : end.character] == "myproc"
@@ -0,0 +1,103 @@
import os
import sys
from pathlib import Path
# Ensure server/src is on the path for imports
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.proc_docs import build_proc_docs, is_proc_declaration_position
from lsp_server import LSP_SERVER, hover # type: ignore
def test_build_proc_docs_extracts_block_and_tags():
source = (
"#____________________________________________________________________________________________\n"
"# <Documentation>\n"
"# This procedure creates a new directory if it does not exist.\n"
"# <Arguments>\n"
"# directory\n"
"#\tThe full pathname of the directory to be created.\n"
"# <Returnvalue>\n"
"# 0 - directory created or already exists\n"
"# 1 - error\n"
"#______________________________________________________________________________________________\n"
"proc LIB_FH_create_directory {directory} {\n"
" return 0\n"
"}\n"
)
from tools.parser import CustomParser
tree = CustomParser().parse(source)
docs = build_proc_docs(tree, source)
assert "LIB_FH_create_directory" in docs
md = docs["LIB_FH_create_directory"]
# Tags become markdown headings
assert "### Documentation" in md
assert "### Arguments" in md
assert "### Return value" in md
# Content preserved
assert "creates a new directory" in md
def test_hover_shows_doc_on_usage_but_not_on_declaration(tmp_path: Path):
# Build a TCL file with a documented proc and a usage
source_lines = [
"#_________________________________________________________________________________________________",
"# <Documentation>",
"# Function to delete the file",
"#_________________________________________________________________________________________________",
"proc SERVICE_remove_file {file} {",
" if {![SERVICE_check_file_exists $file]} {return}",
" MOM_remove_file $file",
"}",
"",
"proc SERVICE_check_file_exists {file} {",
" if {[file exists $file]} {return 1}",
" return 0",
"}",
"",
"# usage below",
"SERVICE_remove_file \"C:/tmp/x\"",
]
source = "\n".join(source_lines)
# Register document with server
uri = Path(tmp_path / "test.tcl").as_uri()
LSP_SERVER.workspace.put_text_document(
lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source)
)
# Force server to parse and build proc docs
doc = LSP_SERVER.workspace.get_text_document(uri)
LSP_SERVER.update_poco_completion_for_file(doc)
# 1) Hover on usage -> should return docs
usage_line = source_lines.index("SERVICE_remove_file \"C:/tmp/x\"")
char_index = source_lines[usage_line].find("SERVICE_remove_file") + 5 # inside the token
params = lsp.HoverParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=lsp.Position(line=usage_line, character=char_index),
)
result = hover(params)
assert result is not None
assert "Function to delete the file" in result.contents.value # type: ignore[attr-defined]
# 2) Hover on declaration name -> should be None
decl_line = source_lines.index("proc SERVICE_remove_file {file} {")
decl_char = source_lines[decl_line].find("SERVICE_remove_file") + 2
assert is_proc_declaration_position(source, decl_line, decl_char)
params_decl = lsp.HoverParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=lsp.Position(line=decl_line, character=decl_char),
)
none_result = hover(params_decl)
assert none_result is None
@@ -0,0 +1,75 @@
import sys
from pathlib import Path
# Ensure server/src is on sys.path for imports
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.parser import CustomParser
from tools.proc_docs import build_proc_docs
def test_example_is_marked_as_tcl_code_block():
source_lines = [
"#____________________________________________________________________________________________",
"# <Documentation>",
"# This procedure creates a new directory if it does not exist.",
"# <Arguments>",
"# directory",
"#\tThe full pathname of the directory to be created.",
"# <Returnvalue>",
"# 0 - directory created or already exists",
"# 1 - error",
"# <Example>",
"# name: Example 1",
"# code: LIB_FH_create_directory \"C:/Temp/Test\"",
"# desc: If error = 0, the directory is created.",
"proc LIB_FH_create_directory {directory} {",
" return 0",
"}",
]
source = "\n".join(source_lines)
tree = CustomParser().parse(source)
docs = build_proc_docs(tree, source)
assert "LIB_FH_create_directory" in docs
md = docs["LIB_FH_create_directory"]
# Headings preserved
assert "### Documentation" in md
assert "### Arguments" in md
assert "### Return value" in md
assert "### Example" in md
# Code fence with tcl language hint and the code line present
assert "```tcl" in md
assert "LIB_FH_create_directory \"C:/Temp/Test\"" in md
assert md.strip().endswith("```")
def test_internal_example_is_marked_as_tcl_code_block():
source_lines = [
"# <Internal Documentation>",
"# Helper utility",
"# <Internal Example>",
"# code: puts \"hello\"",
"proc helper {} {",
" return",
"}",
]
source = "\n".join(source_lines)
tree = CustomParser().parse(source)
docs = build_proc_docs(tree, source)
assert "helper" in docs
md = docs["helper"]
assert "### Internal Documentation" in md
assert "### Internal Example" in md
assert "```tcl" in md and "puts \"hello\"" in md and md.strip().endswith("```")
+59 -214
View File
@@ -1,30 +1,19 @@
{
"fileTypes": [
"tcl"
],
"fileTypes": [ "tcl" ],
"name": "tcl",
"patterns": [
{
"include": "#main"
}
{ "include": "#main" }
],
"scopeName": "source.tcl",
"uuid": "c7017136-2ff2-48e9-bdb0-570cf238b4a2",
"repository": {
"main": {
"patterns": [
{
"include": "#command"
}
]
"patterns": [ { "include": "#command" } ]
},
"args": {
"patterns": [
{
"include": "#quoted_args"
},
{
"include": "#numeric"
},
{ "include": "#quoted_args" },
{ "include": "#numeric" },
{
"match": "((?<=;)\\s*#.*$)",
"name": "comment.tcl",
@@ -36,22 +25,10 @@
"patterns": [
{
"begin": "((?<!\\\\)\\{)",
"beginCaptures": {
"1": {
"name": "punctuation.tcl"
}
},
"patterns": [
{
"include": "#braced__1"
}
],
"end": "(\\}([^\\s\\]]*))",
"endCaptures": {
"1": {
"name": "punctuation.tcl"
}
}
"beginCaptures": { "1": { "name": "punctuation.tcl" } },
"patterns": [ { "include": "#braced__1" } ],
"end": "(\\}([^\\s\\]\\x{0022}]*))",
"endCaptures": { "1": { "name": "punctuation.tcl" } }
}
]
},
@@ -61,42 +38,24 @@
"match": "(\\\\(?:\\d{1,3}|x[a-fA-F0-9]{1,2}|u[a-fA-F0-9]{1,4}|U[a-fA-F0-9]{1,8}|.))",
"name": "constant.character.escape.tcl"
},
{
"include": "#keywords"
},
{ "include": "#keywords" },
{
"match": "((?<={)\\s*(?:after|append|apply|array|auto_execok|auto_import|auto_load|auto_mkindex|auto_qualify|auto_reset|bgerror|binary|break|catch|cd|chan|clock|close|concat|continue|coroutine|dde|dict|encoding|eof|error|eval|exec|exit|expr|fblocked|fconfigure|fcopy|fileevent|file|flush|foreach|for|format|gets|global|glob|history|http|if|incr|info|interp|join|lappend|lassign|lindex|linsert|list|llength|lmap|load|lrange|lrepeat|lreplace|lreverse|lsearch|lset|lsort|memory|my|namespace|nextto|next|oo::class|oo::copy|oo::define|oo::objdefine|oo::object|open|package|parray|pid|pkg::create|pkg_mkIndex|platform::shell|proc|puts|pwd|read|regexp|registry|regsub|rename|return|scan|seek|self|set|socket|source|split|string|subst|switch|tailcall|tcl::prefix|tcl_endOfWord|tcl_findLibrary|tcl_startOfNextWord|tcl_startOfPreviousWord|tcl_wordBreakAfter|tcl_wordBreakBefore|tell|throw|time|trace|try|unknown|unload|unset|update|uplevel|upvar|variable|vwait|while|yieldto|yield)\\s+)",
"name": "keyword.tcl",
"comment": "Special handling for known commands."
},
{
"include": "#braced_inner"
},
{
"include": "#args"
}
{ "include": "#braced_inner" },
{ "include": "#args" }
]
},
"braced_inner": {
"patterns": [
{
"begin": "(\\{)",
"beginCaptures": {
"1": {
"name": "punctuation.tcl"
}
},
"patterns": [
{
"include": "#braced_inner__1"
}
],
"beginCaptures": { "1": { "name": "punctuation.tcl" } },
"patterns": [ { "include": "#braced_inner__1" } ],
"end": "(\\})",
"endCaptures": {
"1": {
"name": "punctuation.tcl"
}
}
"endCaptures": { "1": { "name": "punctuation.tcl" } }
}
]
},
@@ -106,15 +65,9 @@
"match": "(\\\\[\\x{007b}\\x{007d}])",
"name": "constant.character.escape.tcl"
},
{
"include": "#numeric"
},
{
"include": "#braced_inner"
},
{
"include": "#args"
}
{ "include": "#numeric" },
{ "include": "#braced_inner" },
{ "include": "#args" }
]
},
"braced_lit": {
@@ -139,22 +92,10 @@
"patterns": [
{
"begin": "(\\\\\\s*$)",
"beginCaptures": {
"1": {
"name": "constant.character.escape.tcl"
}
},
"patterns": [
{
"include": "#command__1"
}
],
"beginCaptures": { "1": { "name": "constant.character.escape.tcl" } },
"patterns": [ { "include": "#command__1" } ],
"end": "((?<!\\\\\\s)$)",
"endCaptures": {
"1": {
"name": "none.tcl"
}
}
"endCaptures": { "1": { "name": "none.tcl" } }
},
{
"match": "(^\\s*#.*)",
@@ -169,73 +110,41 @@
{
"match": "(?<=^|{)(\\s*proc\\s+)(\\S+\\s+)((?:(?<!\\\\){)(?:[^\\x{007b}\\x{007d}\\n]*(?:\\g<1>(?:\\\\{|\\\\}|[^\\x{007b}\\x{007d}])*)*)})",
"captures": {
"1": {
"name": "keyword.tcl"
},
"2": {
"name": "entity.name.function.tcl"
},
"3": {
"name": "none.tcl"
}
"1": { "name": "keyword.tcl" },
"2": { "name": "entity.name.function.tcl" },
"3": { "name": "none.tcl" }
},
"comment": "Proc command."
},
{ "include": "#regexp" },
{ "include": "#keywords" },
{
"include": "#regexp"
},
{
"include": "#keywords"
},
{
"match": "(^\\s*\\S+|(?:(?<=[^\\x{005c}]\\[)[^\\s\\]]++(?!\\])))",
"match": "(^\\s*[^\\s\\x{0022}]+|(?:(?<=[^\\x{005c}]\\[)[^\\s\\]]+))",
"name": "keyword.tcl",
"comment": "All other commands. (NOTE: Iro doesn't support possessive quantifiers, but the grammar does; be sure to replace the second + with ++ after regenerating!)"
},
{
"include": "#args"
}
{ "include": "#args" }
]
},
"command__1": {
"patterns": [
{
"include": "#keywords"
},
{
"include": "#args"
}
{ "include": "#keywords" },
{ "include": "#args" }
]
},
"embedded": {
"patterns": [
{
"begin": "((?<!\\\\)\\[)",
"beginCaptures": {
"1": {
"name": "punctuation.tcl"
}
},
"patterns": [
{
"include": "#embedded__1"
}
],
"beginCaptures": { "1": { "name": "punctuation.tcl" } },
"patterns": [ { "include": "#embedded__1" } ],
"end": "((?<!\\\\)\\])",
"endCaptures": {
"1": {
"name": "punctuation.tcl"
}
}
"endCaptures": { "1": { "name": "punctuation.tcl" } }
}
]
},
"embedded__1": {
"patterns": [
{
"include": "#command"
}
]
"patterns": [ { "include": "#command" } ]
},
"keywords": {
"patterns": [
@@ -263,30 +172,16 @@
"patterns": [
{
"begin": "((?<!\\\\)\")",
"beginCaptures": {
"1": {
"name": "string.tcl"
}
},
"patterns": [
{
"include": "#quoted__1"
}
],
"beginCaptures": { "1": { "name": "string.tcl" } },
"patterns": [ { "include": "#quoted__1" } ],
"end": "(\")",
"endCaptures": {
"1": {
"name": "string.tcl"
}
}
"endCaptures": { "1": { "name": "string.tcl" } }
}
]
},
"quoted__1": {
"patterns": [
{
"include": "#quoted_args"
},
{ "include": "#quoted_args" },
{
"match": "([^\\x{0024}\\x{005b}\\x{0022}\\x{005c}]+)",
"name": "string.tcl"
@@ -295,22 +190,14 @@
},
"quoted_args": {
"patterns": [
{
"include": "#braced"
},
{
"include": "#quoted"
},
{
"include": "#embedded"
},
{ "include": "#braced" },
{ "include": "#quoted" },
{ "include": "#embedded" },
{
"match": "(\\\\(?:\\d{1,3}|x[a-fA-F0-9]{1,2}|u[a-fA-F0-9]{1,4}|U[a-fA-F0-9]{1,8}|.))",
"name": "constant.character.escape.tcl"
},
{
"include": "#variable"
}
{ "include": "#variable" }
]
},
"regexp": {
@@ -318,57 +205,27 @@
{
"begin": "((?<=^|[\\[\\x{007b}\\x{003b}])\\s*(?:regexp|regsub)\\s+)((?:(?:(?:-about|-all|-expanded|-indices|-inline|-lineanchor|-linestop|-line|-nocase|(?:-index\\s+(?:end|\\d+)(?:[\\+\\-]-?\\d+)?))\\s+)*(?:--\\s+)?)?)",
"beginCaptures": {
"1": {
"name": "keyword.tcl"
},
"2": {
"name": "none.tcl"
}
"1": { "name": "keyword.tcl" },
"2": { "name": "none.tcl" }
},
"patterns": [
{
"include": "#regexp__1"
}
],
"patterns": [ { "include": "#regexp__1" } ],
"end": "(\\s+)",
"endCaptures": {
"1": {
"name": "none.tcl"
}
}
"endCaptures": { "1": { "name": "none.tcl" } }
},
{
"include": "#args"
}
{ "include": "#args" }
]
},
"regexp__1": {
"patterns": [
{
"include": "#braced_lit_re"
},
{ "include": "#braced_lit_re" },
{
"begin": "((?:(?<!\\\\)\"))",
"beginCaptures": {
"1": {
"name": "string.regexp.tcl"
}
},
"patterns": [
{
"include": "#regexp__2"
}
],
"beginCaptures": { "1": { "name": "string.regexp.tcl" } },
"patterns": [ { "include": "#regexp__2" } ],
"end": "((?:(?<!\\\\)\"))",
"endCaptures": {
"1": {
"name": "string.regexp.tcl"
}
}
},
{
"include": "#variable"
"endCaptures": { "1": { "name": "string.regexp.tcl" } }
},
{ "include": "#variable" },
{
"match": "(\\S+)",
"name": "string.regexp.tcl",
@@ -378,15 +235,9 @@
},
"regexp__2": {
"patterns": [
{
"include": "#braced_lit_re"
},
{
"include": "#embedded"
},
{
"include": "#variable"
},
{ "include": "#braced_lit_re" },
{ "include": "#embedded" },
{ "include": "#variable" },
{
"match": "(\\\\\"|[^\\x{0022}])",
"name": "string.regexp.tcl",
@@ -404,15 +255,9 @@
},
"word_lit__1": {
"patterns": [
{
"include": "#braced_lit"
},
{
"include": "#embedded"
},
{
"include": "#variable"
},
{ "include": "#braced_lit" },
{ "include": "#embedded" },
{ "include": "#variable" },
{
"match": "(\\\\\"|[^\\x{0022}])",
"name": "string.tcl",