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
run: npm run package
- 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
run: |
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
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
# 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 }}'
if [[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]]; then
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
1. Install from the VS Code Marketplace
2. Install Python 3.8 or higher
3. Open any `.cdl`, `.tcl`, or `.def` file
4. The extension will automatically activate and provide language support
2. Open any `.cdl`, `.tcl`, or `.def` file
3. The extension will automatically activate and provide language support
## Configuration
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "nx-post-support",
"version": "0.3.0",
"version": "2025.9.100",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nx-post-support",
"version": "0.3.0",
"version": "2025.9.100",
"devDependencies": {
"@types/vscode": "^1.96.0",
"@vscode/vsce": "^3.2.1",
+1 -2
View File
@@ -1,11 +1,10 @@
{
"name": "nx-post-support",
"displayName": "NX Postprocessor Support",
"description": "VS Code extension for NX CAM postprocessor development with syntax highlighting, formatting, linting, and auto-completion for CDL, TCL, and DEF files",
"description": "",
"version": "2025.9.100",
"publisher": "Christoph",
"icon": "images/nx-1.png",
"extensionDependencies": ["ms-python.python"],
"serverInfo": {
"name": "NX Postprocessor Support",
"module": "nx-post-support"
+62 -47
View File
@@ -5,33 +5,23 @@
import json
import os
import pathlib
import sys
import tomllib
import urllib.request as url_lib
from typing import List
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:
deps = _read_dependencies()
session.run(
"uv",
"pip",
"install",
"--target",
session.install(
"-t",
"./libs",
"--no-cache-dir",
"--implementation",
"py",
"--no-deps",
"--upgrade",
*deps,
external=True,
"-r",
"./requirements.txt",
)
@@ -44,6 +34,24 @@ def _check_files(names: List[str]) -> None:
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):
json_uri = f"https://registry.npmjs.org/{package}"
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
# Ensure engine matches the package
if package_json["engines"]["vscode"] != package_json["devDependencies"]["@types/vscode"]:
print("Please check VS Code engine version and @types/vscode version in package.json.")
if (
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)
# 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:
"""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)
@nox.session()
def setup(session: nox.Session) -> None:
"""Sets up the template for development."""
session.install("pip<24")
_setup_template_environment(session)
@nox.session()
def tests(session: nox.Session) -> None:
"""Runs all the tests for the extension."""
deps = _read_dependencies()
session.run("uv", "pip", "install", "--python", sys.executable, *deps, external=True)
session.run("uv", "pip", "install", "--python", sys.executable, "pytest", external=True)
session.run("pytest", "tests/python_tests")
session.install("-r", "src/test/python_tests/requirements.txt")
session.run("pytest", "src/test/python_tests")
@nox.session()
def lint(session: nox.Session) -> None:
"""Runs linter and formatter checks on python files."""
deps = _read_dependencies()
session.run("uv", "pip", "install", "--python", sys.executable, *deps, external=True)
session.run(
"uv",
"pip",
"install",
"--python",
sys.executable,
"pytest",
"pylint",
"black",
"isort",
external=True,
)
session.install("-r", "./requirements.txt")
session.install("-r", "src/test/python_tests/requirements.txt")
session.install("pylint")
session.run("pylint", "-d", "W0511", "./bundled/tool")
session.run(
"pylint",
"-d",
"W0511",
"--ignore=./tests/python_tests/test_data",
"./tests/python_tests",
"--ignore=./src/test/python_tests/test_data",
"./src/test/python_tests",
)
session.run("pylint", "-d", "W0511", "noxfile.py")
# check formatting using black
session.install("black")
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")
# check import sorting using isort
session.install("isort")
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")
# check typescript code
@@ -153,12 +171,9 @@ def build_package(session: nox.Session) -> None:
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()
def update_packages(session: nox.Session) -> None:
"""Update Python and npm packages."""
_update_uv_lock(session)
"""Update pip and npm packages."""
session.install("wheel", "pip-tools")
_update_pip_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",
"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>+",
+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.inlay_hint import InlayHintGenerator
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
@@ -235,6 +236,22 @@ def semantic_tokens(params: lsp.SemanticTokensParams):
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)
def hover(params: lsp.HoverParams) -> lsp.Hover:
pos = params.position
+127 -6
View File
@@ -1,11 +1,12 @@
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
class Rules(Enum):
VALIDATION = "validation"
OPTIONAL_ARG_POSITION = "optinal_args"
UNDECLARED_VARIABLE = "undeclared-variable"
def __str__(self):
return self.value
@@ -21,10 +22,7 @@ class CommandArgsCheck(Visitor):
return self._violations
def visit_command(self, command: Command):
if (
not hasattr(command.routine, "contents")
or command.routine.contents != "proc"
):
if not hasattr(command.routine, "contents") or command.routine.contents != "proc":
return
if len(command.args) < 2:
return
@@ -51,7 +49,130 @@ class CommandArgsCheck(Visitor):
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():
checkers = (CommandArgsCheck(),)
checkers = (
CommandArgsCheck(),
UndeclaredVariableCheck(),
)
return checkers
+3
View File
@@ -39,6 +39,9 @@ class _Completion(Visitor):
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):
# 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)
def visit_command(self, command: Command):
+45 -13
View File
@@ -1,6 +1,6 @@
import enum
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
import attrs
from common.load_data import standard_items
@@ -14,6 +14,9 @@ class TokenModifier(enum.IntFlag):
definition = enum.auto()
declaration = enum.auto()
builtin = enum.auto()
globalvar = enum.auto()
reference = enum.auto()
write = enum.auto()
@attrs.define
@@ -101,6 +104,10 @@ class _Highlighter(Visitor):
if in_custom or in_standard:
line, col = routine.contents_pos
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":
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:
first_arg = command.args[0]
token_info = self._get_token_info(first_arg)
if token_info:
(line, col), length = token_info
self._tokens.append(
(
(
(line, col),
length,
"variable",
[TokenModifier.declaration],
)
)
)
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
if len(command.args) == 1 and token_info:
(line, col), length = token_info
mods = [TokenModifier.reference]
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:
first_arg = command.args[0]
if hasattr(first_arg, "pos") and hasattr(first_arg, "value"):
@@ -153,11 +173,12 @@ class _Highlighter(Visitor):
# Parameter kann einfaches Wort sein
if hasattr(child, "value") and child.value is not None:
line, col = child.pos
# Parameters are variables (locals): mark as variable with declaration
self._tokens.append(
(
(line - 1, col - 1),
len(child.value),
"parameter",
"variable",
[TokenModifier.declaration],
)
)
@@ -171,7 +192,7 @@ class _Highlighter(Visitor):
(
(line - 1, col - 1),
len(name_node.value),
"parameter",
"variable",
[TokenModifier.declaration],
)
)
@@ -181,6 +202,17 @@ class _Highlighter(Visitor):
line, col = first_arg.pos
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]:
"""Encode tokens as described in
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)