Compare commits

..
11 Commits
Author SHA1 Message Date
Christoph b9d12555b5 allow same version when update the version
/ build_and_publish (release) Successful in 40s
2025-08-16 18:33:17 +02:00
Christoph fe8d60cf64 2025.9.100 2025-08-16 18:31:21 +02:00
Christoph 3679a808c5 update readme
/ build_and_publish (release) Failing after 37s
2025-08-16 18:26:07 +02:00
Christoph 91fb97a7c0 Update README.md 2025-08-16 11:45:26 +02:00
Christoph 727ea1fa0d Relicense to AGPL-3.0-or-later 2025-08-16 11:37:54 +02:00
Christoph Brandau 4a94f1aea8 fix if var exits for incr 2025-08-13 12:45:46 +02:00
Christoph Brandau 781b240128 add signature help 2025-08-13 11:00:22 +02:00
Christoph Brandau 1a542cc9e9 add new semantic tokens 2025-08-13 09:26:35 +02:00
Christoph Brandau 5b2bf39cba fix foreach var 2025-08-13 09:26:22 +02:00
Christoph Brandau db8f849a06 add variable check 2025-08-13 07:41:54 +02:00
Christoph Brandau 2ce48ad558 add: Outline support for DEF and CDL files 2025-08-12 18:43:41 +02:00
14 changed files with 442 additions and 90 deletions
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
- name: Build Package Files - name: Build Package Files
run: npm run package run: npm run package
- name: Update package.json Version - name: Update package.json Version
run: npm version ${{ github.ref_name }} --no-git-tag-version run: npm version ${{ github.ref_name }} --no-git-tag-version --allow-same-version
- name: Commit and Push Changes - name: Commit and Push Changes
run: | run: |
git config user.name "${{ vars.USERNAME_GIT }}" git config user.name "${{ vars.USERNAME_GIT }}"
@@ -40,7 +40,7 @@ jobs:
# Get current branch; if detached, resolve to a remote branch that contains this commit # Get current branch; if detached, resolve to a remote branch that contains this commit
BRANCH="$(git rev-parse --abbrev-ref HEAD)" BRANCH="$(git rev-parse --abbrev-ref HEAD)"
if [[ "$BRANCH" == "HEAD" || -z "$BRANCH" ]]; then if [[ "$BRANCH" == "HEAD" || -z "$BRANCH" ]]; then
# If target_commitish is a branch, use it; if its a SHA, find a branch that contains it # If target_commitish is a branch, use it; if it's a SHA, find a branch that contains it
CANDIDATE='${{ github.event.release.target_commitish }}' CANDIDATE='${{ github.event.release.target_commitish }}'
if [[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]]; then if [[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]]; then
BRANCH="$(git branch -r --contains "$GITHUB_SHA" | sed -n 's|.*origin/||p' | head -n1)" BRANCH="$(git branch -r --contains "$GITHUB_SHA" | sed -n 's|.*origin/||p' | head -n1)"
+2 -3
View File
@@ -19,9 +19,8 @@ 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. Open any `.cdl`, `.tcl`, or `.def` file
3. Open any `.cdl`, `.tcl`, or `.def` file 3. The extension will automatically activate and provide language support
4. The extension will automatically activate and provide language support
## Configuration ## Configuration
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nx-post-support", "name": "nx-post-support",
"version": "0.3.0", "version": "2025.9.100",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nx-post-support", "name": "nx-post-support",
"version": "0.3.0", "version": "2025.9.100",
"devDependencies": { "devDependencies": {
"@types/vscode": "^1.96.0", "@types/vscode": "^1.96.0",
"@vscode/vsce": "^3.2.1", "@vscode/vsce": "^3.2.1",
+1 -2
View File
@@ -1,11 +1,10 @@
{ {
"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": "",
"version": "2025.9.100", "version": "2025.9.100",
"publisher": "Christoph", "publisher": "Christoph",
"icon": "images/nx-1.png", "icon": "images/nx-1.png",
"extensionDependencies": ["ms-python.python"],
"serverInfo": { "serverInfo": {
"name": "NX Postprocessor Support", "name": "NX Postprocessor Support",
"module": "nx-post-support" "module": "nx-post-support"
+62 -47
View File
@@ -5,33 +5,23 @@
import json import json
import os import os
import pathlib import pathlib
import sys
import tomllib
import urllib.request as url_lib import urllib.request as url_lib
from typing import List from typing import List
import nox # pylint: disable=import-error import nox # pylint: disable=import-error
def _read_dependencies() -> List[str]:
"""Read project dependencies from pyproject.toml."""
pyproject = tomllib.loads(pathlib.Path("pyproject.toml").read_text())
return list(pyproject.get("project", {}).get("dependencies", []))
def _install_bundle(session: nox.Session) -> None: def _install_bundle(session: nox.Session) -> None:
deps = _read_dependencies() session.install(
session.run( "-t",
"uv",
"pip",
"install",
"--target",
"./libs", "./libs",
"--no-cache-dir", "--no-cache-dir",
"--implementation",
"py", "py",
"--no-deps",
"--upgrade", "--upgrade",
*deps, "-r",
external=True, "./requirements.txt",
) )
@@ -44,6 +34,24 @@ def _check_files(names: List[str]) -> None:
raise Exception(f"Please update {os.fspath(file_path)}.") raise Exception(f"Please update {os.fspath(file_path)}.")
def _update_pip_packages(session: nox.Session) -> None:
session.install("wheel", "pip-tools==7.3.0")
session.run(
"pip-compile",
"--generate-hashes",
"--resolver=backtracking",
"--upgrade",
"./requirements.in",
)
session.run(
"pip-compile",
"--generate-hashes",
"--resolver=backtracking",
"--upgrade",
"./src/test/python_tests/requirements.in",
)
def _get_package_data(package): def _get_package_data(package):
json_uri = f"https://registry.npmjs.org/{package}" json_uri = f"https://registry.npmjs.org/{package}"
with url_lib.urlopen(json_uri) as response: with url_lib.urlopen(json_uri) as response:
@@ -72,8 +80,13 @@ def _update_npm_packages(session: nox.Session) -> None:
package_json["devDependencies"][package] = latest package_json["devDependencies"][package] = latest
# Ensure engine matches the package # Ensure engine matches the package
if package_json["engines"]["vscode"] != package_json["devDependencies"]["@types/vscode"]: if (
print("Please check VS Code engine version and @types/vscode version in package.json.") package_json["engines"]["vscode"]
!= package_json["devDependencies"]["@types/vscode"]
):
print(
"Please check VS Code engine version and @types/vscode version in package.json."
)
new_package_json = json.dumps(package_json, indent=4) new_package_json = json.dumps(package_json, indent=4)
# JSON dumps uses \n for line ending on all platforms by default # JSON dumps uses \n for line ending on all platforms by default
@@ -84,60 +97,65 @@ def _update_npm_packages(session: nox.Session) -> None:
def _setup_template_environment(session: nox.Session) -> None: def _setup_template_environment(session: nox.Session) -> None:
"""Install project dependencies into the bundled libs directory.""" session.install("wheel", "pip-tools")
session.run(
"pip-compile",
"--generate-hashes",
"--resolver=backtracking",
"--upgrade",
"./requirements.in",
)
# session.run(
# "pip-compile",
# "--generate-hashes",
# "--resolver=backtracking",
# "--upgrade",
# "./src/test/python_tests/requirements.in",
# )
_install_bundle(session) _install_bundle(session)
@nox.session() @nox.session()
def setup(session: nox.Session) -> None: def setup(session: nox.Session) -> None:
"""Sets up the template for development.""" """Sets up the template for development."""
session.install("pip<24")
_setup_template_environment(session) _setup_template_environment(session)
@nox.session() @nox.session()
def tests(session: nox.Session) -> None: def tests(session: nox.Session) -> None:
"""Runs all the tests for the extension.""" """Runs all the tests for the extension."""
deps = _read_dependencies() session.install("-r", "src/test/python_tests/requirements.txt")
session.run("uv", "pip", "install", "--python", sys.executable, *deps, external=True) session.run("pytest", "src/test/python_tests")
session.run("uv", "pip", "install", "--python", sys.executable, "pytest", external=True)
session.run("pytest", "tests/python_tests")
@nox.session() @nox.session()
def lint(session: nox.Session) -> None: def lint(session: nox.Session) -> None:
"""Runs linter and formatter checks on python files.""" """Runs linter and formatter checks on python files."""
deps = _read_dependencies() session.install("-r", "./requirements.txt")
session.run("uv", "pip", "install", "--python", sys.executable, *deps, external=True) session.install("-r", "src/test/python_tests/requirements.txt")
session.run(
"uv", session.install("pylint")
"pip",
"install",
"--python",
sys.executable,
"pytest",
"pylint",
"black",
"isort",
external=True,
)
session.run("pylint", "-d", "W0511", "./bundled/tool") session.run("pylint", "-d", "W0511", "./bundled/tool")
session.run( session.run(
"pylint", "pylint",
"-d", "-d",
"W0511", "W0511",
"--ignore=./tests/python_tests/test_data", "--ignore=./src/test/python_tests/test_data",
"./tests/python_tests", "./src/test/python_tests",
) )
session.run("pylint", "-d", "W0511", "noxfile.py") session.run("pylint", "-d", "W0511", "noxfile.py")
# check formatting using black # check formatting using black
session.install("black")
session.run("black", "--check", "./bundled/tool") session.run("black", "--check", "./bundled/tool")
session.run("black", "--check", "./tests/python_tests") session.run("black", "--check", "./src/test/python_tests")
session.run("black", "--check", "noxfile.py") session.run("black", "--check", "noxfile.py")
# check import sorting using isort # check import sorting using isort
session.install("isort")
session.run("isort", "--check", "./bundled/tool") session.run("isort", "--check", "./bundled/tool")
session.run("isort", "--check", "./tests/python_tests") session.run("isort", "--check", "./src/test/python_tests")
session.run("isort", "--check", "noxfile.py") session.run("isort", "--check", "noxfile.py")
# check typescript code # check typescript code
@@ -153,12 +171,9 @@ def build_package(session: nox.Session) -> None:
session.run("npm", "run", "vsce-package", external=True) session.run("npm", "run", "vsce-package", external=True)
def _update_uv_lock(session: nox.Session) -> None:
session.run("uv", "lock", "--upgrade", external=True)
@nox.session() @nox.session()
def update_packages(session: nox.Session) -> None: def update_packages(session: nox.Session) -> None:
"""Update Python and npm packages.""" """Update pip and npm packages."""
_update_uv_lock(session) session.install("wheel", "pip-tools")
_update_pip_packages(session)
_update_npm_packages(session) _update_npm_packages(session)
-14
View File
@@ -1,14 +0,0 @@
[project]
name = "nx-post-support-server"
version = "0.1.0"
description = "Python language server for NX Postprocessor Support"
requires-python = ">=3.8"
dependencies = [
"pygls",
"packaging",
"tclint",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
+16
View File
@@ -0,0 +1,16 @@
# This file is used to generate requirements.txt.
# NOTE:
# Use Python 3.8 or greater which ever is the minimum version of the python
# you plan on supporting when creating the environment or using pip-tools.
# Only run the commands below to manully upgrade packages in requirements.txt:
# 1) python -m pip install pip-tools
# 2) pip-compile --generate-hashes --resolver=backtracking --upgrade ./requirements.in
# If you are using nox commands to setup or build package you don't need to
# run the above commands manually.
# Required packages
pygls
packaging
# TODO: Add your tool here
tclint
+60
View File
@@ -0,0 +1,60 @@
#
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile --generate-hashes ./requirements.in
#
attrs==25.3.0 \
--hash=sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3 \
--hash=sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b
# via
# cattrs
# lsprotocol
cattrs==25.1.1 \
--hash=sha256:1b40b2d3402af7be79a7e7e097a9b4cd16d4c06e6d526644b0b26a063a1cc064 \
--hash=sha256:c914b734e0f2d59e5b720d145ee010f1fd9a13ee93900922a2f3f9d593b8382c
# via
# lsprotocol
# pygls
importlib-metadata==6.8.0 \
--hash=sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb \
--hash=sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743
# via tclint
lsprotocol==2023.0.1 \
--hash=sha256:c75223c9e4af2f24272b14c6375787438279369236cd568f596d4951052a60f2 \
--hash=sha256:cc5c15130d2403c18b734304339e51242d3018a05c4f7d0f198ad6e0cd21861d
# via pygls
packaging==25.0 \
--hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \
--hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f
# via -r ./requirements.in
pathspec==0.11.2 \
--hash=sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20 \
--hash=sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3
# via tclint
ply==3.11 \
--hash=sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3 \
--hash=sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce
# via tclint
pygls==1.3.1 \
--hash=sha256:140edceefa0da0e9b3c533547c892a42a7d2fd9217ae848c330c53d266a55018 \
--hash=sha256:6e00f11efc56321bdeb6eac04f6d86131f654c7d49124344a9ebb968da3dd91e
# via
# -r ./requirements.in
# tclint
tclint==0.6.0 \
--hash=sha256:8dd4d7b519e040c164615df8072cc4c28def4bfdc9d2a8672a280b0984b45fc3 \
--hash=sha256:f60d2378dd203c0ee1268e9f9138f17cb6106b8824b727e584f054c5932a87db
# via -r ./requirements.in
typing-extensions==4.14.1 \
--hash=sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36 \
--hash=sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76
# via cattrs
voluptuous==0.15.2 \
--hash=sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566 \
--hash=sha256:6ffcab32c4d3230b4d2af3a577c87e1908a714a11f6f95570456b1849b0279aa
# via tclint
zipp==3.23.0 \
--hash=sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e \
--hash=sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166
# via importlib-metadata
+1 -1
View File
@@ -192,7 +192,7 @@
] ]
}, },
{ {
"label": "MOM_add_to_line_buffer", "label": "MOM_add_to_block_buffer",
"kind": "function", "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.", "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>+", "format": "MOM_add_to_line_buffer <start|end> <value>+",
+17
View File
@@ -46,6 +46,7 @@ from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier
from tools.completion_items import completion, remove_existing_items, remove_shared_keys from tools.completion_items import completion, remove_existing_items, remove_shared_keys
from tools.inlay_hint import InlayHintGenerator from tools.inlay_hint import InlayHintGenerator
from tools.file_sourcing import get_all_psc_files, read_psc_file from tools.file_sourcing import get_all_psc_files, read_psc_file
from tools.signature_help import get_signature_help
from lsp_tclserver import TclLanguageServer from lsp_tclserver import TclLanguageServer
@@ -235,6 +236,22 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
return lsp.SemanticTokens(data=data) return lsp.SemanticTokens(data=data)
@LSP_SERVER.feature(
lsp.TEXT_DOCUMENT_SIGNATURE_HELP,
lsp.SignatureHelpOptions(trigger_characters=[" ", "\t", "[", ",", "(", "{", '"', "'"], retrigger_characters=list("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_:$\"'{}[]() ,\t")),
)
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)
# Merge proc signatures across files
merged_signatures = {}
for sigs in LSP_SERVER.proc_signatures.values():
merged_signatures.update(sigs)
return get_signature_help(document.source, tree, merged_signatures, params.position)
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_HOVER) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_HOVER)
def hover(params: lsp.HoverParams) -> lsp.Hover: def hover(params: lsp.HoverParams) -> lsp.Hover:
pos = params.position pos = params.position
+127 -6
View File
@@ -1,11 +1,12 @@
from enum import Enum from enum import Enum
from tclint.syntax_tree import Visitor, BareWord, Command from tclint.syntax_tree import Visitor, BareWord, Command, VarSub, QuotedWord, BracedWord
from tclint.violations import Violation, Rule from tclint.violations import Violation, Rule
class Rules(Enum): class Rules(Enum):
VALIDATION = "validation" VALIDATION = "validation"
OPTIONAL_ARG_POSITION = "optinal_args" OPTIONAL_ARG_POSITION = "optinal_args"
UNDECLARED_VARIABLE = "undeclared-variable"
def __str__(self): def __str__(self):
return self.value return self.value
@@ -21,10 +22,7 @@ class CommandArgsCheck(Visitor):
return self._violations return self._violations
def visit_command(self, command: Command): def visit_command(self, command: Command):
if ( if not hasattr(command.routine, "contents") or command.routine.contents != "proc":
not hasattr(command.routine, "contents")
or command.routine.contents != "proc"
):
return return
if len(command.args) < 2: if len(command.args) < 2:
return return
@@ -51,7 +49,130 @@ class CommandArgsCheck(Visitor):
found_optional = True found_optional = True
class UndeclaredVariableCheck(Visitor):
"""
Warn when inside a proc a variable is used ($var) that is neither:
- declared global in the proc via `global var`, nor
- referenced as namespaced global (e.g. $::var), nor
- assigned locally in the proc via `set var ...`, nor
- a proc argument.
"""
def __init__(self):
self._violations = []
def check(self, source, tree):
self._violations.clear()
# Traverse to find procs; inner scanning is handled per-proc
tree.accept(self, recurse=True)
return self._violations
def visit_command(self, command: Command):
# Only interested in procs
if not hasattr(command.routine, "contents"):
return
if command.routine.contents != "proc":
return
# Expect: proc <name> <args> <body>
if len(command.args) < 3:
return
proc_name_node = command.args[0]
args_node = command.args[1]
body_node = command.args[2]
# Collect proc arg names
arg_names = set()
if hasattr(args_node, "children"):
for child in args_node.children:
if isinstance(child, BareWord):
if child.contents:
arg_names.add(child.contents)
elif hasattr(child, "children") and child.children:
# Defaulted arg: first element should be the arg name
first = child.children[0]
if hasattr(first, "contents") and first.contents:
arg_names.add(first.contents)
# Two-pass scan over body: first collect locals (set) and declared globals
locals_set = set(arg_names)
declared_globals = set()
def _word_text(node) -> str | None:
if isinstance(node, BareWord) or isinstance(node, QuotedWord) or isinstance(node, BracedWord):
return node.contents
return getattr(node, "contents", None)
def _collect(node):
# Depth-first walk to collect set/global declarations
if isinstance(node, Command) and hasattr(node.routine, "contents"):
name = node.routine.contents
if name == "global":
for an in node.args:
text = _word_text(an)
if text:
declared_globals.add(text)
elif name == "set" and node.args:
# set var [value] - first arg is the variable name
first_arg = node.args[0]
text = _word_text(first_arg)
if text:
base = text.split("(", 1)[0] # Handle array syntax
if not base.startswith("::"):
locals_set.add(base)
elif name == "foreach" and len(node.args) >= 3:
# foreach varlist1 list1 ?varlist2 list2 ...? body
# Treat loop variables as locals within the proc
# Iterate pairs (varlist, list) over all but the last arg (body)
pair_args = node.args[:-1]
i = 0
while i + 1 < len(pair_args):
varnode = pair_args[i]
text = _word_text(varnode) or ""
# Split var list by whitespace if braced list, else single name
names = text.split()
for nm in names:
base = nm.split("(", 1)[0]
if base and not base.startswith("::"):
locals_set.add(base)
i += 2
# Recurse
for ch in getattr(node, "children", []):
_collect(ch)
_collect(body_node)
# Second pass: flag VarSub usages that are not accounted for
def _scan(node):
# 1) Variable substitutions like $var
if isinstance(node, VarSub):
var_name = node.value or ""
if var_name.startswith("::"):
return
base = var_name
if base not in locals_set and base not in declared_globals:
msg = f"Variable '{base}' used in proc is not set locally and not declared global; use 'global {base}' or '$::{base}'"
self._violations.append(Violation(Rules.UNDECLARED_VARIABLE, msg, node.pos, node.end_pos))
# 2) Commands that take a variable name as first argument, e.g., 'incr var [amount]'
elif isinstance(node, Command) and hasattr(node.routine, "contents"):
if node.routine.contents == "incr" and node.args:
first_arg = node.args[0]
text = _word_text(first_arg) or ""
base = text.split("(", 1)[0]
if base and not base.startswith("::"):
if base not in locals_set and base not in declared_globals:
msg = f"Variable '{base}' used in proc is not set locally and not declared global; use 'global {base}'"
self._violations.append(Violation(Rules.UNDECLARED_VARIABLE, msg, first_arg.pos, first_arg.end_pos))
for ch in getattr(node, "children", []):
_scan(ch)
_scan(body_node)
def get_checkers(): def get_checkers():
checkers = (CommandArgsCheck(),) checkers = (
CommandArgsCheck(),
UndeclaredVariableCheck(),
)
return checkers return checkers
+3
View File
@@ -39,6 +39,9 @@ class _Completion(Visitor):
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): if not any(ci.label == item.label for ci in self._custom_functions):
# For functions, attach a command to trigger signature help after completion
if item.kind == lsp.CompletionItemKind.Function and item.command is None:
item.command = lsp.Command(title="Trigger Signature Help", command="editor.action.triggerParameterHints")
self._custom_functions.append(item) self._custom_functions.append(item)
def visit_command(self, command: Command): def visit_command(self, command: Command):
+45 -13
View File
@@ -1,6 +1,6 @@
import enum import enum
from typing import List from typing import List
from tclint.syntax_tree import Visitor, QuotedWord, Command, BareWord from tclint.syntax_tree import Visitor, QuotedWord, Command, BareWord, VarSub
from tclint.commands import get_commands from tclint.commands import get_commands
import attrs import attrs
from common.load_data import standard_items from common.load_data import standard_items
@@ -14,6 +14,9 @@ class TokenModifier(enum.IntFlag):
definition = enum.auto() definition = enum.auto()
declaration = enum.auto() declaration = enum.auto()
builtin = enum.auto() builtin = enum.auto()
globalvar = enum.auto()
reference = enum.auto()
write = enum.auto()
@attrs.define @attrs.define
@@ -101,6 +104,10 @@ class _Highlighter(Visitor):
if in_custom or in_standard: if in_custom or in_standard:
line, col = routine.contents_pos line, col = routine.contents_pos
self._tokens.append((((line - 1, col - 1), len(name), "function", []))) self._tokens.append((((line - 1, col - 1), len(name), "function", [])))
# Highlight 'global' itself as a keyword
if name == "global":
line, col = routine.contents_pos
self._tokens.append((((line - 1, col - 1), len(name), "keyword", [])))
if routine.contents == "puts": if routine.contents == "puts":
line, col = routine.contents_pos line, col = routine.contents_pos
@@ -114,21 +121,34 @@ class _Highlighter(Visitor):
) )
) )
) )
# Variable declaration vs reference for 'set'
if routine.contents == "set" and command.args: if routine.contents == "set" and command.args:
first_arg = command.args[0] first_arg = command.args[0]
token_info = self._get_token_info(first_arg) token_info = self._get_token_info(first_arg)
if token_info: if token_info:
(line, col), length = token_info (line, col), length = token_info
self._tokens.append( mods = [TokenModifier.declaration, TokenModifier.write]
( self._tokens.append(((line, col), length, "variable", mods))
( # If there is exactly one argument, 'set var' is a read/reference
(line, col), if len(command.args) == 1 and token_info:
length, (line, col), length = token_info
"variable", mods = [TokenModifier.reference]
[TokenModifier.declaration], self._tokens.append(((line, col), length, "variable", mods))
) # Variable modification via 'incr var [amount]'
) if routine.contents == "incr" and command.args:
) first_arg = command.args[0]
token_info = self._get_token_info(first_arg)
if token_info:
(line, col), length = token_info
mods = [TokenModifier.write]
# Add global flag if explicitly namespaced
try:
name_text = getattr(first_arg, "value", None) or getattr(first_arg, "contents", None) or ""
if isinstance(name_text, str) and name_text.startswith("::"):
mods.append(TokenModifier.globalvar)
except Exception:
pass
self._tokens.append(((line, col), length, "variable", mods))
if routine.contents == "proc" and command.args: if routine.contents == "proc" and command.args:
first_arg = command.args[0] first_arg = command.args[0]
if hasattr(first_arg, "pos") and hasattr(first_arg, "value"): if hasattr(first_arg, "pos") and hasattr(first_arg, "value"):
@@ -153,11 +173,12 @@ class _Highlighter(Visitor):
# Parameter kann einfaches Wort sein # Parameter kann einfaches Wort sein
if hasattr(child, "value") and child.value is not None: if hasattr(child, "value") and child.value is not None:
line, col = child.pos line, col = child.pos
# Parameters are variables (locals): mark as variable with declaration
self._tokens.append( self._tokens.append(
( (
(line - 1, col - 1), (line - 1, col - 1),
len(child.value), len(child.value),
"parameter", "variable",
[TokenModifier.declaration], [TokenModifier.declaration],
) )
) )
@@ -171,7 +192,7 @@ class _Highlighter(Visitor):
( (
(line - 1, col - 1), (line - 1, col - 1),
len(name_node.value), len(name_node.value),
"parameter", "variable",
[TokenModifier.declaration], [TokenModifier.declaration],
) )
) )
@@ -181,6 +202,17 @@ class _Highlighter(Visitor):
line, col = first_arg.pos 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", [])))
# Highlight variables used via $var and $::var as references (read)
for arg in getattr(command, "args", []):
for child in getattr(arg, "children", []):
if isinstance(child, VarSub) and hasattr(child, "pos") and child.value:
line, col = child.pos
# Determine if this looks like a global reference ($::var)
mods = [TokenModifier.reference]
if child.value.startswith("::"):
mods.append(TokenModifier.globalvar)
self._tokens.append((((line - 1, col - 1), len(child.value), "variable", mods)))
def tokens(self) -> list[Token]: def tokens(self) -> list[Token]:
"""Encode tokens as described in """Encode tokens as described in
https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens. https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_semanticTokens.
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Dict, List, Optional, Tuple
import lsprotocol.types as lsp
from tclint.syntax_tree import Visitor, Command, Node
class _CommandAtPositionFinder(Visitor):
def __init__(self, line1: int, col1: int, lines: list[str]):
# Cursor position in 1-based line/col to match AST
self.line1 = line1
self.col1 = col1
self.lines = lines
self.match: Optional[Command] = None
def _pos_le(self, a: Tuple[int, int] | None, b: Tuple[int, int] | None) -> bool:
if a is None or b is None:
return False
return a[0] < b[0] or (a[0] == b[0] and a[1] <= b[1])
def _pos_ge(self, a: Tuple[int, int] | None, b: Tuple[int, int] | None) -> bool:
if a is None or b is None:
return False
return a[0] > b[0] or (a[0] == b[0] and a[1] >= b[1])
def _contains_or_trailing(self, node: Node) -> bool:
# Standard containment
if self._pos_le(node.pos, (self.line1, self.col1)) and self._pos_ge(node.end_pos, (self.line1, self.col1)):
return True
# Extend containment to trailing whitespace on same line as the command end
if getattr(node, "end_pos", None) and node.end_pos and node.end_pos[0] == self.line1 and self.col1 >= node.end_pos[1]:
try:
line = self.lines[self.line1 - 1]
except Exception:
line = ""
start = max(0, node.end_pos[1] - 1)
end = max(0, min(len(line), self.col1 - 1))
segment = line[start:end]
# If there is a command terminator (';' or ']') between end_pos and cursor, do not extend
if ";" not in segment and "]" not in segment:
return True
return False
def visit_command(self, command: Command):
if hasattr(command, "pos") and hasattr(command, "end_pos") and self._contains_or_trailing(command):
# Prefer the deepest command: overwrite and continue
self.match = command
# Continue traversal
for ch in getattr(command, "children", []):
ch.accept(self, recurse=True)
def _compute_active_parameter(cmd: Command, line1: int, col1: int) -> int:
# Count which arg contains the cursor; else number of args before cursor
for idx, arg in enumerate(getattr(cmd, "args", []) or []):
if getattr(arg, "pos", None) and getattr(arg, "end_pos", None):
if (arg.pos[0] < line1 or (arg.pos[0] == line1 and arg.pos[1] <= col1)) and (arg.end_pos[0] > line1 or (arg.end_pos[0] == line1 and arg.end_pos[1] >= col1)):
return idx
# Not inside any arg; compute based on separator position
count = 0
for arg in getattr(cmd, "args", []) or []:
if getattr(arg, "end_pos", None):
if arg.end_pos[0] < line1 or (arg.end_pos[0] == line1 and arg.end_pos[1] <= col1):
count += 1
return min(count, max(0, len(getattr(cmd, "args", [])) - 1))
def get_signature_help(
document_text: str,
tree,
merged_signatures: Dict[str, List[str]],
position: lsp.Position,
) -> Optional[lsp.SignatureHelp]:
# Convert to 1-based coordinates expected by AST
line1 = position.line + 1
col1 = position.character + 1
# Find the innermost command that contains the position
# Pass lines for trailing-space containment handling
lines = document_text.splitlines()
finder = _CommandAtPositionFinder(line1, col1, lines)
tree.accept(finder, recurse=True)
cmd = finder.match
if not cmd or not hasattr(cmd, "routine") or not hasattr(cmd.routine, "contents"):
return None
routine_name = cmd.routine.contents
if not routine_name:
return None
params = merged_signatures.get(routine_name)
if not params:
# No known signature for this routine
return None
# Build a signature info
label = f"{routine_name}({', '.join(params)})"
parameters = [lsp.ParameterInformation(label=p) for p in params]
active_param = _compute_active_parameter(cmd, line1, col1)
active_param = max(0, min(active_param, len(params) - 1))
sig = lsp.SignatureInformation(label=label, parameters=parameters)
return lsp.SignatureHelp(signatures=[sig], active_signature=0, active_parameter=active_param)