Compare commits

...
53 Commits
Author SHA1 Message Date
Christoph 6edd2d3a2e Delete .gitea/workflows/recover.yaml
Tests / python (push) Successful in 7s
Tests / node (push) Successful in 9s
2026-09-26 19:53:33 +00:00
Christoph 2bc9122d79 Update .gitea/workflows/recover.yaml 2026-09-25 23:07:26 +00:00
Christoph 6fb849b7c0 Add .gitea/workflows/recover.yaml 2026-09-25 23:06:41 +00:00
Christoph 94bffa1f35 Merge pull request 'Show effective (last-loaded) .def declaration in hover and GoTo' (#50) from enhancements into main 2026-09-25 19:39:33 +00:00
Christoph c8466d2e32 feat(def-navigation): surface last-loaded .def declaration for hover/GoTo
When a block template or address is declared in multiple PSC layers, hover and
Go to Definition now resolve to the effective declaration (the one from the
last-loaded .def file). Find References and Rename still include all
declarations.

- add effective_def_locations(documents, target, uris) helper (returns the last declaration)
- use effective_def_locations in lsp_server for goto/definition endpoints
- change def_hover_markdown to show the effective declaration and append a short
  "_Overrides ..._" note listing overridden declarations
- update changelog and add a test that verifies hover/GoTo reference the last
  loaded declaration while references include all declarations
2026-09-25 21:38:06 +02:00
Christoph e9af7b4bb5 Update version to 2026.9.900 2026-09-25 08:09:15 +00:00
Christoph e539d78902 Merge pull request 'Recognize stored COMMANDBLOCK procs and warn on unknown .def names' (#49) from enhancements into main 2026-09-25 06:46:47 +00:00
Christoph 525a08c289 feat(tools): recognize procedures stored in COMMANDBLOCK properties
Add a new module that parses PostConfigurator COMMANDBLOCK values (CONF_* set ...)
to extract the first word of braced list elements as procedure names with precise
line/column spans.

- server/src/tools/stored_procs.py: implement stored_command_names(command)
  which returns (name, line, column) for static/braced list elements.
- Integrate into navigation (build_file_symbol_index) to index these names as
  non-definitions so Go To Definition / Find References can resolve them.
- Integrate into semantic highlighting to mark known stored procedures as
  functions when appropriate.
- Add tests (server/tests/python_tests/test_stored_procs.py) covering parsing,
  goto-definition, references, and highlighting behavior.
- Update CHANGELOG to note the new capability.

Notes/constraints:
- Only static/braced COMMANDBLOCK values (BracedWord) are considered.
- Names must match the command-name pattern and are taken from the first word
  of each list element.
2026-09-25 08:43:42 +02:00
Christoph 82951a3911 feat(lsp): warn on undeclared block templates and addresses
Add diagnostics that warn when a literal NX command argument names a block
template or address that no loaded .def file declares (diagnostic codes
unknown-block-template and unknown-address). Names computed at runtime
(e.g. $var or "CYCLE_$x") are not checked, and kinds whose .def file is
not loaded are skipped.

The server now recomputes and refreshes diagnostics when .def documents
change (clears cached diagnostics and requests a workspace diagnostic
refresh if the client supports it). Tests cover positive, negative and
invalidated-cache cases.
2026-09-25 08:37:09 +02:00
Christoph dde96506dd Merge pull request 'Recognize derived .def names and show .def previews on completion resolve' (#48) from enhancements into main 2026-09-25 06:17:47 +00:00
Christoph 3f9091c713 ci: add test workflow to run Python and Node jobs
Add a Gitea workflow that runs on pull requests and pushes to main. It defines two jobs:
- Python: sets up Python 3.12, installs pytest, and runs language-server tests under server/tests/python_tests with PYTHONPATH=libs.
- Node: sets up Node 20, installs npm deps (root and client), runs extension tests (node --test test/) and builds the extension (npm run package).

Workflow file added at .gitea/workflows/tests.yml.
2026-09-25 08:15:30 +02:00
Christoph 5b027a2717 feat(def_flow): recognize .def names propagated via variables and wrapper procs
Add a new def_flow analysis module that follows .def block template
and address names through local variables and proc parameters, and a
wrapper-table builder to resolve proc arguments that forward .def names.
Derived names are resolved only for hover/definition (not for rename).

Integrate this into the LSP:
- lsp_server: add _word_at and _tcl_def_symbol helpers; fallback to
  derived_def_symbol when direct NX-argument navigation fails for hover,
  goto-definition and references; return proper ranges for hover.
- lsp_tclserver: cache and expose a def_wrapper_table built from index
  def_flows (with cache invalidation on index generation).

Also add unit tests for def_flow and update CHANGELOG to note hover/
definition and completion preview improvements for derived names.
2026-09-25 08:14:59 +02:00
Christoph 17fb79a346 feat(lsp): support completion item resolve to show .def previews
Enable completion item resolution so selecting a completion can populate
documentation with a preview of the corresponding .def declaration.

- Turn on resolve_provider in the completion options.
- Implement on_completion_resolve to attach Markdown documentation
  computed by def_hover_markdown when the completion item's data
  contains a ("def" -> [kind, name]) payload.
- Include that "def" payload when generating def-related completion items.
- Add tests that verify block-template and address-list items resolve to
  Markdown previews.

Before: def-related completion items had no documentation on resolve.
After: selecting such items will return a Markdown preview of the .def entry.
2026-09-25 07:58:56 +02:00
Christoph 0a8e8a7368 test(completion_context): update test expectations and reflow formatting
Adjust tests to match current completion item labels and clean up formatting:
- Expect "nocomplain" (no leading dash) for the unset option case instead of {"-nocomplain", "--"}.
- Reflow several multi-line source literals into single-line/f-string forms.
- Normalize various assertions and generator expressions onto single lines.
- Reformat TextDocumentItem construction for readability.

These changes are limited to test code and reflect the updated shape/labels of completion items and stylistic cleanup; no production logic is modified.
2026-09-24 23:05:49 +02:00
Christoph bdeb0d946d Update version to 2026.9.801 2026-09-24 21:02:12 +00:00
Christoph bd9c73452e Merge pull request 'Add .def navigation helpers and tests' (#47) from bug_fix into main
build_and_puplish.yml / build_and_publish (release) Successful in 39s
2026-09-24 21:01:33 +00:00
Christoph a393ca3aec feat(tools): add .def navigation helpers and tests
Add tools/def_navigation.py providing utilities to locate .def symbols
and occurrences, produce definition/reference locations, build hover
Markdown for addresses (property table with format links and modality
labels), and compute workspace edits for renames (only when a
declaration exists). Helpers include def_symbol_at, def_definition_locations,
def_reference_locations, tcl_def_occurrences, all_def_target_locations,
def_rename_edits and def_hover_markdown, plus small formatting helpers.

Also add server/tests/python_tests/test_def_navigation.py exercising
go-to-definition, hover, references and rename behavior between Tcl and
.def files, including use of unsaved text and ensuring undeclared names
are not renamed. Tests assert address property ordering, modality labels,
and correct edit ordering for workspace edits.
2026-09-24 23:01:05 +02:00
Christoph 85a8684254 Update version to 2026.9.800 2026-09-24 20:57:15 +00:00
Christoph 0fe03c20f7 Merge pull request 'Cross-file navigation and rename for PSC .def templates and addresses' (#46) from DEF_file into main
build_and_puplish.yml / build_and_publish (release) Successful in 34s
2026-09-24 20:56:27 +00:00
Christoph 513b1cb340 feat(def): add cross-file navigation and rename for PSC .def block templates and addresses
Add client and server support to navigate, inspect, reference, and rename block
template and address symbols declared in PSC .def files:

- Client: register language providers for .def (definition, hover, references,
  prepare/provide rename) and send the current document text with each request.
- Server: parse .def files into DefDocument/DefDeclaration/DefReference, expose
  def-specific LSP endpoints (definition/hover/references/prepareRename/rename),
  and integrate .def lookups into existing Tcl hover/definition/references/rename
  flows so Tcl calls jump to .def declarations.
- Tcl server keeps a snapshot API for .def documents (current editor content can
  replace file on request); only declared names in loaded .def files can be
  renamed. Name validation uses DEF_NAME_RE.

Update README and CHANGELOG to document navigation features.
2026-09-24 22:55:40 +02:00
Christoph 1d36b3bb77 feat(tcl_command_completion): add completions for MOM_do_template and enable/disable address
Add static value suggestions for MOM_do_template's second argument (CREATE, BUFFER)
and a dynamic completion rule marking that position as a VALUE.

Also add dynamic completion rules to provide ADDRESS completions for
MOM_disable_address (positions 1–32) and for repeated address arguments of
MOM_enable_address.

Before: these arguments had no completion assistance.
After: the CLI completion subsystem will offer appropriate VALUE/ADDRESS suggestions.
2026-09-24 22:54:55 +02:00
Christoph 12e6d27331 Update version to 2026.9.701 2026-09-24 13:19:24 +00:00
Christoph e5c1ba1908 docs(changelog): add release notes through 2026.9.700
build_and_puplish.yml / build_and_publish (release) Successful in 30s
Populate CHANGELOG with detailed release entries up to 2026.9.700 and tidy historical notes. The new content documents recent features and fixes (notably DEF block template/address handling, the BLOCK_LIST/ADDR_LIST completion keywords, comment spacing/formatting changes, and the persistent workspace index), and cleans up formatting and older release headings for readability.
2026-09-24 15:17:02 +02:00
Christoph 81fe58e023 docs: document DEF block templates, addresses, and formatting changes
Update the README and CHANGELOG to describe newly documented language-server behaviors and editor UX details.

- Read BLOCK_TEMPLATE and ADDRESS names from .def files listed under <DefinedEvents> in PSC layers and refresh on PSC/DEF changes; completion suggestions for MOM_do_template, MOM_ask_address_value, MOM_force, and MOM_suppress
- Insert quoted block templates/addresses and Always|Once|Off modes (replace auto-closed/typed quotes instead of doubling) and insert variables with a leading `$`
- Add BLOCK_LIST and ADDR_LIST completion keywords to list all loaded block templates or addresses
- Document formatting/linting of uplevel bodies as Tcl and normalization of comment spacing (#Comment → # Comment) while preserving ##, #!, and already spaced comments
- Note that the Python debug bootstrap now stops with a clear error instead of hanging when no debugpy listener or only a stale one is available

These changes are reflected under "Unreleased" in the changelog and in the README section about DEF block templates and addresses.
2026-09-24 14:34:23 +02:00
Christoph 4c1837c557 Update version to 2026.9.700 2026-09-24 12:28:41 +00:00
Christoph a1eccf90f4 Merge pull request 'Complete BLOCK_LIST/ADDR_LIST completions and format comment spacing' (#45) from dynamic_snippets into main
build_and_puplish.yml / build_and_publish (release) Successful in 29s
2026-09-24 12:27:55 +00:00
Christoph da9091fa38 feat: complete BLOCK_LIST/ADDR_LIST keywords and space comments
Add keyword-driven completions for BLOCK_LIST and ADDR_LIST in the LSP server.
- While typing a prefix of these keywords the server returns the keyword(s)
  as incomplete snippet suggestions that trigger a re-request.
- Once the keyword is typed the server replaces it with a full list of the
  corresponding loaded block templates or addresses (quoted), and sets each
  item's filter_text to include the keyword so further typing narrows results.
- Integrate this flow into on_completion and factor the symbol-list logic
  into a helper that returns either incomplete keyword suggestions or the
  completed symbol list.

Also implement NxFormatter.format_comment to ensure a single space after a
leading '#' for comments that don't already start with whitespace, while
leaving sequences of '#' (separators), shebangs, and already-spaced comments
unchanged. This affects both standalone and inline comments.

Add/rename tests to cover the new completions and comment-spacing behavior.
2026-09-24 13:57:19 +02:00
Christoph 439c700226 Update version to 2026.9.600 2026-09-24 05:57:44 +00:00
Christoph fa13531668 Merge pull request 'Add persistent index, incremental reparse, and .def language support' (#44) from performance into main
build_and_puplish.yml / build_and_publish (release) Successful in 34s
2026-09-24 05:54:10 +00:00
Christoph 4f11ed7ecf chore(vscode): add Linux debug launch configurations and tasks, update lockfile 2026-09-24 07:53:15 +02:00
Christoph c1355970bf test(server): add tests for .def completions and uplevel formatting 2026-09-24 07:53:15 +02:00
Christoph 3167c18bea fix(server/debug): robust debugpy attach with bounded wait and tests update 2026-09-24 07:53:15 +02:00
Christoph 0666a87d15 feat(client/server): watch .def files in the client file watcher 2026-09-24 07:53:15 +02:00
Christoph ca7c23a0f6 feat(tools/parser): parse uplevel bodies for formatting and linting 2026-09-24 07:53:15 +02:00
Christoph 5d23df8da7 feat(server/lsp_server): include .def file changes and offer quoted def/variable completions 2026-09-24 07:53:15 +02:00
Christoph de310a6604 feat(tools/tcl_command_completion): support def symbols and value completions 2026-09-24 07:53:15 +02:00
Christoph abf32a5e50 feat(server/lsp_tclserver): index .def symbols and provide completion items 2026-09-24 07:53:15 +02:00
Christoph aa0780dd51 feat(tools/file_sourcing): read PSC DefinedEvents and expose .def resolution 2026-09-24 07:53:15 +02:00
Christoph b2656599b2 feat(tools/def_symbols): add parser for .def block templates and addresses 2026-09-24 07:53:15 +02:00
Christoph Brandau 01e8670cc1 feat(indexing): add persistent index cache and incremental reparse
- Pass extension storage path to the server (client/ changes) so the
  server can persist a workspace index.
- Introduce IndexCache (server/tools/index_cache.py) and load/save it on
  initialization and after background indexing. Index entries are stored
  only when the file's stat hasn't changed while being read.
- Add incremental reparse logic (server/tools/incremental_parse.py) and
  use a per-file _last_parse cache in the language server to reparse only
  the top-level Tcl commands touched by an edit, falling back to a full
  parse when necessary.
- Use a new _FileIndex dataclass and _build_file_index helper to unify
  what is stored/loaded for a file; update update_poco_completion_for_file
  to use the persistent cache for disk-read files (from_disk/source_stat).
- Keep background indexing non-blocking and persist the index at the
  end of the run. Add basic unit tests for incremental parse and index cache.

Before: edits and background work always required full parsing of files
and no persistent cross-restart index. After: some edits reuse previous
ASTs and files read from disk can use a persisted index to skip
re-indexing across restarts.
2026-09-23 13:20:09 +02:00
Christoph Brandau b2e6e9d250 perf(server): avoid unnecessary reparses and cache navigation definitions
Introduce several changes to reduce full-document reparses, lock contention and
redundant work when handling TclOO analysis and navigation:

- Add a cheap may_contain_classes pre-check and several cursor/receiver
  heuristics so completions, signature help and tcloo definitions skip the
  expensive marker reparse when the document cannot contain useful OO info.
- Allow passing an existing parsed tree into tcloo completion/signature/definition
  helpers; update callers to use the server's cached tree when available.
- Use a thread-local parser for request-time parse_source to avoid blocking the
  shared parser during background indexing, and add navigation_state() which
  returns cached definition identities (invalidated on index generation changes).
- Add a cheap name pre-filter (_may_resolve_to) for symbol matching and only
  run class highlighting when classes may exist.

These changes reduce contention and repeated parsing, improve responsiveness for
requests during background indexing, and cache navigation definition identities.
Tests were added/updated to assert caching and non-blocking behavior.
2026-09-23 12:04:36 +02:00
Christoph d9d619c1dc Update version to 2026.9.501 2026-09-21 19:14:25 +00:00
Christoph b6228503fc Merge pull request 'Highlight braced stored-proc names in lappend arguments' (#42) from #41 into main
build_and_puplish.yml / build_and_publish (release) Successful in 32s
2026-09-21 19:13:31 +00:00
Christoph Brandau 4de05caac7 fix(semantic_tokens): highlight braced stored-proc names in lappend
Treat braced lappend arguments that match custom or standard procedure names as
function tokens for semantic highlighting. This applies the same visual
highlighting as calls but does not treat the literal as executable Tcl. Previously
such braced stored-proc names were not highlighted.
2026-09-21 21:12:47 +02:00
Christoph 5c3ebc4c82 Update version to 2026.9.500 2026-09-21 18:54:48 +00:00
Christoph 5e5c7dcc42 Merge pull request 'Index PSC scripts and add TclOO cross-file navigation/completions; handle CDL TOGGLE Off' (#40) from add_class_support into main
build_and_puplish.yml / build_and_publish (release) Successful in 38s
2026-09-21 18:53:52 +00:00
Christoph Brandau 84821d2852 feat(cdl): allow INVALID in CATEGORY pattern
Accept "INVALID" as a valid token in the CATEGORY grammar rule (both the initial
and repeated entries). Also compacted several JSON objects (pattern includes
and "captures" entries) in syntaxes/cdl.tmLanguage.json; these formatting
changes are non-functional.
2026-09-21 20:52:53 +02:00
Christoph Brandau 547da644ce feat(cdlEventHandler): detect TOGGLE Off and emit *_defined globals
Record parameters marked with "TOGGLE Off" by adding an optional
toggleOffParameterNames field and including an extra <var>_defined global
for each such parameter in createCdlEventHandlerSnippet.

To support this, the parser was changed from a brace-delta approach to a
token-based scanner so nesting and the association between a PARAM and its
TOGGLE can be tracked reliably. Added unit tests that verify detection,
case-insensitivity, and ignoring TOGGLE occurrences inside comments/strings
or other events.
2026-09-21 20:51:02 +02:00
Christoph Brandau 757b885f28 feat(server): index PSC scripts and provide cross-file TclOO navigation/completions
Add PSC (.psc) indexing and share TclOO class metadata across files so class
definitions discovered via PSC layers can be used for completions, signature
help, inlay hints, and "go to definition". Key behavior changes:

- Client file watcher now includes *.psc and .vscode launch paths/tests updated
  to use the postprocessor test folder; .gitignore updated to ignore that folder.
- Server watches .psc changes and refreshes a PSC script index; new
  tools/tcloo_navigation.py exposes tcloo_definition used by the language server
  to resolve cross-file class/constructor/method definitions.
- Language server uses class_snapshot(document.path) when producing TclOO
  completions, signature help, and inlay hints so resolved class metadata is
  available across files.

Also includes related docs/changelog updates, minor code formatting cleanups,
and added tests for PSC/TclOO behavior.
2026-09-21 20:47:51 +02:00
Christoph 3762a01d40 Update version to 2026.9.400 2026-09-11 21:40:33 +00:00
Christoph 91b86f29cb Merge pull request 'Add TclOO class completions, signature help and inlay hints' (#39) from add_class_support into main
build_and_puplish.yml / build_and_publish (release) Successful in 39s
2026-09-11 21:39:34 +00:00
Christoph Brandau f88a50d4ab feat(tcloo): add document-local TclOO completions, signature help and hints
Add conservative, document-local TclOO type inference and tooling so the server
can offer method completions, signature help, and inlay parameter hints for
statically resolvable TclOO receivers (including `new`/`create`, `my`, and
simple return-chains). Also surface class names as completion items and emit
semantic tokens for class declarations/references.

Notable changes:
- New tcloo_* tools: completion, symbols, and argument parsing; integrated into
  on_completion, signature_help, inlay hint generation and semantic token
  highlighting. Completions are returned early when an OO receiver context is
  detected.
- Use a completion-friendly parser fallback when the main AST fails (TclSyntaxError)
  so editing-in-progress code still yields useful completions.
- Add CompletionItemKind.Class to command kinds, exclude class items from the
  poco completion name cache, and include new unit tests for the TclOO helpers.
2026-09-11 23:38:42 +02:00
Christoph c28836933c Update version to 2026.9.300 2026-09-10 08:31:25 +00:00
52 changed files with 5711 additions and 714 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Tests
on:
pull_request:
push:
branches: [main]
jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install pytest
run: pip install pytest
- name: Run language server tests
working-directory: server
# The server's dependencies are bundled in server/libs.
env:
PYTHONPATH: libs
run: python -m pytest tests/python_tests -q
node:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install NodeJS
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install NPM Packages
run: |
npm ci
cd ./client
npm ci
- name: Run extension tests
run: node --test test/
- name: Build extension
run: npm run package
+2 -1
View File
@@ -9,4 +9,5 @@ __pycache__
.nox .nox
*.g4 *.g4
.antlr .antlr
.claude .claude
/test/postprocessor/
+56 -4
View File
@@ -13,7 +13,7 @@
"args": [ "args": [
"--extensionDevelopmentPath=${env:TEMP}/nx-post-support-vscode-debug", "--extensionDevelopmentPath=${env:TEMP}/nx-post-support-vscode-debug",
"${env:TEMP}/nx-post-support-vscode-debug", "${env:TEMP}/nx-post-support-vscode-debug",
"${env:TEMP}/nx-post-support-vscode-debug/test/test.tcl" "${env:TEMP}/nx-post-support-vscode-debug/test/postprocessor"
], ],
"cwd": "${env:TEMP}/nx-post-support-vscode-debug", "cwd": "${env:TEMP}/nx-post-support-vscode-debug",
"outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"], "outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"],
@@ -21,7 +21,7 @@
"args": [ "args": [
"--extensionDevelopmentPath=${workspaceFolder}", "--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}", "${workspaceFolder}",
"${workspaceFolder}/test/test.tcl" "${workspaceFolder}/test/postprocessor"
], ],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"outFiles": ["${workspaceFolder}/dist/**/*.js"], "outFiles": ["${workspaceFolder}/dist/**/*.js"],
@@ -39,6 +39,48 @@
"autoAttachChildProcesses": true, "autoAttachChildProcesses": true,
"preLaunchTask": "NX Post Support: Compile Debug" "preLaunchTask": "NX Post Support: Compile Debug"
}, },
{
// Linux workaround: js-debug's extensionHost attach probes localhost and [::1]
// in parallel and aborts when [::1] is refused. Start the dev host ourselves
// with a fixed inspector port and attach directly to 127.0.0.1.
"name": "Run Extension (Linux)",
"type": "node",
"request": "attach",
"address": "127.0.0.1",
"port": 9333,
"timeout": 30000,
"continueOnAttach": true,
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"resolveSourceMapLocations": [
"${workspaceFolder}/dist/**/*.js",
"!**/node_modules/**"
],
"skipFiles": ["<node_internals>/**"],
"preLaunchTask": "NX Post Support: Start Dev Host (Linux)"
},
{
"name": "Debug Extension (Linux, hidden)",
"type": "node",
"request": "attach",
"address": "127.0.0.1",
"port": 9333,
"timeout": 30000,
"continueOnAttach": true,
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"resolveSourceMapLocations": [
"${workspaceFolder}/dist/**/*.js",
"!**/node_modules/**"
],
"skipFiles": ["<node_internals>/**"],
"preLaunchTask": "NX Post Support: Start Dev Host with debugpy (Linux)",
"presentation": {
"hidden": true,
"group": "",
"order": 4
}
},
{ {
"name": "Python Attach", "name": "Python Attach",
"type": "debugpy", "type": "debugpy",
@@ -59,7 +101,7 @@
"args": [ "args": [
"--extensionDevelopmentPath=${env:TEMP}/nx-post-support-vscode-debug", "--extensionDevelopmentPath=${env:TEMP}/nx-post-support-vscode-debug",
"${env:TEMP}/nx-post-support-vscode-debug", "${env:TEMP}/nx-post-support-vscode-debug",
"${env:TEMP}/nx-post-support-vscode-debug/test/test.tcl" "${env:TEMP}/nx-post-support-vscode-debug/test/postprocessor"
], ],
"cwd": "${env:TEMP}/nx-post-support-vscode-debug", "cwd": "${env:TEMP}/nx-post-support-vscode-debug",
"outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"], "outFiles": ["${env:TEMP}/nx-post-support-vscode-debug/dist/**/*.js"],
@@ -67,7 +109,7 @@
"args": [ "args": [
"--extensionDevelopmentPath=${workspaceFolder}", "--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}", "${workspaceFolder}",
"${workspaceFolder}/test/test.tcl" "${workspaceFolder}/test/postprocessor"
], ],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"outFiles": ["${workspaceFolder}/dist/**/*.js"], "outFiles": ["${workspaceFolder}/dist/**/*.js"],
@@ -118,6 +160,16 @@
"group": "", "group": "",
"order": 1 "order": 1
} }
},
{
"name": "Debug Extension and Python (Linux)",
"configurations": ["Python debug server (hidden)", "Debug Extension (Linux, hidden)"],
"stopAll": true,
"presentation": {
"hidden": false,
"group": "",
"order": 2
}
} }
] ]
} }
+41
View File
@@ -28,6 +28,47 @@
"panel": "dedicated", "panel": "dedicated",
"clear": true "clear": true
} }
},
{
"label": "NX Post Support: Start Dev Host (Linux)",
"detail": "Opens the Extension Development Host with the inspector on 127.0.0.1:9333 (workaround for js-debug localhost/::1 attach bug).",
"type": "shell",
"command": "\"$(dirname \"${execPath}\")/bin/$(basename \"${execPath}\")\" --new-window --inspect-brk-extensions=9333 --extensionDevelopmentPath=\"${workspaceFolder}\" \"${workspaceFolder}\" \"${workspaceFolder}/test/postprocessor\"",
"options": {
"shell": {
"executable": "/bin/bash",
"args": ["-c"]
}
},
"dependsOn": "NX Post Support: Compile Debug",
"problemMatcher": [],
"presentation": {
"reveal": "silent",
"panel": "dedicated"
}
},
{
"label": "NX Post Support: Start Dev Host with debugpy (Linux)",
"detail": "Same as above, but the language server connects to the debugpy listener on 127.0.0.1:5678.",
"type": "shell",
"command": "\"$(dirname \"${execPath}\")/bin/$(basename \"${execPath}\")\" --new-window --inspect-brk-extensions=9333 --extensionDevelopmentPath=\"${workspaceFolder}\" \"${workspaceFolder}\" \"${workspaceFolder}/test/postprocessor\"",
"options": {
"shell": {
"executable": "/bin/bash",
"args": ["-c"]
},
"env": {
"USE_DEBUGPY": "True",
"NXPS_DEBUG_HOST": "127.0.0.1",
"NXPS_DEBUG_PORT": "5678"
}
},
"dependsOn": "NX Post Support: Compile Debug",
"problemMatcher": [],
"presentation": {
"reveal": "silent",
"panel": "dedicated"
}
} }
] ]
} }
+307 -32
View File
@@ -1,46 +1,321 @@
# Changelog
All notable changes to NX Postprocessor Support are listed here, newest release first.
Versions correspond to the Git tags of this repository.
## Unreleased ## Unreleased
- Add incoming and outgoing call hierarchy for custom TCL procedures and MOM event handlers ### Added
- Add document highlights for procedure and variable occurrences
- Make completion context-aware and prioritize local, current-file, workspace, and built-in symbols
- Add command-aware completion for Tcl subcommands, fixed arguments, and valid options
- Add semantic argument completion for variables, procedures, namespaces, and local file paths
- Add placeholder-based snippets for common Tcl structures and `dict for`
- Integrate the NX Tcl Remote Debugger directly into NX Postprocessor Support
- Add `nx-tcl` attach configurations and breakpoint support for TCL and DEF files
- Support breakpoints, stepping, stack frames, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops
- Add generation-safe stack and variable references so delayed VS Code requests cannot address stale NX frames
- Add command-buffer breakpoint handling for `LIB_GE_command_buffer_edit_*` source bodies
- Extend the README with launch configuration, path mapping, and debugger usage
- Prevent truncated TCL inlay hints and add configurable parameter hint modes
- Add inlay hints for built-in NX procedures, variadic arguments, and visible ranges
- Add inlay hint documentation and navigation to custom procedure definitions
- Add signature help for custom TCL procedures and built-in NX/MOM procedures
- Clean stale TCL indexes on close, delete, and rename operations
- Make background parsing and index updates thread-safe
- Improve TCL response times with debounced edits and cached semantic, inlay, hover, completion, and variable indexes
- Debounce CDL/DEF diagnostics and remove per-line diagnostic logging
## [0.0.1] - Procedures stored in PostConfigurator COMMANDBLOCK properties (`CONF_CTRL_tool set auto_preselect_last_template {custom_header}`) are highlighted as procedures and support Go to Definition, Find References, Rename, and the call hierarchy
- Go to Definition from block template and address arguments in Tcl (`MOM_do_template`, `MOM_force`, `MOM_suppress`, `MOM_ask_address_value`, ...) to the `BLOCK_TEMPLATE`/`ADDRESS` declaration in the PSC `.def` files
- Hover over block templates shows the template body; hover over addresses shows format, leader, trailer, min/max, and modality
- Find References and Rename for block templates and addresses across Tcl and `.def` files, including addresses used inside block templates
- Go to Definition, hover, references, and rename also work inside `.def` files
- Hover and Go to Definition also recognize block template and address names that reach an NX command through a local variable (`set`, `lappend`, `list`, `foreach`) or through the parameter of a custom proc such as `LIB_SPF_call_cycle "absolute_mode"`, including nested wrapper procs; same-named strings without such a path are not recognized, and these derived names are not renamed
- Warning for block template and address names in NX commands (`MOM_do_template stedy_rest`) that no loaded `.def` file declares; names built at runtime (`$var`, `"CYCLE_$x"`) are not checked, and the warnings update when a `.def` file changes
- Completion items for block templates and addresses (`BLOCK_LIST`, `ADDR_LIST`, `MOM_do_template`, ...) show the same preview as the hover
- Initial release ### Changed
## [0.0.2] - When a block template or address is declared in several PSC layers, hover and Go to Definition show only the effective one, the declaration of the last loaded `.def` file, and name the declarations it overrides; Find References and Rename still include all declarations
- Add Autocomp for TYPE ### Documentation
## [0.1.0] - Document DEF block templates, addresses, `BLOCK_LIST`/`ADDR_LIST`, and the formatting changes in the README
- Add Hover Feature ## [2026.9.700] - 2026-09-24
## [0.2.0] ### Added
- Add DEF File Support - `BLOCK_LIST` and `ADDR_LIST` completion keywords that list all loaded block templates or addresses; the keyword is replaced by the selected quoted name
## [2026.6.100] ### Changed
- Fix several bugs - Format comments with a space after `#` (`#Comment` becomes `# Comment`); `##` separators, `#!`, and comments that already start with whitespace stay unchanged
## [2026.6.200] ## [2026.9.600] - 2026-09-24
- Fix foramtting bug ### Added
- Read `BLOCK_TEMPLATE` and `ADDRESS` names from the `.def` files listed under `<DefinedEvents>` in PSC layers; changes to PSC and DEF files are picked up automatically
- Suggest block templates for `MOM_do_template` and addresses for `MOM_ask_address_value`, `MOM_force`, and `MOM_suppress`, each followed by variables
- Suggest `Always`, `Once`, and `Off` for the first argument of `MOM_force` and `MOM_suppress`
- Insert block templates, addresses, and modes in quotes without doubling typed or auto-closed quotes; insert variables with a leading `$`
- Persistent workspace index in the extension storage, so restarts skip reparsing unchanged files; the cache is discarded automatically when the server or bundled tclint changes
### Changed
- Reparse only the top-level Tcl commands touched by an edit instead of the whole file
- Speed up TclOO completion, signature help, inlay hints, and Go to Definition by reusing the cached syntax tree and skipping files without classes
- Speed up references, document highlights, and call hierarchy with cached definition lookups
- Keep background indexing from blocking requests that need a fresh syntax tree
### Fixed
- Format and lint `uplevel` bodies as Tcl scripts instead of leaving them untouched
- Stop the Python debug bootstrap with a clear error instead of hanging when no debugpy listener or only a stale one is available
## [2026.9.501] - 2026-09-21
### Fixed
- Highlight braced stored-procedure names in `lappend` arguments
## [2026.9.500] - 2026-09-21
### Added
- Index Tcl scripts referenced by PSC layers, including external folders, environment-variable folders, and legacy Windows encoding
- Share TclOO class metadata across files for completion, signature help, inlay hints, and highlighting
- Go to Definition for TclOO classes, constructors, and resolved methods, including PSC library definitions
- CDL event handler snippets declare an extra `<var>_defined` global for parameters marked `TOGGLE Off`
- Allow `INVALID` in the CDL `CATEGORY` grammar
## [2026.9.400] - 2026-09-11
### Added
- Document-local TclOO method completion for `new`/`create` instances, `my`, and statically inferred return chains
- Signature help and parameter inlay hints for resolved TclOO methods and constructors, including optional and variadic arguments
- Suggest TclOO class names as classes and semantically highlight their declarations and calls
- Keep completion working while the file has syntax errors
## [2026.9.300] - 2026-09-10
### Added
- Array key completion, including keys built with substitutions
- Generated CDL event handler snippets declare `args`
## [2026.9.220] - 2026-09-08
### Fixed
- Completion inside unfinished braced arguments now recognizes nested commands
## [2026.9.210] - 2026-09-04
### Added
- Highlight escaped quoted strings and embedded variables in DEF files
## [2026.9.200] - 2026-09-03
### Added
- Command-aware completion for Tcl subcommands, fixed argument values, and options such as `string compare -nocase`
- Semantic argument completion for variables, procedures, namespaces, and local file paths
- Placeholder-based snippets for `if`, `foreach`, `proc`, `switch`, `try`, and `dict for`
## [2026.9.100] - 2026-09-03
### Added
- Incoming and outgoing call hierarchy for custom Tcl procedures and MOM event handlers
- Document highlights for procedure and variable occurrences
- Context-aware completion that ranks local, current-file, workspace, and built-in symbols in that order
### Changed
- Update lsprotocol to 2025.0.0
## [2026.8.300] - 2026-08-28
### Added
- Integrate the NX Tcl Remote Debugger; the separate `local-nx.nx-tcl-debug` extension is no longer needed
- `nx-tcl` attach configurations and breakpoints in Tcl and DEF files
- Stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops
- Breakpoints in `LIB_GE_command_buffer_edit_*` bodies, mapped back to their source and owning procedure
## [2026.8.200] - 2026-08-19
### Added
- Configurable parameter-name inlay hints (`inlayHints.parameterNames`, `inlayHints.suppressWhenArgumentMatchesName`) for custom and built-in NX procedures, including variadic arguments
- Unlimited inlay hint length for Tcl files, so later parameter names are not truncated
### Changed
- Cache semantic tokens, inlay hints, hover, completion, and variable indexes and debounce edits and diagnostics for faster responses
## [2026.8.100] - 2026-08-19
### Added
- Workspace symbol index with Go to Definition, references, and rename
- Signature help for custom Tcl procedures and built-in NX/MOM procedures
- CDL event handler snippets and hover with generated handler and `mom_` parameter hints
- Keep indexes in sync when Tcl files are changed, deleted, or renamed on disk
## [2026.6.201] - 2026-06-22
### Fixed
- Preserve whitespace inside single-line braced expressions when formatting
## [2026.6.200] - 2026-06-22
### Fixed
- Tcl formatting bug caused by the formatter options
## [2026.6.100] - 2026-06-18
### Changed
- Folding ranges for Tcl are computed by the language server, avoiding duplicate regions
- Language server restarts are serialized so only one server runs at a time
- Pin the Python dependencies of the server and update the bundled libraries
### Fixed
- Several stability fixes
## [2025.9.200] - 2025-09-01
### Changed
- Install server dependencies with uv instead of pip
- Relicense to AGPL-3.0-or-later
## [2025.9.101] - 2025-08-16
### Added
- Signature help
- Variable checks and additional semantic tokens
### Fixed
- `incr` on variables that already exist and `foreach` loop variables
## [2025.8.400] - 2025-08-15
### Added
- Outline for DEF and CDL files
### Fixed
- Duplicate `MOM_add_to_block_buffer` entry renamed to `MOM_add_to_line_buffer`
## [2025.8.300] - 2025-08-12
### Added
- Go to Definition and document outline for Tcl
- Variable index with positions
- Procedure documentation in hover
### Fixed
- Formatter deleting line continuations (`\`)
- Wrong semantic highlighting in several cases
## [2025.8.202] - 2025-08-12
### Fixed
- Formatter deleting line continuations (`\`)
## [2025.8.201] - 2025-08-11
### Fixed
- Highlighting errors
## [2025.8.200] - 2025-08-09
- Version change only
## [2025.8.2] - 2025-08-09
- Version change only
## [2025.08.1] - 2025-08-09
### Added
- Load procedures from all workspace files and PSC-referenced scripts for completion
- Extension icon
## [0.4.14] - 2025-08-04
### Added
- `LIB_GE_command_buffer_edit_*` support in the parser
### Fixed
- Namespace calls in POCO code
- Semantic highlighting of variables in arrays
## [0.4.12] - 2025-08-03
### Added
- More semantic tokens and completion items
## [0.4.10] - 2025-07-28
### Added
- Linter based on tclint
### Changed
- Use tclint as parser and formatter
## [0.4.9] - 2025-07-22
### Added
- More completion items
## [0.4.8] - 2025-07-21
### Added
- More NX functions and hover documentation
## [0.4.7] - 2025-07-21
### Added
- Formatter settings
### Fixed
- Formatter fixes
## [0.4.6] - 2025-07-21
### Fixed
- `@` in buffers when formatting
## [0.4.5] - 2025-07-21
### Fixed
- Formatting of buffers
## [0.4.4] - 2025-07-21
### Fixed
- Formatter fixes
## [0.4.3] - 2025-07-21
### Fixed
- Formatting fixes
## [0.4.2] - 2025-07-20
### Added
- Initial release with syntax highlighting, formatting, auto-completion (including `TYPE`), hover, and DEF file support
+51 -1
View File
@@ -13,6 +13,8 @@ A comprehensive VS Code extension providing language support and remote debuggin
- **Call Hierarchy** - Traces incoming and outgoing calls between custom TCL procedures and MOM event handlers - **Call Hierarchy** - Traces incoming and outgoing calls between custom TCL procedures and MOM event handlers
- **Document Highlights** - Highlights all reads, writes, and calls of the symbol under the cursor - **Document Highlights** - Highlights all reads, writes, and calls of the symbol under the cursor
- **Context-aware Completion** - Prioritizes local symbols and suggests variables, procedures, namespaces, paths, Tcl subcommands, valid argument values, and options based on cursor context - **Context-aware Completion** - Prioritizes local symbols and suggests variables, procedures, namespaces, paths, Tcl subcommands, valid argument values, and options based on cursor context
- **PSC and TclOO Classes** - Indexes Tcl scripts referenced by PSC layers, including external folders. Classes from indexed files provide method completion, signature help, parameter hints, and class highlighting in other files. PSC script names may omit `.tcl`; relative folders resolve from the PSC directory, and environment-variable folders are supported. Missing scripts are reported in the output channel; encrypted libraries cannot supply static class metadata.
- **DEF Block Templates and Addresses** - Reads the `.def` files listed under `<DefinedEvents>` in PSC layers and suggests their `BLOCK_TEMPLATE` and `ADDRESS` names, see [DEF block templates and addresses](#def-block-templates-and-addresses)
- **Tcl Snippets** - Inserts placeholder-based structures for `if`, `foreach`, `proc`, `switch`, `try`, and `dict for` - **Tcl Snippets** - Inserts placeholder-based structures for `if`, `foreach`, `proc`, `switch`, `try`, and `dict for`
- **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process - **NX Tcl Remote Debugger** - Breakpoints, stepping, call stack, scopes, variables, watches, evaluation, logpoints, hit conditions, and Tcl error stops directly in a running NX Post process
@@ -47,6 +49,52 @@ TCL files default to unlimited inlay hint length so that VS Code does not
truncate later parameter names on a line. An explicit user setting for truncate later parameter names on a line. An explicit user setting for
`editor.inlayHints.maximumLength` still takes precedence. `editor.inlayHints.maximumLength` still takes precedence.
## DEF Block Templates and Addresses
The language server resolves the `.def` files of every PSC layer's `<DefinedEvents>` section the
same way as layer scripts (relative to the PSC, `SubFolder`, environment variables, missing `.def`
extension) and collects all `BLOCK_TEMPLATE` and `ADDRESS` declarations. Changes to PSC or DEF files
are picked up automatically.
| Command | Suggestions |
|---|---|
| `MOM_do_template` | Block templates, then variables |
| `MOM_ask_address_value` | Addresses, then variables |
| `MOM_force`, `MOM_suppress` | 1st argument: `Always`, `Once`, `Off`, then variables; further arguments: addresses, then variables |
Block templates, addresses, and `Always|Once|Off` are inserted in quotes (`"steady_rest"`); a quote
that is already typed or auto-closed is replaced instead of doubled. Variables are inserted with a
leading `$`. Typing `$` yourself still shows variables only.
Two keywords list all loaded names anywhere in a Tcl file:
- `BLOCK_LIST` - shows all block templates
- `ADDR_LIST` - shows all addresses
Select the keyword from the completion list (or type it completely) to open the list; text typed
directly after the keyword, such as `ADDR_LISTSP`, narrows it. The keyword is replaced by the
selected quoted name.
### Navigation between Tcl and DEF files
Block template and address names are linked to their declarations in the PSC `.def` files:
- **Go to Definition** (F12) on `MOM_do_template "steady_rest"` or `MOM_force Once X` jumps to
`BLOCK_TEMPLATE steady_rest` or `ADDRESS X`. Inside a `.def` file, an address used in a block
template (`X[$mom_pos(0)]`) jumps to its `ADDRESS` declaration.
- **Hover** shows the body of a block template, or the format (resolved to its `FORMAT`
definition), leader, trailer, min/max, and modality (`FORCE`) of an address.
- **Find All References** (Shift+F12) lists the declaration, all Tcl calls, and all block templates
that use an address.
- **Rename** (F2) changes the declaration and all usages in Tcl and `.def` files together. Only
names declared in a loaded `.def` file can be renamed.
Recognized Tcl commands: `MOM_do_template`, `MOM_add_to_block_buffer`, `MOM_polar_motion`,
`MOM_force_block`, `MOM_ask_address_value`, `MOM_add_to_address_buffer`, `MOM_enable_address`,
`MOM_disable_address`, `MOM_force`, `MOM_suppress`, `MOM_incremental`, and
`MOM_ask_definition_element`/`MOM_has_definition_element` with `ADDRESS` or `BLOCK`. Names built
from variables (`MOM_do_template $name`) cannot be resolved statically.
## NX Tcl Remote Debugger ## NX Tcl Remote Debugger
### Add a VS Code attach configuration ### Add a VS Code attach configuration
@@ -97,13 +145,15 @@ Simply open any supported file type and enjoy:
- Syntax highlighting - Syntax highlighting
- Error detection and linting - Error detection and linting
- Code completion - Code completion
- Code formatting (Format Document command) - Code formatting (Format Document command), including `uplevel` bodies and a space after `#` in comments
- Hover information - Hover information
- Signature help while entering procedure arguments - Signature help while entering procedure arguments
- Incoming and outgoing call hierarchy for custom procedures and MOM event handlers - Incoming and outgoing call hierarchy for custom procedures and MOM event handlers
- Document-wide highlights for procedure and variable occurrences - Document-wide highlights for procedure and variable occurrences
- Context-aware completion with local symbols ranked before workspace and built-in symbols, plus semantic arguments, local paths, Tcl subcommands, and options such as `string compare -nocase` - Context-aware completion with local symbols ranked before workspace and built-in symbols, plus semantic arguments, local paths, Tcl subcommands, and options such as `string compare -nocase`
- Placeholder-based snippets for common Tcl control structures and procedures - Placeholder-based snippets for common Tcl control structures and procedures
- Block template and address suggestions from PSC DEF files, including `BLOCK_LIST` and `ADDR_LIST`
- Go to Definition, hover, references, and rename for block templates and addresses across Tcl and DEF files
- Remote NX Tcl debugging with breakpoints and full stepping - Remote NX Tcl debugging with breakpoints and full stepping
## Contributing ## Contributing
+32 -30
View File
@@ -1,6 +1,7 @@
export interface CdlEventHandler { export interface CdlEventHandler {
eventName: string eventName: string
parameterNames: string[] parameterNames: string[]
toggleOffParameterNames?: string[]
} }
function structuralCode(line: string): string { function structuralCode(line: string): string {
@@ -33,18 +34,6 @@ function structuralCode(line: string): string {
return result return result
} }
function braceDelta(line: string): number {
let delta = 0
for (const character of structuralCode(line)) {
if (character === "{") {
delta += 1
} else if (character === "}") {
delta -= 1
}
}
return delta
}
export function cdlEventHandlerAtLine( export function cdlEventHandlerAtLine(
source: string, source: string,
declarationLine: number declarationLine: number
@@ -61,35 +50,44 @@ export function cdlEventHandlerAtLine(
} }
const parameterNames: string[] = [] const parameterNames: string[] = []
const toggleOffParameterNames: string[] = []
let currentParameter: string | undefined
let eventOpened = false let eventOpened = false
let depth = 0 let depth = 0
for (let lineNumber = declarationLine; lineNumber < lines.length; lineNumber++) { eventLines: for (let lineNumber = declarationLine; lineNumber < lines.length; lineNumber++) {
const line = lines[lineNumber] const line = lines[lineNumber]
const code = structuralCode(line) const code = structuralCode(line)
if (eventOpened && depth === 1) { const tokens = code.match(/[{}]|[^\s{}]+/g) ?? []
const parameterMatch = /^\s*PARAM\s+([^\s{]+)/.exec(code) for (let index = 0; index < tokens.length; index++) {
if (parameterMatch) { const token = tokens[index]
parameterNames.push(parameterMatch[1]) if (token === "{") {
} eventOpened = true
} depth++
} else if (token === "}" && eventOpened) {
const delta = braceDelta(line) depth--
if (!eventOpened && delta > 0) { if (depth === 1) currentParameter = undefined
eventOpened = true if (depth <= 0) break eventLines
} } else if (depth === 1 && token === "PARAM") {
if (eventOpened) { const name = tokens[index + 1]
depth += delta if (name && name !== "{" && name !== "}") {
if (depth <= 0) { currentParameter = name
break parameterNames.push(name)
index++
}
} else if (depth === 2 && currentParameter && token === "TOGGLE") {
if (tokens[index + 1]?.toLowerCase() === "off") {
toggleOffParameterNames.push(currentParameter)
}
} }
} }
} }
return { return {
eventName: eventMatch[1], eventName: eventMatch[1],
parameterNames parameterNames,
toggleOffParameterNames
} }
} }
@@ -102,8 +100,12 @@ function momVariableName(parameterName: string): string {
} }
export function createCdlEventHandlerSnippet(handler: CdlEventHandler): string { export function createCdlEventHandlerSnippet(handler: CdlEventHandler): string {
const toggleOffParameters = new Set(handler.toggleOffParameterNames ?? [])
const globals = [ const globals = [
...new Set(handler.parameterNames.map((parameter) => momVariableName(parameter))) ...new Set(handler.parameterNames.flatMap((parameter) => {
const variable = momVariableName(parameter)
return toggleOffParameters.has(parameter) ? [variable, `${variable}_defined`] : [variable]
}))
] ]
const lines = [`proc ${momEventName(handler.eventName)} {args} {`] const lines = [`proc ${momEventName(handler.eventName)} {args} {`]
+81
View File
@@ -0,0 +1,81 @@
import * as vscode from "vscode"
import { LanguageClient, Range, State } from "vscode-languageclient/node"
// .def files are not synchronized with the language server, so each request
// carries the current text of the document.
const DEF_SELECTOR: vscode.DocumentSelector = { scheme: "file", language: "def" }
function params(document: vscode.TextDocument, position: vscode.Position, extra: object = {}) {
return {
textDocument: { uri: document.uri.toString() },
position: { line: position.line, character: position.character },
text: document.getText(),
...extra
}
}
export function registerDefProviders(getClient: () => LanguageClient | undefined): vscode.Disposable[] {
const request = async <T>(
method: string,
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken,
extra: object = {}
): Promise<{ client: LanguageClient; result: T } | undefined> => {
const client = getClient()
if (!client || client.state !== State.Running) {
return undefined
}
const result = await client.sendRequest<T>(method, params(document, position, extra), token)
return result ? { client, result } : undefined
}
return [
vscode.languages.registerDefinitionProvider(DEF_SELECTOR, {
async provideDefinition(document, position, token) {
const response = await request<any>("nxPostSupport/def/definition", document, position, token)
return response && response.client.protocol2CodeConverter.asDefinitionResult(response.result, token)
}
}),
vscode.languages.registerHoverProvider(DEF_SELECTOR, {
async provideHover(document, position, token) {
const response = await request<any>("nxPostSupport/def/hover", document, position, token)
return response && response.client.protocol2CodeConverter.asHover(response.result)
}
}),
vscode.languages.registerReferenceProvider(DEF_SELECTOR, {
async provideReferences(document, position, context, token) {
const response = await request<any>("nxPostSupport/def/references", document, position, token, {
includeDeclaration: context.includeDeclaration
})
return response && response.client.protocol2CodeConverter.asReferences(response.result, token)
}
}),
vscode.languages.registerRenameProvider(DEF_SELECTOR, {
async prepareRename(document, position, token) {
const response = await request<{ range: Range; placeholder: string }>(
"nxPostSupport/def/prepareRename",
document,
position,
token
)
if (!response) {
throw new Error("Only declared block templates and addresses can be renamed.")
}
return {
range: response.client.protocol2CodeConverter.asRange(response.result.range),
placeholder: response.result.placeholder
}
},
async provideRenameEdits(document, position, newName, token) {
const response = await request<any>("nxPostSupport/def/rename", document, position, token, {
newName
})
if (!response) {
throw new Error(`"${newName}" is not a valid block template or address name.`)
}
return response.client.protocol2CodeConverter.asWorkspaceEdit(response.result, token)
}
})
]
}
+6 -20
View File
@@ -1,8 +1,5 @@
import * as vscode from "vscode" import * as vscode from "vscode"
import { import { cdlEventHandlerAtLine, createCdlEventHandlerSnippet } from "./cdlEventHandler"
cdlEventHandlerAtLine,
createCdlEventHandlerSnippet
} from "./cdlEventHandler"
const MACHINE_HEADER_REGEX = /^MACHINE\s+\S+/ const MACHINE_HEADER_REGEX = /^MACHINE\s+\S+/
@@ -65,10 +62,7 @@ export function diagnosticHandler(document: vscode.TextDocument) {
if (document.languageId === "cdl" || document.languageId === "def") { if (document.languageId === "cdl" || document.languageId === "def") {
const text = document.getText() const text = document.getText()
if (!isFirstLineMachine(text)) { if (!isFirstLineMachine(text)) {
const range = new vscode.Range( const range = new vscode.Range(document.positionAt(0), document.positionAt(text.length))
document.positionAt(0),
document.positionAt(text.length)
)
const diagnostic = new vscode.Diagnostic( const diagnostic = new vscode.Diagnostic(
range, range,
"The first line should contain 'MACHINE'.", "The first line should contain 'MACHINE'.",
@@ -82,7 +76,7 @@ export function diagnosticHandler(document: vscode.TextDocument) {
export function completionHandlerCdl(document: vscode.TextDocument, position: vscode.Position) { export function completionHandlerCdl(document: vscode.TextDocument, position: vscode.Position) {
const linePrefix = document.lineAt(position).text.substring(0, position.character) const linePrefix = document.lineAt(position).text.substring(0, position.character)
const categories = ["MILL", "LATHE", "DRILL"] const categories = ["MILL", "LATHE", "DRILL", "INVALID"]
if (linePrefix.endsWith("TYPE ")) { if (linePrefix.endsWith("TYPE ")) {
return [ return [
@@ -193,8 +187,7 @@ export async function definitionCdlEventHandler(
.filter( .filter(
(symbol) => (symbol) =>
symbol.kind === vscode.SymbolKind.Function && symbol.kind === vscode.SymbolKind.Function &&
(symbol.name === handlerName || (symbol.name === handlerName || symbol.name.endsWith(`::${handlerName}`))
symbol.name.endsWith(`::${handlerName}`))
) )
.map((symbol) => symbol.location) .map((symbol) => symbol.location)
if (indexedLocations.length > 0) { if (indexedLocations.length > 0) {
@@ -204,9 +197,7 @@ export async function definitionCdlEventHandler(
// The Tcl language server may still be starting; use the file fallback below. // The Tcl language server may still be starting; use the file fallback below.
} }
const declaration = new RegExp( const declaration = new RegExp(`^\\s*proc\\s+(?:::)?${escapeRegExp(handlerName)}(?=\\s|\\{)`)
`^\\s*proc\\s+(?:::)?${escapeRegExp(handlerName)}(?=\\s|\\{)`
)
const tclFiles = await vscode.workspace.findFiles( const tclFiles = await vscode.workspace.findFiles(
"**/*.tcl", "**/*.tcl",
"**/{.git,.nox,.venv,dist,node_modules,out}/**" "**/{.git,.nox,.venv,dist,node_modules,out}/**"
@@ -233,12 +224,7 @@ export async function definitionCdlEventHandler(
locations.push( locations.push(
new vscode.Location( new vscode.Location(
uri, uri,
new vscode.Range( new vscode.Range(lineNumber, start, lineNumber, start + handlerName.length)
lineNumber,
start,
lineNumber,
start + handlerName.length
)
) )
) )
} }
+11 -4
View File
@@ -22,7 +22,12 @@ import {
import { getLSClientTraceLevel, getProjectRoot } from "./utilities" import { getLSClientTraceLevel, getProjectRoot } from "./utilities"
import { isVirtualWorkspace } from "./vscodeapi" import { isVirtualWorkspace } from "./vscodeapi"
export type IInitOptions = { settings: ISettings[]; globalSettings: ISettings } export type IInitOptions = {
settings: ISettings[]
globalSettings: ISettings
// Folder for the server's persistent index cache; omitted without a workspace.
indexCachePath?: string
}
let _disposables: Disposable[] = [] let _disposables: Disposable[] = []
@@ -86,7 +91,7 @@ async function createServer(
} }
// Options to control the language client // Options to control the language client
const tclFileWatcher = workspace.createFileSystemWatcher("**/*.tcl") const tclFileWatcher = workspace.createFileSystemWatcher("**/*.{tcl,psc,def}")
const clientOptions: LanguageClientOptions = { const clientOptions: LanguageClientOptions = {
// Register the server for python documents // Register the server for python documents
documentSelector: isVirtualWorkspace() documentSelector: isVirtualWorkspace()
@@ -114,7 +119,8 @@ export async function restartServer(
serverId: string, serverId: string,
serverName: string, serverName: string,
outputChannel: LogOutputChannel, outputChannel: LogOutputChannel,
lsClient?: LanguageClient lsClient?: LanguageClient,
indexCachePath?: string
): Promise<LanguageClient | undefined> { ): Promise<LanguageClient | undefined> {
if (lsClient) { if (lsClient) {
traceInfo(`Server: Stop requested`) traceInfo(`Server: Stop requested`)
@@ -132,7 +138,8 @@ export async function restartServer(
outputChannel, outputChannel,
{ {
settings: await getExtensionSettings(serverId, true), settings: await getExtensionSettings(serverId, true),
globalSettings: await getGlobalSettings(serverId, false) globalSettings: await getGlobalSettings(serverId, false),
indexCachePath
} }
) )
traceInfo(`Server: Start requested.`) traceInfo(`Server: Start requested.`)
+16 -2
View File
@@ -16,6 +16,7 @@ import {
defDocumentSymbolProvider, defDocumentSymbolProvider,
definitionCdlEventHandler definitionCdlEventHandler
} from "./common/handlers" } from "./common/handlers"
import { registerDefProviders } from "./common/defProviders"
import { registerLogger, traceError, traceLog, traceVerbose } from "./common/log/logging" import { registerLogger, traceError, traceLog, traceVerbose } from "./common/log/logging"
import { import {
checkVersion, checkVersion,
@@ -73,7 +74,13 @@ export async function activate(context: vscode.ExtensionContext) {
traceVerbose( traceVerbose(
`Using interpreter from ${serverInfo.module}.interpreter: ${interpreter.join(" ")}` `Using interpreter from ${serverInfo.module}.interpreter: ${interpreter.join(" ")}`
) )
client = await restartServer(serverId, serverName, outputChannel, client) client = await restartServer(
serverId,
serverName,
outputChannel,
client,
context.storageUri?.fsPath
)
} }
return return
} }
@@ -83,7 +90,13 @@ export async function activate(context: vscode.ExtensionContext) {
traceVerbose( traceVerbose(
`Using interpreter from Python extension: ${interpreterDetails.path.join(" ")}` `Using interpreter from Python extension: ${interpreterDetails.path.join(" ")}`
) )
client = await restartServer(serverId, serverName, outputChannel, client) client = await restartServer(
serverId,
serverName,
outputChannel,
client,
context.storageUri?.fsPath
)
return return
} }
@@ -222,6 +235,7 @@ export async function activate(context: vscode.ExtensionContext) {
) )
context.subscriptions.push(formatDefProvider) context.subscriptions.push(formatDefProvider)
context.subscriptions.push(...registerDefProviders(() => client))
const cdlSymbolProvider = vscode.languages.registerDocumentSymbolProvider( const cdlSymbolProvider = vscode.languages.registerDocumentSymbolProvider(
{ scheme: "file", language: "cdl" }, { scheme: "file", language: "cdl" },
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nx-post-support", "name": "nx-post-support",
"version": "2026.8.201", "version": "2026.9.501",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nx-post-support", "name": "nx-post-support",
"version": "2026.8.201", "version": "2026.9.501",
"devDependencies": { "devDependencies": {
"@types/vscode": "^1.96.0", "@types/vscode": "^1.96.0",
"@vscode/vsce": "^3.2.1", "@vscode/vsce": "^3.2.1",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "nx-post-support", "name": "nx-post-support",
"displayName": "NX Postprocessor Support", "displayName": "NX Postprocessor Support",
"description": "VS Code extension for NX CAM postprocessor development with language support and remote Tcl debugging for CDL, TCL, and DEF files", "description": "VS Code extension for NX CAM postprocessor development with language support and remote Tcl debugging for CDL, TCL, and DEF files",
"version": "2026.9.220", "version": "2026.9.900",
"publisher": "Christoph", "publisher": "Christoph",
"icon": "images/nx-1.png", "icon": "images/nx-1.png",
"activationEvents": [ "activationEvents": [
+32 -13
View File
@@ -6,7 +6,7 @@ import os
import pathlib import pathlib
import runpy import runpy
import sys import sys
import time import threading
def update_sys_path(path_to_add: str) -> None: def update_sys_path(path_to_add: str) -> None:
@@ -27,21 +27,35 @@ def _debug_endpoint() -> tuple[str, int]:
return host, port return host, port
def _connect_debugger(debugpy, host: str, port: int, timeout: float = 15.0) -> None: def _connect_debugger(debugpy, host: str, port: int, timeout: float = 30.0) -> None:
deadline = time.monotonic() + timeout errors: list[BaseException] = []
last_error: OSError | None = None
while time.monotonic() < deadline: def attach() -> None:
# debugpy.connect() cannot be retried: after a refused connection a second
# call terminates the process silently. Connect exactly once.
try: try:
debugpy.connect((host, port)) debugpy.connect((host, port))
debugpy.wait_for_client() debugpy.wait_for_client()
return except BaseException as error: # pylint: disable=broad-exception-caught
except (ConnectionRefusedError, OSError) as error: errors.append(error)
last_error = error
time.sleep(0.25)
raise RuntimeError( # A stale debugpy adapter from an earlier debug session can still own the port.
f"Could not connect debugpy to {host}:{port} within {timeout:.0f} seconds" # It accepts the connection but never attaches, so bound the wait.
) from last_error waiter = threading.Thread(target=attach, daemon=True)
waiter.start()
waiter.join(timeout)
if waiter.is_alive():
raise RuntimeError(
f"Connected to {host}:{port}, but no VS Code debug session attached within "
f"{timeout:.0f} seconds. A stale debugpy adapter probably still owns the "
f"port; stop it (e.g. 'fuser -k {port}/tcp') and restart debugging."
)
if errors:
raise RuntimeError(
f"No debugpy listener on {host}:{port}. Start the launch configuration "
"'Python debug server (hidden)' (e.g. via a 'Debug Extension and Python' "
"compound) before the language server."
) from errors[0]
def main() -> None: def main() -> None:
@@ -61,7 +75,12 @@ def main() -> None:
host, port = _debug_endpoint() host, port = _debug_endpoint()
print(f"debugpy: waiting for VS Code at {host}:{port}", file=sys.stderr) print(f"debugpy: waiting for VS Code at {host}:{port}", file=sys.stderr)
_connect_debugger(debugpy, host, port) try:
_connect_debugger(debugpy, host, port)
except RuntimeError as error:
print(f"debugpy: {error}", file=sys.stderr, flush=True)
# debugpy's background threads can keep the interpreter alive; exit hard.
os._exit(1)
print("debugpy: VS Code attached; starting language server", file=sys.stderr) print("debugpy: VS Code attached; starting language server", file=sys.stderr)
server_path = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py") server_path = os.fspath(pathlib.Path(__file__).parent / "lsp_server.py")
+460 -198
View File
File diff suppressed because it is too large Load Diff
+313 -33
View File
@@ -2,6 +2,8 @@ import logging
import os import os
import pathlib import pathlib
import threading import threading
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
import lsprotocol.types as lsp import lsprotocol.types as lsp
@@ -12,11 +14,22 @@ from pygls.workspace.text_document import TextDocument
from tclint.format import FormatterOpts from tclint.format import FormatterOpts
from tclint.lexer import TclSyntaxError from tclint.lexer import TclSyntaxError
from tclint.violations import Violation from tclint.violations import Violation
from tools import checks, parser from tools import checks, incremental_parse, parser
from tools.completion_items import CompletionCollector from tools.completion_items import CompletionCollector
from tools.tcloo_symbols import class_completion_items
from tools.tcloo_completion import indexed_classes
from tools.def_flow import WrapperTable, build_wrapper_table, unknown_def_names
from tools.def_symbols import ADDRESS, BLOCK_TEMPLATE, DefDocument, parse_def_document, read_def_source
from tools.file_sourcing import get_all_psc_files, psc_defined_event_files, psc_script_files
from tools.formatter import NxFormatter as Formatter from tools.formatter import NxFormatter as Formatter
from tools.index_cache import FileStat, IndexCache, file_stat
from tools.inlay_hint import InlayHintSignature, build_custom_inlay_signatures from tools.inlay_hint import InlayHintSignature, build_custom_inlay_signatures
from tools.navigation import FileSymbolIndex, build_file_symbol_index from tools.navigation import (
FileSymbolIndex,
SymbolIdentity,
build_file_symbol_index,
definition_identities,
)
from tools.proc_docs import build_proc_docs from tools.proc_docs import build_proc_docs
from tools.variable_index import ProcRange, build_variable_index from tools.variable_index import ProcRange, build_variable_index
@@ -24,6 +37,18 @@ DIAGNOSTIC_SOURCE = "nx-post-support"
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True)
class _FileIndex:
"""Everything indexed for one file; also the persistent cache entry."""
completion_items: list[lsp.CompletionItem]
proc_signatures: dict[str, list[str]]
proc_docs: dict[str, str]
classes: dict
navigation_index: FileSymbolIndex
variable_index: tuple[set[str], dict[str, set[str]], list[ProcRange]]
class TclLanguageServer(LanguageServer): class TclLanguageServer(LanguageServer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -34,6 +59,12 @@ class TclLanguageServer(LanguageServer):
self.poco_completion: dict = {} self.poco_completion: dict = {}
self.proc_signatures: dict = {} self.proc_signatures: dict = {}
self.proc_docs: dict = {} self.proc_docs: dict = {}
self.class_indexes: dict = {}
self.psc_script_paths: list[str] = []
self._psc_files: dict[str, list[pathlib.Path]] = {}
self._psc_lock = threading.RLock()
# .def path -> parsed declarations, in PSC DefinedEvents order.
self.def_documents: dict[str, DefDocument] = {}
self.navigation_indexes: dict[str, FileSymbolIndex] = {} self.navigation_indexes: dict[str, FileSymbolIndex] = {}
self.variable_indexes: dict[ self.variable_indexes: dict[
str, str,
@@ -46,6 +77,11 @@ class TclLanguageServer(LanguageServer):
self._ast_cache = {} self._ast_cache = {}
self._line_cache: dict[tuple[str, int | None], tuple[str, ...]] = {} self._line_cache: dict[tuple[str, int | None], tuple[str, ...]] = {}
self._parser_lock = threading.RLock() self._parser_lock = threading.RLock()
self._thread_parsers = threading.local()
# uri -> (normalized source, tree, violations) of the last successful
# parse; survives version changes so edits can be reparsed partially.
self._last_parse: dict[str, tuple[str, object, list]] = {}
self.index_cache = IndexCache()
self._index_lock = threading.RLock() self._index_lock = threading.RLock()
self._index_tokens: dict[str, int] = {} self._index_tokens: dict[str, int] = {}
self._index_versions: dict[str, int | None] = {} self._index_versions: dict[str, int | None] = {}
@@ -59,6 +95,10 @@ class TclLanguageServer(LanguageServer):
-1, -1,
frozenset(), frozenset(),
) )
self._definition_identities_cache: tuple[
int, frozenset[SymbolIdentity]
] = (-1, frozenset())
self._def_wrapper_cache: tuple[int, WrapperTable] = (-1, {})
self._proc_metadata_cache: dict[ self._proc_metadata_cache: dict[
str, tuple[int, dict[str, list[str]], dict[str, str]] str, tuple[int, dict[str, list[str]], dict[str, str]]
] = {} ] = {}
@@ -70,16 +110,44 @@ class TclLanguageServer(LanguageServer):
self._analysis_tokens: dict[str, int] = {} self._analysis_tokens: dict[str, int] = {}
self._next_analysis_token = 0 self._next_analysis_token = 0
def _parse_source(self, source: str): def _parse_source(self, source: str, pos=None):
self.parser.violations = [] self.parser.violations = []
tree = self.parser.parse(source) tree = self.parser.parse(source, pos=pos)
return tree, list(self.parser.violations) return tree, list(self.parser.violations)
def _parse_document(self, document: TextDocument):
"""Parse a document version, reusing unchanged parts of the last one.
Callers hold the parser lock.
"""
source = incremental_parse.normalize_newlines(document.source)
previous = self._last_parse.get(document.uri)
result = None
if previous is not None:
try:
result = incremental_parse.reparse(
*previous, source, self._parse_source
)
except TclSyntaxError:
# E.g. a quote opened in the edit closes further down.
result = None
if result is None:
result = self._parse_source(source)
self._last_parse[document.uri] = (source, *result)
return result
def parse_source(self, source: str): def parse_source(self, source: str):
"""Parse without retaining an AST, serialized around the shared parser.""" """Parse without retaining an AST, on a parser owned by this thread.
with self._parser_lock:
tree, _ = self._parse_source(source) Background indexing must not hold the shared parser lock for whole
return tree files while request handlers wait for their document's tree.
"""
local_parser = getattr(self._thread_parsers, "parser", None)
if local_parser is None:
# Plugin commands live in tclint's shared registry, see __init__.
local_parser = self._thread_parsers.parser = parser.CustomParser()
local_parser.violations = []
return local_parser.parse(source)
def get_tree(self, document: TextDocument): def get_tree(self, document: TextDocument):
key = (document.uri, document.version) key = (document.uri, document.version)
@@ -87,7 +155,7 @@ class TclLanguageServer(LanguageServer):
cached = self._ast_cache.get(key) cached = self._ast_cache.get(key)
if cached is not None: if cached is not None:
return cached[0] return cached[0]
tree, violations = self._parse_source(document.source) tree, violations = self._parse_document(document)
self._ast_cache[key] = (tree, violations) self._ast_cache[key] = (tree, violations)
return tree return tree
@@ -97,7 +165,7 @@ class TclLanguageServer(LanguageServer):
cached = self._ast_cache.get(key) cached = self._ast_cache.get(key)
if cached is not None: if cached is not None:
return cached return cached
tree, violations = self._parse_source(document.source) tree, violations = self._parse_document(document)
self._ast_cache[key] = (tree, violations) self._ast_cache[key] = (tree, violations)
return tree, violations return tree, violations
@@ -146,6 +214,8 @@ class TclLanguageServer(LanguageServer):
self._index_generation += 1 self._index_generation += 1
self._workspace_completion_cache = (-1, ()) self._workspace_completion_cache = (-1, ())
self._custom_function_names_cache = (-1, frozenset()) self._custom_function_names_cache = (-1, frozenset())
self._definition_identities_cache = (-1, frozenset())
self._def_wrapper_cache = (-1, {})
self._proc_metadata_cache.clear() self._proc_metadata_cache.clear()
self._custom_inlay_cache.clear() self._custom_inlay_cache.clear()
@@ -170,6 +240,137 @@ class TclLanguageServer(LanguageServer):
self._workspace_completion_cache = (self._index_generation, items) self._workspace_completion_cache = (self._index_generation, items)
return items return items
def class_snapshot(self, current_path) -> dict:
"""Share class metadata, without stale definitions from the active file.
PSC scripts override other workspace files in their listed load order.
The request parser adds the current document's declarations last.
"""
with self._index_lock:
indexes = {self._normalized_path(path): classes for path, classes in self.class_indexes.items()}
paths = sorted(self.class_indexes, key=str.casefold)
psc_paths = {self._normalized_path(path) for path in self.psc_script_paths}
paths = [path for path in paths if self._normalized_path(path) not in psc_paths]
paths.extend(self.psc_script_paths)
classes = {}
for path in paths:
if not self.paths_equal(path, current_path):
classes.update(indexes.get(self._normalized_path(path), {}))
return classes
def refresh_def_symbols(self, roots, report=LOGGER.warning):
"""Read the block templates and addresses of all .def files listed as PSC DefinedEvents."""
documents: dict[str, DefDocument] = {}
for root in roots:
for psc in get_all_psc_files(root):
try:
def_files = psc_defined_event_files(psc)
except (OSError, ET.ParseError) as error:
report(f"Could not read PSC {psc}: {error}")
continue
for def_file in def_files:
if str(def_file) in documents:
continue
try:
documents[str(def_file)] = parse_def_document(read_def_source(def_file))
except OSError as error:
report(f"Could not read DEF file {def_file}: {error}")
with self._index_lock:
changed = documents != self.def_documents
self.def_documents = documents
if changed:
# Unknown template/address warnings depend on the .def files.
self.diagnostics.clear()
if changed:
self._request_diagnostic_refresh()
def _request_diagnostic_refresh(self) -> None:
# Set by the initialize request; absent before it and in tests.
capabilities = getattr(self.protocol, "client_capabilities", None)
diagnostics = getattr(getattr(capabilities, "workspace", None), "diagnostics", None)
if getattr(diagnostics, "refresh_support", False):
self.workspace_diagnostic_refresh(None)
def def_documents_snapshot(self, current_path=None, current_source: str | None = None) -> dict[str, DefDocument]:
"""Return the PSC .def documents; ``current_source`` replaces the file being edited."""
with self._index_lock:
documents = dict(self.def_documents)
if current_path is not None and current_source is not None:
key = next((path for path in documents if self.paths_equal(path, current_path)), os.fspath(current_path))
documents[key] = parse_def_document(current_source)
return documents
def _def_symbol_items(self, kind: str, item_kind, description: str) -> list[lsp.CompletionItem]:
return [
lsp.CompletionItem(
label=name,
kind=item_kind,
detail=f"{description} ({pathlib.Path(path).name})",
data={"def": [kind, name]},
)
for path, document in self.def_documents_snapshot().items()
for name in document.names(kind)
]
def block_template_items(self) -> list[lsp.CompletionItem]:
return self._def_symbol_items(BLOCK_TEMPLATE, lsp.CompletionItemKind.Struct, "Block template")
def address_items(self) -> list[lsp.CompletionItem]:
return self._def_symbol_items(ADDRESS, lsp.CompletionItemKind.Field, "Address")
def refresh_psc_scripts(self, roots, report=LOGGER.warning):
"""Index PSC dependencies through the same pipeline as workspace procs."""
self.refresh_def_symbols(roots, report=report)
with self._psc_lock:
discovered = {}
for root in roots:
for psc in get_all_psc_files(root):
try:
discovered[str(psc)] = psc_script_files(psc)
except (OSError, ET.ParseError) as error:
report(f"Could not read PSC {psc}: {error}")
discovered[str(psc)] = self._psc_files.get(str(psc), [])
paths = [str(path) for scripts in discovered.values() for path in scripts]
with self._index_lock:
previous = set(self.psc_script_paths)
self.psc_script_paths = paths
self._psc_files = discovered
try:
open_documents = {
self._normalized_path(document.path): document
for document in self.workspace.text_documents.values()
}
except RuntimeError:
open_documents = {}
for removed in previous - set(paths):
path = pathlib.Path(removed)
if (not any(self._is_same_or_child(path, root) for root in roots)
and self._normalized_path(path) not in open_documents):
self.remove_file_state(path.as_uri())
for path_string in dict.fromkeys(paths):
path = pathlib.Path(path_string)
uri = path.as_uri()
document = open_documents.get(self._normalized_path(path))
if document is None and not path.is_file():
self.remove_file_state(uri)
report(f"PSC script not found: {path}")
continue
try:
source_stat = None
if document is None:
source_stat = file_stat(path_string)
data = path.read_bytes()
try:
source = data.decode("utf-8-sig")
except UnicodeDecodeError:
# Older Windows NX layers use the ANSI code page.
source = data.decode("cp1252")
document = TextDocument(uri=uri, source=source, language_id="tcl")
if not self.update_poco_completion_for_file(document, cache_tree=False, source_stat=source_stat):
report(f"Could not index PSC script: {path}")
except (OSError, UnicodeError) as error:
report(f"Could not read PSC script {path}: {error}")
def completion_items_by_file_snapshot( def completion_items_by_file_snapshot(
self, self,
) -> dict[str, tuple[lsp.CompletionItem, ...]]: ) -> dict[str, tuple[lsp.CompletionItem, ...]]:
@@ -190,6 +391,7 @@ class TclLanguageServer(LanguageServer):
item.label item.label
for path_items in self.poco_completion.values() for path_items in self.poco_completion.values()
for item in path_items for item in path_items
if item.kind != lsp.CompletionItemKind.Class
) )
self._custom_function_names_cache = (self._index_generation, names) self._custom_function_names_cache = (self._index_generation, names)
return names return names
@@ -428,6 +630,33 @@ class TclLanguageServer(LanguageServer):
with self._index_lock: with self._index_lock:
return dict(self.navigation_indexes) return dict(self.navigation_indexes)
def navigation_state(
self,
) -> tuple[dict[str, FileSymbolIndex], frozenset[SymbolIdentity]]:
"""Return indexes plus their definitions, cached by index generation."""
with self._index_lock:
generation, definitions = self._definition_identities_cache
if generation != self._index_generation:
definitions = frozenset(
definition_identities(self.navigation_indexes)
)
self._definition_identities_cache = (
self._index_generation,
definitions,
)
return dict(self.navigation_indexes), definitions
def def_wrapper_table(self) -> WrapperTable:
"""Proc arguments that take .def names, cached by index generation."""
with self._index_lock:
generation, table = self._def_wrapper_cache
if generation != self._index_generation:
table = build_wrapper_table(
flow for index in self.navigation_indexes.values() for flow in index.def_flows
)
self._def_wrapper_cache = (self._index_generation, table)
return table
def _begin_index_update(self, filepath: str, version: int | None) -> int | None: def _begin_index_update(self, filepath: str, version: int | None) -> int | None:
with self._index_lock: with self._index_lock:
indexed_version = self._index_versions.get(filepath) indexed_version = self._index_versions.get(filepath)
@@ -453,6 +682,7 @@ class TclLanguageServer(LanguageServer):
self.poco_completion.pop(filepath, None) self.poco_completion.pop(filepath, None)
self.proc_signatures.pop(filepath, None) self.proc_signatures.pop(filepath, None)
self.proc_docs.pop(filepath, None) self.proc_docs.pop(filepath, None)
self.class_indexes.pop(filepath, None)
self.navigation_indexes.pop(filepath, None) self.navigation_indexes.pop(filepath, None)
self.variable_indexes.pop(filepath, None) self.variable_indexes.pop(filepath, None)
self._committed_index_versions.pop(filepath, None) self._committed_index_versions.pop(filepath, None)
@@ -484,6 +714,7 @@ class TclLanguageServer(LanguageServer):
self.poco_completion, self.poco_completion,
self.proc_signatures, self.proc_signatures,
self.proc_docs, self.proc_docs,
self.class_indexes,
self.navigation_indexes, self.navigation_indexes,
self.variable_indexes, self.variable_indexes,
self._index_tokens, self._index_tokens,
@@ -520,6 +751,33 @@ class TclLanguageServer(LanguageServer):
if self._is_same_or_child(cached_path, target): if self._is_same_or_child(cached_path, target):
self._ast_cache.pop(key, None) self._ast_cache.pop(key, None)
self._line_cache.pop(key, None) self._line_cache.pop(key, None)
for uri in list(self._last_parse):
try:
parsed_path = pathlib.Path(uris.to_fs_path(uri))
except (TypeError, ValueError):
continue
if self._is_same_or_child(parsed_path, target):
del self._last_parse[uri]
def _build_file_index(
self, document: TextDocument, filepath: str, cache_tree: bool
) -> "_FileIndex":
tree = (
self.get_tree(document)
if cache_tree
else self.parse_source(document.source)
)
collector = CompletionCollector()
tree.accept(collector, recurse=True)
collector.custom_functions.extend(class_completion_items(tree))
return _FileIndex(
completion_items=list(collector.custom_functions),
proc_signatures=dict(collector.proc_signatures),
proc_docs=build_proc_docs(tree, document.source),
classes=indexed_classes(tree, document.uri, document.source),
navigation_index=build_file_symbol_index(filepath, document.uri, tree),
variable_index=build_variable_index(document.source, tree),
)
def update_poco_completion_for_file( def update_poco_completion_for_file(
self, self,
@@ -527,30 +785,32 @@ class TclLanguageServer(LanguageServer):
*, *,
cache_tree: bool = True, cache_tree: bool = True,
require_file_exists: bool = False, require_file_exists: bool = False,
from_disk: bool = False,
source_stat: FileStat | None = None,
): ):
"""Update poco_completion for a specific file when it changes""" """Update poco_completion for a specific file when it changes.
`from_disk` marks documents that read their source from disk lazily;
`source_stat` is the file's stat taken before a caller read it. Such
results are served from and stored in the persistent index cache.
"""
filepath = str(pathlib.Path(uris.to_fs_path(document.uri))) filepath = str(pathlib.Path(uris.to_fs_path(document.uri)))
token = self._begin_index_update(filepath, document.version) token = self._begin_index_update(filepath, document.version)
if token is None: if token is None:
return False return False
collector = CompletionCollector() stat = source_stat or (file_stat(filepath) if from_disk else None)
try: index = self.index_cache.get(filepath, stat) if stat is not None else None
tree = ( if index is None:
self.get_tree(document) try:
if cache_tree index = self._build_file_index(document, filepath, cache_tree)
else self.parse_source(document.source) except Exception as e:
) LOGGER.debug("Error parsing %s: %s", filepath, e)
tree.accept(collector, recurse=True) self._discard_index_update(filepath, token)
docs = build_proc_docs(tree, document.source) return False
navigation_index = build_file_symbol_index( # Only cache results whose file did not change while being read.
filepath, document.uri, tree if stat is not None and file_stat(filepath) == stat:
) self.index_cache.put(filepath, stat, index)
variable_index = build_variable_index(document.source, tree)
except Exception as e:
LOGGER.debug("Error parsing %s: %s", filepath, e)
self._discard_index_update(filepath, token)
return False
if require_file_exists and not pathlib.Path(filepath).is_file(): if require_file_exists and not pathlib.Path(filepath).is_file():
self._discard_index_update(filepath, token) self._discard_index_update(filepath, token)
@@ -559,11 +819,12 @@ class TclLanguageServer(LanguageServer):
with self._index_lock: with self._index_lock:
if self._index_tokens.get(filepath) != token: if self._index_tokens.get(filepath) != token:
return False return False
self.poco_completion[filepath] = list(collector.custom_functions) self.poco_completion[filepath] = list(index.completion_items)
self.proc_signatures[filepath] = dict(collector.proc_signatures) self.proc_signatures[filepath] = dict(index.proc_signatures)
self.proc_docs[filepath] = docs self.proc_docs[filepath] = index.proc_docs
self.navigation_indexes[filepath] = navigation_index self.class_indexes[filepath] = index.classes
self.variable_indexes[filepath] = (document.version, variable_index) self.navigation_indexes[filepath] = index.navigation_index
self.variable_indexes[filepath] = (document.version, index.variable_index)
self._committed_index_versions[filepath] = document.version self._committed_index_versions[filepath] = document.version
self._invalidate_workspace_caches_locked() self._invalidate_workspace_caches_locked()
return True return True
@@ -650,8 +911,27 @@ class TclLanguageServer(LanguageServer):
) )
) )
diagnostics.extend(self._def_diagnostics(document))
return diagnostics return diagnostics
def _def_diagnostics(self, document: TextDocument) -> List[lsp.Diagnostic]:
documents = self.def_documents_snapshot()
declared = {
kind: frozenset(name for def_document in documents.values() for name in def_document.names(kind))
for kind in (BLOCK_TEMPLATE, ADDRESS)
}
labels = {BLOCK_TEMPLATE: "Block template", ADDRESS: "Address"}
return [
lsp.Diagnostic(
message=f"{labels[kind]} '{name}' is not declared in any loaded .def file",
severity=lsp.DiagnosticSeverity.Warning,
range=range_,
code=f"unknown-{kind.replace('_', '-')}",
source=DIAGNOSTIC_SOURCE,
)
for kind, name, range_ in unknown_def_names(self.get_tree(document), declared)
]
def _compute_diagnostics(self, document: TextDocument) -> List[lsp.Diagnostic]: def _compute_diagnostics(self, document: TextDocument) -> List[lsp.Diagnostic]:
return self.lint(document) return self.lint(document)
+1
View File
@@ -33,6 +33,7 @@ VARIABLE_KINDS = {
lsp.CompletionItemKind.Constant, lsp.CompletionItemKind.Constant,
} }
COMMAND_KINDS = { COMMAND_KINDS = {
lsp.CompletionItemKind.Class,
lsp.CompletionItemKind.Function, lsp.CompletionItemKind.Function,
lsp.CompletionItemKind.Method, lsp.CompletionItemKind.Method,
lsp.CompletionItemKind.Constructor, lsp.CompletionItemKind.Constructor,
+364
View File
@@ -0,0 +1,364 @@
"""Follow .def block template and address names through variables and procs.
A name is only a .def symbol where it provably reaches an NX command taking one:
directly as an argument (``MOM_do_template steady_rest``), through a variable
of the same scope (``set t steady_rest; MOM_do_template $t``) or through the
parameter of a proc that passes it on (``LIB_SPF_call_cycle absolute_mode``).
Derived names are resolved for hover and definition only, never renamed.
"""
from __future__ import annotations
import re
from collections import defaultdict
from collections.abc import Iterable, Iterator
import lsprotocol.types as lsp
from tclint.syntax_tree import BracedWord, Command, CommandSub, List, Node, QuotedWord, Script, VarSub
from tools.tcl_command_completion import TCL_COMMAND_NAMES
ROOT_NAMESPACE = "::"
DEF_BLOCK_TEMPLATE = "block_template"
DEF_ADDRESS = "address"
DEF_SYMBOL_KINDS = frozenset({DEF_BLOCK_TEMPLATE, DEF_ADDRESS})
# NX commands taking .def names: command -> (first argument index, last index or
# None for all following arguments, kind).
_DEF_ARGUMENTS: dict[str, tuple[tuple[int, int | None, str], ...]] = {
"MOM_do_template": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_add_to_block_buffer": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_polar_motion": ((0, 0, DEF_BLOCK_TEMPLATE),),
"MOM_force_block": ((1, None, DEF_BLOCK_TEMPLATE),),
"MOM_ask_address_value": ((0, 0, DEF_ADDRESS),),
"MOM_add_to_address_buffer": ((0, 0, DEF_ADDRESS),),
"MOM_enable_address": ((0, None, DEF_ADDRESS),),
"MOM_disable_address": ((0, None, DEF_ADDRESS),),
"MOM_force": ((1, None, DEF_ADDRESS),),
"MOM_suppress": ((1, None, DEF_ADDRESS),),
"MOM_incremental": ((1, None, DEF_ADDRESS),),
}
_DEFINITION_ELEMENT_COMMANDS = frozenset({"MOM_ask_definition_element", "MOM_has_definition_element"})
_DEFINITION_ELEMENT_KINDS = {"ADDRESS": DEF_ADDRESS, "BLOCK": DEF_BLOCK_TEMPLATE}
# Commands that never pass a .def name on to a proc parameter.
_NON_FORWARDING = (
frozenset(TCL_COMMAND_NAMES)
| frozenset(_DEF_ARGUMENTS)
| _DEFINITION_ELEMENT_COMMANDS
| frozenset({
"set", "unset", "puts", "expr", "return", "incr", "append", "lappend", "list", "lindex", "lrange",
"llength", "lsearch", "lsort", "lreverse", "lassign", "concat", "join", "split", "format", "regsub",
"regexp", "string", "if", "while", "for", "foreach", "lmap", "switch", "catch", "eval", "uplevel",
"upvar", "global", "variable", "info", "array", "dict", "subst", "error", "proc", "namespace",
})
)
# Commands returning (elements of) their first argument's list value.
_LIST_ACCESSORS = frozenset({"lindex", "lrange", "lsort", "lreverse", "lsearch"})
_LIST_WORD_RE = re.compile(r'"([^"\s{}]*)"|([^\s"{}]+)')
# ("def", kind) or ("call", routine, argument index) where a variable ends up.
FlowTarget = tuple
# Fact: ("sink", variable, target) or ("edge", destination, source variable).
FlowFact = tuple
# Per proc: ((parameter index, ("def", kind) | ("call", qualified, fallback, index)), ...)
ProcDefFlows = tuple[tuple[int, tuple], ...]
WrapperTable = dict[str, dict[int, frozenset[str]]]
def static_contents(node: Node | None) -> str | None:
value = getattr(node, "contents", None)
return value if isinstance(value, str) else None
def def_argument_kinds(command: Command) -> list[tuple[Node, str]]:
"""Return the arguments of ``command`` that name a .def block template or address."""
routine = static_contents(command.routine)
if routine in _DEFINITION_ELEMENT_COMMANDS:
kind = _DEFINITION_ELEMENT_KINDS.get((static_contents(command.args[0]) or "").upper()) if command.args else None
return [(command.args[1], kind)] if kind and len(command.args) >= 2 else []
result = []
for first, last, kind in _DEF_ARGUMENTS.get(routine or "", ()):
for position, argument in enumerate(command.args):
if position >= first and (last is None or position <= last):
result.append((argument, kind))
return result
def def_name(node: Node) -> str | None:
name = static_contents(node)
if not name or node.contents_pos is None or any(char.isspace() or char in "$[]{}\\\"" for char in name):
return None
return name
def qualify(name: str, namespace: str) -> str:
if name.startswith("::"):
return name
return f"::{name}" if namespace == ROOT_NAMESPACE else f"{namespace}::{name}"
def _variable_reference(node: Node) -> str | None:
"""Name of the scalar variable ``node`` consists of: ``$v`` or ``"$v"``."""
if isinstance(node, QuotedWord) and len(node.children) == 1:
node = node.children[0]
if isinstance(node, VarSub) and isinstance(node.value, str) and "(" not in node.value:
return node.value
return None
def _value_sources(node: Node) -> list[str]:
"""Variables whose value or list elements ``node`` copies."""
variable = _variable_reference(node)
if variable is not None:
return [variable]
if isinstance(node, CommandSub) and len(node.children) == 1 and isinstance(node.children[0], Command):
inner = node.children[0]
routine = static_contents(inner.routine)
if routine in _LIST_ACCESSORS and inner.args:
return _value_sources(inner.args[0])
if routine in {"list", "concat"}:
return [source for argument in inner.args for source in _value_sources(argument)]
return []
def _bound_names(node: Node) -> list[str]:
nodes = node.children if isinstance(node, List) else [node]
names = [static_contents(child) for child in nodes]
if len(names) == 1 and names[0] and " " in names[0]:
return names[0].split()
return [name for name in names if name]
def command_flow_facts(command: Command) -> list[FlowFact]:
"""Facts on how ``command`` moves variable values towards .def arguments."""
routine = static_contents(command.routine)
args = command.args
facts: list[FlowFact] = [
("sink", variable, ("def", kind))
for node, kind in def_argument_kinds(command)
if (variable := _variable_reference(node)) is not None
]
if routine == "set" and len(args) == 2:
destination = static_contents(args[0])
if destination:
facts.extend(("edge", destination, source) for source in _value_sources(args[1]))
elif routine == "lappend" and args:
destination = static_contents(args[0])
if destination:
facts.extend(("edge", destination, source) for argument in args[1:] for source in _value_sources(argument))
elif routine in {"foreach", "lmap"} and len(args) >= 3:
for position in range(0, len(args) - 1, 2):
sources = _value_sources(args[position + 1])
facts.extend(("edge", name, source) for name in _bound_names(args[position]) for source in sources)
elif routine == "lassign" and args:
sources = _value_sources(args[0])
facts.extend(("edge", name, source) for node in args[1:] if (name := static_contents(node)) for source in sources)
elif routine and routine not in _NON_FORWARDING:
facts.extend(
("sink", variable, ("call", routine, position))
for position, argument in enumerate(args)
if (variable := _variable_reference(argument)) is not None
)
return facts
def solve_flow(facts: Iterable[FlowFact]) -> dict[str, set[FlowTarget]]:
"""Map each variable to the .def arguments and proc parameters it reaches."""
targets: dict[str, set[FlowTarget]] = defaultdict(set)
sources: dict[str, set[str]] = defaultdict(set)
for fact in facts:
if fact[0] == "sink":
targets[fact[1]].add(fact[2])
elif fact[1] != fact[2]:
sources[fact[1]].add(fact[2])
pending = [variable for variable in targets if variable in sources]
while pending:
destination = pending.pop()
for source in sources.get(destination, ()):
before = len(targets[source])
targets[source] |= targets[destination]
if len(targets[source]) != before:
pending.append(source)
return targets
def proc_def_flows(facts: Iterable[FlowFact], parameters: list[str], namespace: str) -> ProcDefFlows:
"""Where the parameters of a proc end up, with qualified callee names."""
targets = solve_flow(facts)
flows = []
for position, parameter in enumerate(parameters):
if parameter == "args" and position == len(parameters) - 1:
break
for target in targets.get(parameter, ()):
if target[0] == "call":
target = ("call", qualify(target[1], namespace), qualify(target[1], ROOT_NAMESPACE), target[2])
flows.append((position, target))
return tuple(sorted(flows))
def build_wrapper_table(procs: Iterable[tuple[str, ProcDefFlows]]) -> WrapperTable:
"""Resolve which proc arguments take .def names, following nested wrappers."""
kinds: dict[str, dict[int, set[str]]] = defaultdict(lambda: defaultdict(set))
calls = []
for proc, flows in procs:
for position, target in flows:
if target[0] == "def":
kinds[proc][position].add(target[1])
else:
calls.append((proc, position, target[1], target[2], target[3]))
changed = True
while changed:
changed = False
for proc, position, callee, fallback, callee_position in calls:
entry = kinds.get(callee) or kinds.get(fallback)
found = entry.get(callee_position) if entry else None
if found and not found <= kinds[proc][position]:
kinds[proc][position] |= found
changed = True
return {
proc: {position: frozenset(names) for position, names in positions.items() if names}
for proc, positions in kinds.items()
if any(positions.values())
}
def _wrapper_kinds(table: WrapperTable, routine: str, position: int, namespace: str) -> frozenset[str]:
entry = table.get(qualify(routine, namespace)) or table.get(qualify(routine, ROOT_NAMESPACE))
return entry.get(position, frozenset()) if entry else frozenset()
def _target_kinds(target: FlowTarget, table: WrapperTable, namespace: str) -> frozenset[str]:
if target[0] == "def":
return frozenset({target[1]})
return _wrapper_kinds(table, target[1], target[2], namespace)
def _scope_commands(script: Node) -> Iterator[Command]:
"""Commands of one scope, without the bodies of procs defined in it."""
for child in getattr(script, "children", []):
if isinstance(child, Command):
yield child
if static_contents(child.routine) == "proc":
continue
yield from _scope_commands(child)
def _contains(node: Node, point: tuple[int, int]) -> bool:
return node.pos is not None and node.end_pos is not None and node.pos <= point < node.end_pos
def _path_at(tree: Node, point: tuple[int, int]) -> list[Node]:
path = [tree]
while True:
child = next((child for child in getattr(path[-1], "children", []) if _contains(child, point)), None)
if child is None:
return path
path.append(child)
def _literal_at(node: Node, point: tuple[int, int]) -> tuple[str, lsp.Range] | None:
"""The single name ``node`` holds, or the list element of a braced word at ``point``."""
if isinstance(node, BracedWord):
contents = static_contents(node)
if contents is None or node.contents_pos is None:
return None
line, column = node.contents_pos
for match in _LIST_WORD_RE.finditer(contents):
start = match.start(1) if match.group(1) is not None else match.start(2)
name = match.group(1) if match.group(1) is not None else match.group(2)
before = contents[:start]
element_line = line + before.count("\n")
element_column = (start - before.rfind("\n") if "\n" in before else column + start)
if element_line == point[0] and element_column <= point[1] < element_column + len(name):
return name, _range(element_line, element_column, name)
return None
name = def_name(node)
if name is None:
return None
line, column = node.contents_pos
return name, _range(line, column, name)
def _range(line: int, column: int, name: str) -> lsp.Range:
return lsp.Range(
start=lsp.Position(line=line - 1, character=column - 1),
end=lsp.Position(line=line - 1, character=column - 1 + len(name)),
)
def _literal_targets(commands: list[Command], values: list[Node]) -> tuple[list[str], list[FlowTarget]]:
"""Variables and proc arguments a literal flows into; ``values`` are its enclosing words."""
command, value = commands[-1], values[-1]
routine = static_contents(command.routine)
args = list(command.args)
position = next((index for index, argument in enumerate(args) if argument is value), None)
if position is None:
return [], []
if routine in {"list", "concat"} and len(commands) >= 2 and isinstance(values[-2], CommandSub):
return _literal_targets(commands[:-1], values[:-1])
if routine == "set" and position == 1:
destination = static_contents(args[0])
return ([destination] if destination else []), []
if routine == "lappend" and position >= 1:
destination = static_contents(args[0])
return ([destination] if destination else []), []
if routine in {"foreach", "lmap"} and position % 2 == 1 and position < len(args) - 1:
return _bound_names(args[position - 1]), []
if routine and routine not in _NON_FORWARDING:
return [], [("call", routine, position)]
return [], []
def derived_def_symbol(
tree: Node, position: lsp.Position, table: WrapperTable
) -> tuple[frozenset[str], str, lsp.Range] | None:
"""Kinds, name and range of a literal that reaches a .def argument indirectly."""
point = (position.line + 1, position.character + 1)
path = _path_at(tree, point)
commands: list[Command] = []
values: list[Node] = []
scope: Node = tree
namespace = ROOT_NAMESPACE
for parent, child in zip(path, path[1:]):
if isinstance(parent, Command):
commands.append(parent)
values.append(child)
if static_contents(parent.routine) == "proc" and len(parent.args) >= 3 and child is parent.args[2]:
scope = child
name = qualify(static_contents(parent.args[0]) or "", ROOT_NAMESPACE)
namespace = name.rsplit("::", 1)[0] or ROOT_NAMESPACE
if not commands or isinstance(values[-1], Script):
return None
literal = _literal_at(values[-1], point)
if literal is None:
return None
variables, targets = _literal_targets(commands, values)
if variables:
scope_targets = solve_flow(fact for command in _scope_commands(scope) for fact in command_flow_facts(command))
targets.extend(target for variable in variables for target in scope_targets.get(variable, ()))
kinds = frozenset(kind for target in targets for kind in _target_kinds(target, table, namespace))
return (kinds, *literal) if kinds else None
def _all_commands(node: Node) -> Iterator[Command]:
for child in getattr(node, "children", []):
if isinstance(child, Command):
yield child
yield from _all_commands(child)
def unknown_def_names(tree: Node, declared: dict[str, frozenset[str]]) -> list[tuple[str, str, lsp.Range]]:
"""Literal NX command arguments naming a block template or address no .def file declares.
``declared`` maps each kind to its declared names; kinds without any
declaration are not checked, since their .def file is not loaded.
"""
unknown = []
for command in _all_commands(tree):
for node, kind in def_argument_kinds(command):
names = declared.get(kind)
name = def_name(node) if names else None
if name is not None and name not in names:
line, column = node.contents_pos
unknown.append((kind, name, _range(line, column, name)))
return unknown
+201
View File
@@ -0,0 +1,201 @@
"""Navigation between Tcl code and the block templates and addresses of .def files."""
from __future__ import annotations
from pathlib import Path
import lsprotocol.types as lsp
from tools.def_symbols import ADDRESS, BLOCK_TEMPLATE, FORMAT, DefDeclaration, DefDocument
from tools.navigation import FileSymbolIndex, SymbolOccurrence
DefTarget = tuple[str, str]
_KIND_LABELS = {BLOCK_TEMPLATE: "Block template", ADDRESS: "Address", FORMAT: "Format"}
# Address properties in display order; others follow as declared.
_ADDRESS_PROPERTIES = (
("FORMAT", "Format"),
("LEADER", "Leader"),
("TRAILER", "Trailer"),
("MIN", "Min"),
("MAX", "Max"),
("FORCE", "Modality"),
("ZERO_FORMAT", "Zero format"),
("INCREMENTAL", "Incremental"),
("OMIT", "Omit"),
)
_MODALITY = {
"OFF": "modal, output only on change",
"ONCE": "output once, then modal",
"ALWAYS": "non-modal, always output",
}
def _range(line: int, start: int, end: int) -> lsp.Range:
return lsp.Range(
start=lsp.Position(line=line, character=start),
end=lsp.Position(line=line, character=end),
)
def _contains(line: int, start: int, end: int, position: lsp.Position) -> bool:
return position.line == line and start <= position.character <= end
def def_symbol_at(document: DefDocument, position: lsp.Position) -> tuple[DefTarget, lsp.Range, bool] | None:
"""Return the block template or address at ``position`` of a .def document.
The flag tells whether the position is on a declaration.
"""
for declaration in document.declarations:
if declaration.kind != FORMAT and _contains(declaration.line, declaration.start, declaration.end, position):
return (declaration.kind, declaration.name), _range(declaration.line, declaration.start, declaration.end), True
for reference in document.references:
if _contains(reference.line, reference.start, reference.end, position):
return (reference.kind, reference.name), _range(reference.line, reference.start, reference.end), False
return None
def _uri(path: str, uris: dict[str, str]) -> str:
return uris.get(path) or Path(path).as_uri()
def def_declarations(documents: dict[str, DefDocument], target: DefTarget) -> list[tuple[str, DefDeclaration]]:
kind, name = target
return [
(path, declaration)
for path, document in documents.items()
for declaration in document.declarations
if declaration.kind == kind and declaration.name == name
]
def def_definition_locations(
documents: dict[str, DefDocument], target: DefTarget, uris: dict[str, str] | None = None
) -> list[lsp.Location]:
uris = uris or {}
return [
lsp.Location(uri=_uri(path, uris), range=_range(declaration.line, declaration.start, declaration.end))
for path, declaration in def_declarations(documents, target)
]
def effective_def_locations(
documents: dict[str, DefDocument], target: DefTarget, uris: dict[str, str] | None = None
) -> list[lsp.Location]:
"""The declaration NX uses: ``documents`` follow the PSC layer order, the last one wins."""
return def_definition_locations(documents, target, uris)[-1:]
def def_reference_locations(
documents: dict[str, DefDocument],
target: DefTarget,
include_declaration: bool,
uris: dict[str, str] | None = None,
) -> list[lsp.Location]:
"""Return .def occurrences: declarations and addresses used in block templates."""
kind, name = target
uris = uris or {}
locations = def_definition_locations(documents, target, uris) if include_declaration else []
for path, document in documents.items():
for reference in document.references:
if reference.kind == kind and reference.name == name:
locations.append(
lsp.Location(uri=_uri(path, uris), range=_range(reference.line, reference.start, reference.end))
)
return locations
def tcl_def_occurrences(
indexes: dict[str, FileSymbolIndex], target: DefTarget
) -> list[tuple[FileSymbolIndex, SymbolOccurrence]]:
kind, name = target
return [
(index, occurrence)
for index in indexes.values()
for occurrence in index.occurrences
if occurrence.identity.kind == kind and occurrence.identity.name == name
]
def all_def_target_locations(
documents: dict[str, DefDocument],
indexes: dict[str, FileSymbolIndex],
target: DefTarget,
include_declaration: bool,
uris: dict[str, str] | None = None,
) -> list[lsp.Location]:
locations = def_reference_locations(documents, target, include_declaration, uris)
locations.extend(
lsp.Location(uri=index.uri, range=occurrence.range) for index, occurrence in tcl_def_occurrences(indexes, target)
)
unique = {}
for location in locations:
key = (location.uri, location.range.start.line, location.range.start.character)
unique.setdefault(key, location)
return sorted(unique.values(), key=lambda item: (item.uri, item.range.start.line, item.range.start.character))
def def_rename_edits(
documents: dict[str, DefDocument],
indexes: dict[str, FileSymbolIndex],
target: DefTarget,
new_name: str,
uris: dict[str, str] | None = None,
) -> lsp.WorkspaceEdit | None:
"""Rename a block template or address in all .def and Tcl files, if it is declared."""
if not def_declarations(documents, target):
return None
changes: dict[str, list[lsp.TextEdit]] = {}
for location in all_def_target_locations(documents, indexes, target, True, uris):
changes.setdefault(location.uri, []).append(lsp.TextEdit(range=location.range, new_text=new_name))
for edits in changes.values():
edits.sort(key=lambda edit: (edit.range.start.line, edit.range.start.character), reverse=True)
return lsp.WorkspaceEdit(changes=changes)
def _escape_cell(value: str) -> str:
return value.replace("|", "\\|") or " "
def _address_table(declaration: DefDeclaration, formats: dict[str, DefDeclaration]) -> str:
properties = dict(declaration.properties)
rows = []
for key, label in _ADDRESS_PROPERTIES:
value = properties.pop(key, None)
if value is None:
continue
cell = f"`{value}`" if value else ""
if key == "FORMAT" and value in formats:
cell += f" → `{dict(formats[value].properties).get('FORMAT', '')}`"
if key == "FORCE":
cell += f" ({_MODALITY[value.upper()]})" if value.upper() in _MODALITY else ""
rows.append(f"| {label} | {_escape_cell(cell)} |")
rows.extend(f"| {key.title()} | {_escape_cell(f'`{value}`' if value else '')} |" for key, value in properties.items())
if not rows:
return "_No properties_"
return "\n".join(["| Property | Value |", "|---|---|", *rows])
def def_hover_markdown(documents: dict[str, DefDocument], target: DefTarget) -> str | None:
"""Describe the effective (last loaded) declaration and name the ones it overrides."""
declarations = def_declarations(documents, target)
if not declarations:
return None
formats = {
declaration.name: declaration
for document in documents.values()
for declaration in document.declarations
if declaration.kind == FORMAT
}
kind, name = target
path, declaration = declarations[-1]
header = f"**{_KIND_LABELS[kind]}** `{name}` — {Path(path).name}:{declaration.line + 1}"
if kind == ADDRESS:
body = _address_table(declaration, formats)
else:
body = f"```def\n{declaration.text}\n```"
markdown = f"{header}\n\n{body}"
if len(declarations) > 1:
overridden = ", ".join(f"{Path(other).name}:{item.line + 1}" for other, item in declarations[:-1])
markdown += f"\n\n---\n\n_Overrides {overridden}_"
return markdown
+180
View File
@@ -0,0 +1,180 @@
"""Block templates, addresses and formats declared in NX post definition (.def) files."""
import re
from dataclasses import dataclass
from pathlib import Path
BLOCK_TEMPLATE = "block_template"
ADDRESS = "address"
FORMAT = "format"
_KINDS = {"BLOCK_TEMPLATE": BLOCK_TEMPLATE, "ADDRESS": ADDRESS, "FORMAT": FORMAT}
_HEADER_RE = re.compile(r"^\s*(BLOCK_TEMPLATE|ADDRESS|FORMAT)\s+([^\s{]+)")
# A block template element is an address followed by its expression: X[$mom_pos(0)].
_ELEMENT_RE = re.compile(r"^\s*([A-Za-z_]\w*)\[")
_PROPERTY_RE = re.compile(r"^\s*([A-Za-z_]+)\s*(.*?)\s*$")
@dataclass(frozen=True)
class DefDeclaration:
kind: str
name: str
# 0-based line and UTF-16 columns of the name.
line: int
start: int
end: int
end_line: int
text: str
# ADDRESS: body properties such as ("LEADER", '"X"'); FORMAT: (("FORMAT", '"%d"'),).
properties: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True)
class DefReference:
"""An address used as element of a block template."""
kind: str
name: str
line: int
start: int
end: int
container: str
@dataclass(frozen=True)
class DefDocument:
declarations: tuple[DefDeclaration, ...] = ()
references: tuple[DefReference, ...] = ()
def names(self, kind: str) -> tuple[str, ...]:
return tuple(dict.fromkeys(item.name for item in self.declarations if item.kind == kind))
@dataclass(frozen=True)
class DefSymbols:
block_templates: tuple[str, ...] = ()
addresses: tuple[str, ...] = ()
def _utf16_length(text: str) -> int:
return len(text.encode("utf-16-le")) // 2
def _is_comment(line: str) -> bool:
return line.lstrip().startswith("#")
def _body(lines: list[str], header: int, rest: str) -> tuple[int, int, int] | None:
"""Return (first body line, first body column, closing line) of a braced body.
The body starts after the first "{" on the header line or on a following
line and ends at the next line starting with "}".
"""
if "{" in rest:
after = rest.split("{", 1)[1]
if "}" in after:
return None
start = header + 1
else:
start = header + 1
while start < len(lines) and (not lines[start].strip() or _is_comment(lines[start])):
start += 1
if start >= len(lines) or not lines[start].lstrip().startswith("{"):
return None
start += 1
end = start
while end < len(lines) and not lines[end].lstrip().startswith("}"):
end += 1
return start, 0, min(end, len(lines) - 1)
def parse_def_document(source: str) -> DefDocument:
"""Parse the block templates, addresses and formats of a .def source."""
lines = source.splitlines()
declarations: list[DefDeclaration] = []
references: list[DefReference] = []
index = 0
while index < len(lines):
line = lines[index]
match = None if _is_comment(line) else _HEADER_RE.match(line)
if match is None:
index += 1
continue
kind = _KINDS[match.group(1)]
name = match.group(2)
start = _utf16_length(line[: match.start(2)])
end = start + _utf16_length(name)
rest = line[match.end(2) :]
if kind == FORMAT:
declarations.append(
DefDeclaration(kind, name, index, start, end, index, line.strip(), (("FORMAT", rest.strip()),))
)
index += 1
continue
body = _body(lines, index, rest)
end_line = index if body is None else body[2]
properties: list[tuple[str, str]] = []
if body is not None:
for number in range(body[0], body[2]):
body_line = lines[number]
if _is_comment(body_line) or not body_line.strip():
continue
if kind == BLOCK_TEMPLATE:
element = _ELEMENT_RE.match(body_line)
if element is not None:
element_start = _utf16_length(body_line[: element.start(1)])
references.append(
DefReference(
ADDRESS,
element.group(1),
number,
element_start,
element_start + _utf16_length(element.group(1)),
name,
)
)
else:
prop = _PROPERTY_RE.match(body_line)
if prop is not None:
properties.append((prop.group(1).upper(), prop.group(2)))
declarations.append(
DefDeclaration(
kind,
name,
index,
start,
end,
end_line,
"\n".join(lines[index : end_line + 1]),
tuple(properties),
)
)
index = end_line + 1
return DefDocument(tuple(declarations), tuple(references))
def parse_def_symbols(source: str) -> DefSymbols:
"""Return the BLOCK_TEMPLATE and ADDRESS names of a .def source in declaration order."""
document = parse_def_document(source)
return DefSymbols(
block_templates=document.names(BLOCK_TEMPLATE),
addresses=document.names(ADDRESS),
)
def read_def_source(path: Path) -> str:
data = path.read_bytes()
try:
return data.decode("utf-8-sig")
except UnicodeDecodeError:
# Older Windows NX layers use the ANSI code page.
return data.decode("cp1252")
def read_def_symbols(path: Path) -> DefSymbols:
return parse_def_symbols(read_def_source(path))
+49 -12
View File
@@ -1,5 +1,6 @@
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from dataclasses import dataclass import os
from dataclasses import dataclass, field
from typing import List, Optional from typing import List, Optional
from pathlib import Path from pathlib import Path
@@ -9,11 +10,15 @@ class SourcedFile:
layer_name: str layer_name: str
subfolder: Optional[str] subfolder: Optional[str]
files: List[str] files: List[str]
defined_events: List[str] = field(default_factory=list)
def read_psc_file(psc_file: Path) -> List[SourcedFile]: def read_psc_file(psc_file: Path) -> List[SourcedFile]:
tree = ET.parse(psc_file) tree = ET.parse(psc_file)
root = tree.getroot() root = tree.getroot()
# PSC exports may use a default XML namespace.
for element in root.iter():
element.tag = element.tag.rsplit("}", 1)[-1]
layers = root.findall(".//Layer") layers = root.findall(".//Layer")
@@ -30,22 +35,54 @@ def read_psc_file(psc_file: Path) -> List[SourcedFile]:
if name: if name:
script_names.append(name) script_names.append(name)
# DefinedEvents-Filenames (.def files)
defined_events = layer.find("DefinedEvents")
event_names = []
if defined_events is not None:
for filename in defined_events.findall("Filename"):
name = filename.attrib.get("Name")
if name:
event_names.append(name)
layer_info_list.append( layer_info_list.append(
SourcedFile(layer_name=layer_name, subfolder=subfolder, files=script_names) SourcedFile(
layer_name=layer_name,
subfolder=subfolder,
files=script_names,
defined_events=event_names,
)
) )
return layer_info_list return layer_info_list
def get_all_psc_files(root_path: Path) -> list[Path]: def get_all_psc_files(root_path: Path) -> list[Path]:
return [path for path in root_path.rglob("*.psc")] return sorted(root_path.rglob("*.psc"), key=lambda path: str(path).casefold())
if __name__ == "__main__": def _expanded(value) -> Path:
test = get_all_psc_files( return Path(os.path.expandvars(value).replace("\\", "/"))
Path(
r"H:\janus-engineering-customers\KSB_Frankenthal\custom\library\machine\installed_machines\ksb_pe_grob_g550_sone\postprocessor"
) def _layer_files(psc_file: Path, attribute: str, suffix: str) -> list[Path]:
) paths = []
print(test) for layer in read_psc_file(psc_file):
for pp in test: folder = layer.subfolder or "."
read_psc_file(pp) base = psc_file.parent / _expanded(os.environ.get(folder, folder))
for name in getattr(layer, attribute):
filename = _expanded(name)
if not filename.suffix:
filename = filename.with_suffix(suffix)
path = (base / filename).resolve()
if path.suffix.lower() == suffix:
paths.append(path)
return paths
def psc_script_files(psc_file: Path) -> list[Path]:
"""Resolve layer script paths relative to the PSC, preserving load order."""
return _layer_files(psc_file, "files", ".tcl")
def psc_defined_event_files(psc_file: Path) -> list[Path]:
"""Resolve the layers' DefinedEvents (.def) paths relative to the PSC."""
return _layer_files(psc_file, "defined_events", ".def")
+8
View File
@@ -12,6 +12,14 @@ class NxFormatter(BaseFormatter):
while leaving all other formatting behavior unchanged. while leaving all other formatting behavior unchanged.
""" """
def format_comment(self, comment) -> List[str]: # type: ignore[override]
# "#Comment" -> "# Comment". Leave "#", "##..." separators, "#!" and
# comments that already start with whitespace untouched.
value = comment.value
if value and not value[0].isspace() and value[0] not in "#!":
value = " " + value
return [f"#{value}"]
def format_braced_expression(self, expr) -> List[str]: # type: ignore[override] def format_braced_expression(self, expr) -> List[str]: # type: ignore[override]
# This method mirrors BaseFormatter.format_braced_expression but inserts # This method mirrors BaseFormatter.format_braced_expression but inserts
# a line continuation (" \") between continuation lines similar to # a line continuation (" \") between continuation lines similar to
+156
View File
@@ -0,0 +1,156 @@
"""Reparse only the top-level commands touched by an edit.
Tcl top-level commands are independent once the previous command ended on its
own line: the parser keeps no state between them. An edit is therefore
reparsed from the first to the last top-level command it touches, commands
before it are reused as-is and commands after it are reused with their line
numbers shifted. Whenever that assumption could break, the caller falls back
to a full parse.
"""
from __future__ import annotations
import copy
from collections.abc import Callable
from tclint.syntax_tree import Node, Script
from tclint.violations import Violation
ParseChunk = Callable[[str, tuple[int, int]], tuple[Script, list[Violation]]]
def normalize_newlines(source: str) -> str:
"""Match the universal newline handling of the tclint parser."""
return source.replace("\r\n", "\n").replace("\r", "\n")
def _shifted_tree(node: Node, delta: int) -> Node:
"""Copy a subtree with its line numbers moved by `delta`.
Cached trees may still be read by other requests, so nodes are never
mutated. Attributes such as `Command.routine` alias entries of `children`,
so every reference is remapped to the same copy.
"""
copies: dict[int, Node] = {}
def shifted(original: Node) -> Node:
existing = copies.get(id(original))
if existing is not None:
return existing
clone = object.__new__(type(original))
copies[id(original)] = clone
state = dict(original.__dict__)
if state.get("line") is not None:
state["line"] += delta
end = state.get("end_pos")
if end is not None:
state["end_pos"] = (end[0] + delta, end[1])
for key, value in state.items():
if isinstance(value, Node):
state[key] = shifted(value)
elif isinstance(value, (list, tuple)) and value and isinstance(value[0], Node):
state[key] = type(value)(shifted(item) for item in value)
clone.__dict__.update(state)
return clone
return shifted(node)
def _shifted_violation(violation: Violation, delta: int) -> Violation:
clone = copy.copy(violation)
clone.start = (violation.start[0] + delta, violation.start[1])
clone.end = (violation.end[0] + delta, violation.end[1])
return clone
def reparse(
old_source: str,
old_tree: Script,
old_violations: list[Violation],
new_source: str,
parse_chunk: ParseChunk,
) -> tuple[Script, list[Violation]] | None:
"""Return the tree of `new_source`, or None when a full parse is needed.
Both sources must already be newline-normalized. `parse_chunk` parses a
top-level fragment starting at the given (line, column) and may raise
TclSyntaxError, which the caller handles like any failed parse.
"""
if old_source == new_source:
return old_tree, list(old_violations)
# Changed line range (1-indexed); lines outside it are identical. A pure
# insertion leaves last_changed_old == first_changed_line - 1.
old_lines = old_source.split("\n")
new_lines = new_source.split("\n")
limit = min(len(old_lines), len(new_lines))
same_before = 0
while same_before < limit and old_lines[same_before] == new_lines[same_before]:
same_before += 1
same_after = 0
while (
same_after < limit - same_before
and old_lines[-1 - same_after] == new_lines[-1 - same_after]
):
same_after += 1
first_changed_line = same_before + 1
last_changed_old = len(old_lines) - same_after
delta = len(new_lines) - len(old_lines)
commands = old_tree.children
if any(command.line is None or command.end_pos is None for command in commands):
return None
# Commands overlapping the changed lines, widened so that no reused
# command shares a line with the reparsed range.
first = next(
(index for index, command in enumerate(commands) if command.end_pos[0] >= first_changed_line),
len(commands),
)
start_line = first_changed_line
if first < len(commands):
start_line = min(start_line, commands[first].line)
while first > 0 and commands[first - 1].end_pos[0] >= start_line:
first -= 1
start_line = min(start_line, commands[first].line)
last = first - 1
end_line_old = last_changed_old
while last + 1 < len(commands) and commands[last + 1].line <= end_line_old:
last += 1
end_line_old = max(end_line_old, commands[last].end_pos[0])
end_line_new = end_line_old + delta
if end_line_new < start_line - 1 or end_line_new > len(new_lines):
return None
# A trailing backslash joins a line with the next one across the boundary.
if start_line > 1 and new_lines[start_line - 2].endswith("\\"):
return None
if end_line_new >= start_line and new_lines[end_line_new - 1].endswith("\\"):
return None
chunk_commands: list[Node] = []
chunk_violations: list[Violation] = []
if end_line_new >= start_line:
chunk = "\n".join(new_lines[start_line - 1 : end_line_new])
chunk_tree, chunk_violations = parse_chunk(chunk, (start_line, 1))
chunk_commands = chunk_tree.children
reused_after = [_shifted_tree(command, delta) for command in commands[last + 1 :]]
tree = Script(
*commands[:first],
*chunk_commands,
*reused_after,
pos=(old_tree.line, old_tree.col),
)
tree.end_pos = (len(new_lines), len(new_lines[-1]) + 1)
violations = [violation for violation in old_violations if violation.start[0] < start_line]
violations += chunk_violations
violations += [
_shifted_violation(violation, delta)
for violation in old_violations
if violation.start[0] > end_line_old
]
return tree, violations
+128
View File
@@ -0,0 +1,128 @@
"""Persist per-file index results across server restarts.
Entries are keyed by path and validated by the file's size and mtime. The
whole cache is tied to a fingerprint of the code that produced it: the
indexing sources of this server, the bundled tclint sources and the versions
of all bundled libraries. Any change to them, including a tclint update or a
local patch, discards the cache instead of loading stale results.
"""
from __future__ import annotations
import hashlib
import logging
import os
import pathlib
import pickle
import sys
import tempfile
import threading
import zlib
from typing import Any
LOGGER = logging.getLogger(__name__)
# Bump when the cached data layout changes without a source change above.
CACHE_FORMAT = 1
CACHE_FILE = "index-cache.pickle.z"
_SRC_DIR = pathlib.Path(__file__).resolve().parent.parent
_LIBS_DIR = _SRC_DIR.parent / "libs"
FileStat = tuple[int, int]
def code_fingerprint() -> str:
digest = hashlib.sha256()
digest.update(f"{CACHE_FORMAT}|{sys.version}".encode())
sources = [
_SRC_DIR / "lsp_tclserver.py",
*sorted((_SRC_DIR / "tools").glob("*.py")),
*sorted((_SRC_DIR / "plugins").glob("*.py")),
*sorted((_LIBS_DIR / "tclint").rglob("*.py")),
]
for source in sources:
digest.update(source.relative_to(_SRC_DIR.parent).as_posix().encode())
digest.update(source.read_bytes())
for dist_info in sorted(_LIBS_DIR.glob("*.dist-info")):
digest.update(dist_info.name.encode())
return digest.hexdigest()
def file_stat(path: str) -> FileStat | None:
try:
stat = os.stat(path)
except OSError:
return None
return stat.st_mtime_ns, stat.st_size
class IndexCache:
def __init__(self, directory: pathlib.Path | None = None, fingerprint: str = ""):
self._path = directory / CACHE_FILE if directory is not None else None
self._fingerprint = fingerprint
self._entries: dict[str, tuple[FileStat, Any]] = {}
self._used: set[str] = set()
self._dirty = False
self._lock = threading.Lock()
@classmethod
def load(cls, directory: pathlib.Path | str | None) -> IndexCache:
"""Open the cache in `directory`; without one, nothing is persisted."""
if not directory:
return cls()
cache = cls(pathlib.Path(directory), code_fingerprint())
try:
with open(cache._path, "rb") as file:
fingerprint, entries = pickle.loads(zlib.decompress(file.read()))
except FileNotFoundError:
return cache
except Exception as error: # A damaged cache must never stop indexing.
LOGGER.warning("Ignoring unreadable index cache %s: %s", cache._path, error)
cache._dirty = True
return cache
if fingerprint == cache._fingerprint:
cache._entries = entries
else:
cache._dirty = True
return cache
def get(self, path: str, stat: FileStat) -> Any | None:
with self._lock:
entry = self._entries.get(path)
if entry is None or entry[0] != stat:
return None
self._used.add(path)
return entry[1]
def put(self, path: str, stat: FileStat, data: Any) -> None:
if self._path is None:
return
with self._lock:
self._entries[path] = (stat, data)
self._used.add(path)
self._dirty = True
def save(self) -> None:
"""Write entries used in this session atomically; others are dropped."""
if self._path is None:
return
with self._lock:
if not self._dirty and self._used == self._entries.keys():
return
entries = {path: self._entries[path] for path in self._used if path in self._entries}
self._entries = entries
self._dirty = False
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=self._path.parent, delete=False) as file:
data = pickle.dumps((self._fingerprint, entries), protocol=pickle.HIGHEST_PROTOCOL)
# Pickled indexes are very repetitive; fast compression cuts ~90%.
file.write(zlib.compress(data, 1))
os.replace(file.name, self._path)
except Exception as error:
LOGGER.warning("Could not write index cache %s: %s", self._path, error)
try:
os.unlink(file.name)
except (OSError, NameError):
pass
+20 -1
View File
@@ -9,6 +9,8 @@ from typing import Any
import lsprotocol.types as lsp import lsprotocol.types as lsp
from tclint.syntax_tree import Command, VarSub, Visitor from tclint.syntax_tree import Command, VarSub, Visitor
from tools.navigation import FileSymbolIndex from tools.navigation import FileSymbolIndex
from tools.tcloo_arguments import method_parameters
from tools.tcloo_completion import resolved_method_calls
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -156,10 +158,13 @@ class InlayHintGenerator(Visitor):
requested_range: lsp.Range | None = None, requested_range: lsp.Range | None = None,
parameter_names: str = "all", parameter_names: str = "all",
suppress_when_argument_matches_name: bool = True, suppress_when_argument_matches_name: bool = True,
external_classes=None,
): ):
self.source_lines = ( self.source_lines = (
source_lines if source_lines is not None else source.splitlines() source_lines if source_lines is not None else source.splitlines()
) )
self.source = source
self.external_classes = external_classes
self.proc_signatures = proc_signatures self.proc_signatures = proc_signatures
self.requested_range = requested_range self.requested_range = requested_range
self.parameter_names = parameter_names self.parameter_names = parameter_names
@@ -194,6 +199,17 @@ class InlayHintGenerator(Visitor):
walk(child) walk(child)
walk(tree) walk(tree)
if self.parameter_names != "none":
for call in resolved_method_calls(self.source, self.external_classes, tree):
if not self._node_intersects_requested_range(call.command):
continue
parameters = method_parameters(call.parameters)
signature = InlayHintSignature(
parameters=tuple(InlayHintParameter(p.name, variadic=p.variadic) for p in parameters),
display_label=" ".join([call.label, *(p.label for p in parameters)]),
)
self._argument_hints(signature, call.command.args[call.argument_offset:])
self.hints.sort(key=lambda hint: (hint.position.line, hint.position.character))
return self.hints return self.hints
def _position(self, line: int, column: int) -> lsp.Position: def _position(self, line: int, column: int) -> lsp.Position:
@@ -238,7 +254,10 @@ class InlayHintGenerator(Visitor):
if signature is None or self.parameter_names == "none": if signature is None or self.parameter_names == "none":
return return
for argument_index, argument in enumerate(command.args): self._argument_hints(signature, command.args)
def _argument_hints(self, signature, arguments):
for argument_index, argument in enumerate(arguments):
parameter = self._parameter_for_argument(signature, argument_index) parameter = self._parameter_for_argument(signature, argument_index)
if parameter is None: if parameter is None:
break break
+80 -5
View File
@@ -5,6 +5,17 @@ from pathlib import Path
import lsprotocol.types as lsp import lsprotocol.types as lsp
from tclint.syntax_tree import Command, List, Node, QuotedWord, Script, VarSub from tclint.syntax_tree import Command, List, Node, QuotedWord, Script, VarSub
from tools.def_flow import ( # noqa: F401 (re-exported)
DEF_ADDRESS,
DEF_BLOCK_TEMPLATE,
DEF_SYMBOL_KINDS,
ProcDefFlows,
command_flow_facts,
def_argument_kinds,
proc_def_flows,
)
from tools.def_flow import def_name as _def_name
from tools.stored_procs import stored_command_names
from tools.variable_names import array_key_parts, variable_name from tools.variable_names import array_key_parts, variable_name
ROOT_NAMESPACE = "::" ROOT_NAMESPACE = "::"
@@ -39,6 +50,8 @@ class FileSymbolIndex:
uri: str uri: str
occurrences: tuple[SymbolOccurrence, ...] occurrences: tuple[SymbolOccurrence, ...]
document_range: lsp.Range | None = None document_range: lsp.Range | None = None
# Procs whose parameters reach .def arguments: (qualified proc name, flows).
def_flows: tuple[tuple[str, ProcDefFlows], ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -265,6 +278,15 @@ def build_file_symbol_index(
filepath: str, uri: str, tree: Node filepath: str, uri: str, tree: Node
) -> FileSymbolIndex: ) -> FileSymbolIndex:
occurrences: list[SymbolOccurrence] = [] occurrences: list[SymbolOccurrence] = []
# Most occurrences repeat a few identities; sharing one object per identity
# keeps the index (and its persistent cache) small.
identities: dict[SymbolIdentity, SymbolIdentity] = {}
def_flows: list[tuple[str, ProcDefFlows]] = []
# Flow facts of the procs being walked, innermost last.
flow_facts: list[list] = []
def shared(identity: SymbolIdentity | None) -> SymbolIdentity | None:
return None if identity is None else identities.setdefault(identity, identity)
def add_proc( def add_proc(
node: Node, node: Node,
@@ -273,8 +295,9 @@ def build_file_symbol_index(
*, *,
is_definition: bool, is_definition: bool,
declaration_range: lsp.Range | None = None, declaration_range: lsp.Range | None = None,
name_range: lsp.Range | None = None,
) -> None: ) -> None:
identity = _proc_identity(raw_name, scope.namespace) identity = shared(_proc_identity(raw_name, scope.namespace))
caller = None caller = None
if not is_definition: if not is_definition:
caller = ( caller = (
@@ -288,14 +311,14 @@ def build_file_symbol_index(
fallback_identity=( fallback_identity=(
None None
if is_definition if is_definition
else _proc_fallback(raw_name, scope.namespace) else shared(_proc_fallback(raw_name, scope.namespace))
), ),
range=_name_range(node, raw_name), range=name_range or _name_range(node, raw_name),
placeholder=_basename(raw_name), placeholder=_basename(raw_name),
is_definition=is_definition, is_definition=is_definition,
symbol_kind=lsp.SymbolKind.Function, symbol_kind=lsp.SymbolKind.Function,
container_name=_container_name(identity), container_name=_container_name(identity),
caller=caller, caller=shared(caller),
declaration_range=declaration_range, declaration_range=declaration_range,
) )
) )
@@ -309,7 +332,7 @@ def build_file_symbol_index(
variable_sub: bool = False, variable_sub: bool = False,
identity: SymbolIdentity | None = None, identity: SymbolIdentity | None = None,
) -> None: ) -> None:
symbol_identity = identity or _variable_identity(raw_name, scope) symbol_identity = shared(identity or _variable_identity(raw_name, scope))
occurrences.append( occurrences.append(
SymbolOccurrence( SymbolOccurrence(
identity=symbol_identity, identity=symbol_identity,
@@ -378,11 +401,13 @@ def build_file_symbol_index(
) )
parameters = command.args[1] parameters = command.args[1]
parameter_names = []
for parameter in getattr(parameters, "children", []): for parameter in getattr(parameters, "children", []):
parameter_node = parameter parameter_node = parameter
if isinstance(parameter, List) and parameter.children: if isinstance(parameter, List) and parameter.children:
parameter_node = parameter.children[0] parameter_node = parameter.children[0]
parameter_name = _static_contents(parameter_node) parameter_name = _static_contents(parameter_node)
parameter_names.append(parameter_name or "")
if parameter_name: if parameter_name:
add_variable( add_variable(
parameter_node, parameter_node,
@@ -391,7 +416,12 @@ def build_file_symbol_index(
is_definition=True, is_definition=True,
) )
flow_facts.append([])
walk_script(body, proc_scope) walk_script(body, proc_scope)
facts = flow_facts.pop()
flows = proc_def_flows(facts, parameter_names, proc_namespace) if facts else ()
if flows:
def_flows.append((proc_identity.name, flows))
def walk_namespace(command: Command, scope: _Scope) -> bool: def walk_namespace(command: Command, scope: _Scope) -> bool:
if len(command.args) < 3 or _static_contents(command.args[0]) != "eval": if len(command.args) < 3 or _static_contents(command.args[0]) != "eval":
@@ -429,6 +459,37 @@ def build_file_symbol_index(
if routine: if routine:
add_proc(command.routine, routine, scope, is_definition=False) add_proc(command.routine, routine, scope, is_definition=False)
for name, line, column in stored_command_names(command):
start = column - 1 + (name.rfind("::") + 2 if "::" in name else 0)
add_proc(
command.args[2],
name,
scope,
is_definition=False,
name_range=lsp.Range(
start=lsp.Position(line=line - 1, character=start),
end=lsp.Position(line=line - 1, character=column - 1 + len(name)),
),
)
if flow_facts:
flow_facts[-1].extend(command_flow_facts(command))
for node, kind in def_argument_kinds(command):
name = _def_name(node)
if name is None:
continue
line, column = node.contents_pos
occurrences.append(
SymbolOccurrence(
identity=shared(SymbolIdentity(kind=kind, name=name)),
range=lsp.Range(
start=lsp.Position(line=line - 1, character=column - 1),
end=lsp.Position(line=line - 1, character=column - 1 + len(name)),
),
placeholder=name,
symbol_kind=lsp.SymbolKind.Struct if kind == DEF_BLOCK_TEMPLATE else lsp.SymbolKind.Field,
)
)
declaration_nodes = _variable_declaration_nodes(command) declaration_nodes = _variable_declaration_nodes(command)
declaration_ids = {id(node) for node in declaration_nodes} declaration_ids = {id(node) for node in declaration_nodes}
@@ -484,6 +545,7 @@ def build_file_symbol_index(
uri=uri, uri=uri,
occurrences=tuple(occurrences), occurrences=tuple(occurrences),
document_range=_node_range(tree), document_range=_node_range(tree),
def_flows=tuple(def_flows),
) )
@@ -506,6 +568,15 @@ def resolve_identity(
return occurrence.identity return occurrence.identity
def _may_resolve_to(occurrence: SymbolOccurrence, identity: SymbolIdentity) -> bool:
"""Cheap name pre-filter; resolve_identity only returns one of these two."""
name = identity.name
fallback = occurrence.fallback_identity
return occurrence.identity.name == name or (
fallback is not None and fallback.name == name
)
def symbol_at_position( def symbol_at_position(
index: FileSymbolIndex, index: FileSymbolIndex,
position: lsp.Position, position: lsp.Position,
@@ -530,6 +601,8 @@ def matching_occurrences(
matches = [] matches = []
for index in indexes.values(): for index in indexes.values():
for occurrence in index.occurrences: for occurrence in index.occurrences:
if not _may_resolve_to(occurrence, identity):
continue
if resolve_identity(occurrence, definitions) == identity: if resolve_identity(occurrence, definitions) == identity:
matches.append((index, occurrence)) matches.append((index, occurrence))
return matches return matches
@@ -543,6 +616,8 @@ def document_highlights(
"""Return all occurrences of one symbol in the active document.""" """Return all occurrences of one symbol in the active document."""
highlights = [] highlights = []
for occurrence in index.occurrences: for occurrence in index.occurrences:
if not _may_resolve_to(occurrence, identity):
continue
if resolve_identity(occurrence, definitions) != identity: if resolve_identity(occurrence, definitions) != identity:
continue continue
+25
View File
@@ -1,7 +1,9 @@
import io import io
import re
from typing import Optional, Tuple from typing import Optional, Tuple
from tclint.parser import Parser from tclint.parser import Parser
from tclint.commands import CommandArgError from tclint.commands import CommandArgError
from tclint.commands.checks import eval as eval_script_args
from tclint.syntax_tree import ( from tclint.syntax_tree import (
BracedWord, BracedWord,
BareWord, BareWord,
@@ -13,9 +15,32 @@ from tclint.syntax_tree import (
from tclint.lexer import TclSyntaxError, Lexer, TOK_EOF from tclint.lexer import TclSyntaxError, Lexer, TOK_EOF
_UPLEVEL_LEVEL_RE = re.compile(r"^#?\d+$")
def _uplevel(args, parser):
"""uplevel ?level? arg ?arg ...?"""
# ref: https://www.tcl.tk/man/tcl/TclCmd/uplevel.html
if len(args) == 0:
raise CommandArgError("not enough args to 'uplevel': got 0, expected at least 1")
# The level can only be omitted when the first arg doesn't look like one.
# A non-literal first arg (e.g. $level) is treated as a level as well.
level = []
if len(args) > 1:
first = args[0].contents
if first is None or _UPLEVEL_LEVEL_RE.match(first):
level = args[0:1]
return level + eval_script_args(args[len(level) :], parser, "uplevel")
class CustomParser(Parser): class CustomParser(Parser):
def __init__(self, debug=False, command_plugins=None): def __init__(self, debug=False, command_plugins=None):
super().__init__(debug, command_plugins) super().__init__(debug, command_plugins)
# tclint only checks the arg count of uplevel; parse its body as a script
# so it gets formatted and linted like eval/namespace eval bodies.
self._commands = {**self._commands, "uplevel": _uplevel}
# Used to normalize newlines consistently with open()'s universal newlines mode. # Used to normalize newlines consistently with open()'s universal newlines mode.
self._decoder = io.IncrementalNewlineDecoder(None, True) self._decoder = io.IncrementalNewlineDecoder(None, True)
+62 -8
View File
@@ -4,8 +4,11 @@ from typing import List
import attrs import attrs
from common.load_data import standard_items from common.load_data import standard_items
from tclint.commands.plugins import PluginManager from tclint.commands.plugins import PluginManager
from tclint.syntax_tree import BareWord, Command, QuotedWord, Visitor from tclint.syntax_tree import BareWord, BracedWord, Command, QuotedWord, Visitor
from tools.stored_procs import stored_command_names
from tools.variable_names import variable_name from tools.variable_names import variable_name
from tools.tcloo_symbols import class_symbols
from tools.tcloo_completion import _analyze
# Constructing a PluginManager scans entry points, and get_commands() rebuilds # Constructing a PluginManager scans entry points, and get_commands() rebuilds
# the builtin command set on every call. Semantic tokens are requested often, so # the builtin command set on every call. Semantic tokens are requested often, so
@@ -70,12 +73,10 @@ class _Highlighter(Visitor):
def __init__(self, plugins, custom_functions): def __init__(self, plugins, custom_functions):
self._commands = _load_commands(plugins) self._commands = _load_commands(plugins)
self._tokens = [] self._tokens = []
self._class_tokens = {}
self._method_tokens = {}
if isinstance(custom_functions, dict): if isinstance(custom_functions, dict):
self._custom_function_names = frozenset( self._custom_function_names = frozenset(item.label for items in custom_functions.values() for item in items)
item.label
for items in custom_functions.values()
for item in items
)
else: else:
self._custom_function_names = frozenset(custom_functions) self._custom_function_names = frozenset(custom_functions)
@@ -84,6 +85,20 @@ class _Highlighter(Visitor):
return return
self._tokens.append((position, length, tok_type, modifiers or [])) self._tokens.append((position, length, tok_type, modifiers or []))
def highlight_classes(self, tree, external_classes=None):
declarations, references = class_symbols(tree, external_classes)
for node, modifiers in [
*((node, [TokenModifier.declaration]) for node in declarations.values()),
*((node, []) for node in references),
]:
line, col = node.contents_pos
self._class_tokens[(line - 1, col - 1)] = (
(line - 1, col - 1),
len(node.contents),
"class",
modifiers,
)
def _get_token_info(self, node): def _get_token_info(self, node):
"""Hilfsmethode um Token-Informationen aus verschiedenen Node-Typen zu extrahieren.""" """Hilfsmethode um Token-Informationen aus verschiedenen Node-Typen zu extrahieren."""
if not hasattr(node, "pos"): if not hasattr(node, "pos"):
@@ -111,6 +126,28 @@ class _Highlighter(Visitor):
return None return None
def highlight_methods(self, tree, source, uri, external_classes=None):
"""Use the same function token as procs for resolved TclOO methods."""
classes, _, calls = _analyze(tree, external_classes=external_classes, uri=uri, source=source)
lines = source.splitlines()
for info in classes.values():
for location in info.method_definitions.values():
if location.uri != uri:
continue
start, end = location.range.start, location.range.end
encoded = lines[start.line].encode("utf-16-le")
column = len(encoded[:start.character * 2].decode("utf-16-le"))
length = len(encoded[start.character * 2:end.character * 2].decode("utf-16-le"))
position = (start.line, column)
self._method_tokens[position] = (position, length, "function", [TokenModifier.declaration])
for call in calls:
node = call.command.args[0]
if node.contents is None or node.contents_pos is None:
continue
line, column = node.contents_pos
position = (line - 1, column - 1)
self._method_tokens[position] = (position, len(node.contents), "function", [])
def visit_quoted_word(self, word: QuotedWord): def visit_quoted_word(self, word: QuotedWord):
if not word.contents: if not word.contents:
return return
@@ -137,6 +174,20 @@ class _Highlighter(Visitor):
def visit_command(self, command: Command): def visit_command(self, command: Command):
routine = command.routine routine = command.routine
# Stored procedure names in braced lappend arguments use the same
# highlighting as calls, without treating the literal as executable Tcl.
if routine.contents in {"lappend", "::lappend"}:
for argument in command.args:
if (isinstance(argument, BracedWord)
and argument.contents in self._custom_function_names | _STANDARD_PROC_NAMES):
line, col = argument.contents_pos
self._append_token((line - 1, col - 1), len(argument.contents), "function", [])
# Procedures stored in COMMANDBLOCK properties (CONF_x set prop {proc}).
for stored_name, line, col in stored_command_names(command):
if stored_name in self._custom_function_names or stored_name in _STANDARD_PROC_NAMES:
self._append_token((line - 1, col - 1), len(stored_name), "function", [])
# Highlight functions (custom or standard) when used as the routine # Highlight functions (custom or standard) when used as the routine
name = getattr(routine, "contents", None) name = getattr(routine, "contents", None)
if name: if name:
@@ -157,7 +208,7 @@ class _Highlighter(Visitor):
if routine.contents == "puts": if routine.contents == "puts":
line, col = routine.contents_pos line, col = routine.contents_pos
self._append_token((line - 1, col - 1), len(routine.contents), "function", [TokenModifier.builtin]) self._append_token((line - 1, col - 1), len(routine.contents), "function", [TokenModifier.builtin])
if routine.contents == "set" and command.args: if routine.contents in ["set", "append", "lappend"] 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 first_arg.contents is None: if first_arg.contents is None:
@@ -204,7 +255,10 @@ class _Highlighter(Visitor):
tokens = [] tokens = []
last_line = 0 last_line = 0
last_col = 0 last_col = 0
for (line, col), length, tok_type, tok_modifier in sorted(self._tokens, key=lambda x: x[0]): overrides = {**self._method_tokens, **self._class_tokens}
raw_tokens = [token for token in self._tokens if token[0] not in overrides]
raw_tokens.extend(overrides.values())
for (line, col), length, tok_type, tok_modifier in sorted(raw_tokens, key=lambda x: x[0]):
line_delta = line - last_line line_delta = line - last_line
col_delta = col col_delta = col
if line == last_line: if line == last_line:
+80
View File
@@ -0,0 +1,80 @@
"""Procedure names stored as data and called later.
PostConfigurator COMMANDBLOCK properties hold a Tcl list whose elements are
executed as commands (see LIB_CONF_do_prop_custom_proc), e.g.
``CONF_CTRL_tool set auto_preselect_last_template {custom_header}`` or
``CONF_CTRL_moves set return_safety_pos {{OEM_output arg}}``. The first word of
each element is a command name; only braced values are considered.
"""
from __future__ import annotations
import re
from tclint.syntax_tree import BracedWord, Command, Node
_CONF_OBJECT_RE = re.compile(r"^(::)?CONF_\w+$")
_COMMAND_NAME_RE = re.compile(r"[A-Za-z_:][\w:]*")
def _static(node: Node | None) -> str | None:
value = getattr(node, "contents", None)
return value if isinstance(value, str) else None
def _list_element_spans(text: str) -> list[tuple[int, int]]:
"""(start, end) offsets of the top-level elements of a Tcl list, braces stripped."""
spans = []
index = 0
while index < len(text):
if text[index].isspace():
index += 1
continue
if text[index] == "{":
depth, start = 0, index + 1
while index < len(text):
if text[index] == "\\":
index += 2
continue
depth += {"{": 1, "}": -1}.get(text[index], 0)
index += 1
if depth == 0:
break
spans.append((start, index - 1))
elif text[index] == '"':
start = index + 1
index = text.find('"', start)
index = len(text) if index < 0 else index
spans.append((start, index))
index += 1
else:
start = index
while index < len(text) and not text[index].isspace():
index += 1
spans.append((start, index))
return spans
def stored_command_names(command: Command) -> list[tuple[str, int, int]]:
"""Command names stored in ``command`` as (name, line, column), 1-based."""
routine = _static(command.routine)
args = command.args
if not (routine and _CONF_OBJECT_RE.match(routine) and len(args) >= 3 and _static(args[0]) == "set"):
return []
value = args[2]
text = _static(value)
if not isinstance(value, BracedWord) or not text or value.contents_pos is None:
return []
line, column = value.contents_pos
names = []
for start, end in _list_element_spans(text):
element = text[start:end]
match = _COMMAND_NAME_RE.match(element, len(element) - len(element.lstrip()))
if match is None or (match.end() < len(element) and not element[match.end()].isspace()):
continue
offset = start + match.start()
before = text[:offset]
name_line = line + before.count("\n")
name_column = offset - before.rfind("\n") if "\n" in before else column + offset
names.append((match.group(), name_line, name_column))
return names
+64 -139
View File
@@ -24,6 +24,10 @@ class DynamicCompletionKind(Enum):
PROCEDURE = "procedure" PROCEDURE = "procedure"
NAMESPACE = "namespace" NAMESPACE = "namespace"
PATH = "path" PATH = "path"
BLOCK_TEMPLATE = "block_template"
ADDRESS = "address"
# A variable substituted as a value, inserted with a leading "$".
VALUE = "value"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -284,6 +288,7 @@ def _options(*labels: str) -> tuple[OptionSpec, ...]:
OPTIONS_BY_PATH: dict[tuple[str, ...], tuple[OptionSpec, ...]] = { OPTIONS_BY_PATH: dict[tuple[str, ...], tuple[OptionSpec, ...]] = {
("unset",): _options("nocomplain"),
("binary", "decode", "base64"): (OptionSpec("-strict"),), ("binary", "decode", "base64"): (OptionSpec("-strict"),),
("binary", "encode", "base64"): ( ("binary", "encode", "base64"): (
OptionSpec("-maxlen", takes_value=True), OptionSpec("-maxlen", takes_value=True),
@@ -423,6 +428,9 @@ for _string_class in STRING_CLASSES:
VALUES_BY_POSITION: dict[tuple[tuple[str, ...], int], tuple[str, ...]] = { VALUES_BY_POSITION: dict[tuple[tuple[str, ...], int], tuple[str, ...]] = {
(("array", "names"), 3): ("-exact", "-glob", "-regexp"), (("array", "names"), 3): ("-exact", "-glob", "-regexp"),
(("MOM_force",), 1): ("Always", "Once", "Off"),
(("MOM_suppress",), 1): ("Always", "Once", "Off"),
(("MOM_do_template",), 2): ("CREATE", "BUFFER"),
(("close",), 2): ("read", "write"), (("close",), 2): ("read", "write"),
(("open",), 2): ("r", "r+", "w", "w+", "a", "a+"), (("open",), 2): ("r", "r+", "w", "w+", "a", "a+"),
(("package", "prefer"), 2): ("latest", "stable"), (("package", "prefer"), 2): ("latest", "stable"),
@@ -437,109 +445,60 @@ _REPEATED_SUBCOMMAND_ARGUMENTS = frozenset(range(2, 33))
DYNAMIC_COMPLETION_RULES = ( DYNAMIC_COMPLETION_RULES = (
# Variable-taking commands. # Variable-taking commands.
DynamicCompletionRule(("append",), frozenset({1}), DynamicCompletionKind.VARIABLE), DynamicCompletionRule(("append",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule( DynamicCompletionRule(("array", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE),
("array", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE DynamicCompletionRule(("array", "get"), frozenset({2}), DynamicCompletionKind.VARIABLE),
), DynamicCompletionRule(("array", "names"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule( DynamicCompletionRule(("array", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE),
("array", "get"), frozenset({2}), DynamicCompletionKind.VARIABLE DynamicCompletionRule(("array", "size"), frozenset({2}), DynamicCompletionKind.VARIABLE),
), DynamicCompletionRule(("array", "statistics"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule( DynamicCompletionRule(("array", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE),
("array", "names"), frozenset({2}), DynamicCompletionKind.VARIABLE DynamicCompletionRule(("catch",), frozenset({2, 3}), DynamicCompletionKind.VARIABLE),
), DynamicCompletionRule(("dict", "append"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule( DynamicCompletionRule(("dict", "incr"), frozenset({2}), DynamicCompletionKind.VARIABLE),
("array", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE DynamicCompletionRule(("dict", "lappend"), frozenset({2}), DynamicCompletionKind.VARIABLE),
), DynamicCompletionRule(("dict", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule( DynamicCompletionRule(("dict", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE),
("array", "size"), frozenset({2}), DynamicCompletionKind.VARIABLE DynamicCompletionRule(("dict", "update"), frozenset({2}), DynamicCompletionKind.VARIABLE),
), DynamicCompletionRule(("dict", "with"), frozenset({2}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule( DynamicCompletionRule(("global",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE),
("array", "statistics"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("array", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("catch",), frozenset({2, 3}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "append"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "incr"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "lappend"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "set"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "unset"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "update"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("dict", "with"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(
("global",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(("incr",), frozenset({1}), DynamicCompletionKind.VARIABLE), DynamicCompletionRule(("incr",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule( DynamicCompletionRule(("info", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE),
("info", "exists"), frozenset({2}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(("lappend",), frozenset({1}), DynamicCompletionKind.VARIABLE), DynamicCompletionRule(("lappend",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule(("set",), frozenset({1}), DynamicCompletionKind.VARIABLE), DynamicCompletionRule(("set",), frozenset({1}), DynamicCompletionKind.VARIABLE),
DynamicCompletionRule( DynamicCompletionRule(("unset",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE),
("unset",), _REPEATED_ARGUMENTS, DynamicCompletionKind.VARIABLE DynamicCompletionRule(("variable",), frozenset({1}), DynamicCompletionKind.VARIABLE),
),
DynamicCompletionRule(
("variable",), frozenset({1}), DynamicCompletionKind.VARIABLE
),
DynamicCompletionRule(("vwait",), frozenset({1}), DynamicCompletionKind.VARIABLE), DynamicCompletionRule(("vwait",), frozenset({1}), DynamicCompletionKind.VARIABLE),
# Procedure-taking commands. # Procedure-taking commands.
DynamicCompletionRule( DynamicCompletionRule(("info", "args"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
("info", "args"), frozenset({2}), DynamicCompletionKind.PROCEDURE DynamicCompletionRule(("info", "body"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
), DynamicCompletionRule(("info", "default"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
DynamicCompletionRule( DynamicCompletionRule(("namespace", "origin"), frozenset({2}), DynamicCompletionKind.PROCEDURE),
("info", "body"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(
("info", "default"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(
("namespace", "origin"), frozenset({2}), DynamicCompletionKind.PROCEDURE
),
DynamicCompletionRule(("rename",), frozenset({1}), DynamicCompletionKind.PROCEDURE), DynamicCompletionRule(("rename",), frozenset({1}), DynamicCompletionKind.PROCEDURE),
# Namespace-taking commands. # Namespace-taking commands.
DynamicCompletionRule( DynamicCompletionRule(("MOM_do_template",), frozenset({1}), DynamicCompletionKind.BLOCK_TEMPLATE),
("namespace", "children"), frozenset({2}), DynamicCompletionKind.NAMESPACE DynamicCompletionRule(("MOM_do_template",), frozenset({2}), DynamicCompletionKind.VALUE),
), DynamicCompletionRule(("MOM_ask_address_value",), frozenset({1}), DynamicCompletionKind.ADDRESS),
DynamicCompletionRule(("MOM_force",), frozenset({1}), DynamicCompletionKind.VALUE),
DynamicCompletionRule(("MOM_force",), _REPEATED_SUBCOMMAND_ARGUMENTS, DynamicCompletionKind.ADDRESS),
DynamicCompletionRule(("MOM_suppress",), frozenset({1}), DynamicCompletionKind.VALUE),
DynamicCompletionRule(("MOM_suppress",), _REPEATED_SUBCOMMAND_ARGUMENTS, DynamicCompletionKind.ADDRESS),
DynamicCompletionRule(("MOM_disable_address",), frozenset(range(1, 33)), DynamicCompletionKind.ADDRESS),
DynamicCompletionRule(("MOM_enable_address",), _REPEATED_SUBCOMMAND_ARGUMENTS, DynamicCompletionKind.ADDRESS),
DynamicCompletionRule(("namespace", "children"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
DynamicCompletionRule( DynamicCompletionRule(
("namespace", "delete"), ("namespace", "delete"),
_REPEATED_SUBCOMMAND_ARGUMENTS, _REPEATED_SUBCOMMAND_ARGUMENTS,
DynamicCompletionKind.NAMESPACE, DynamicCompletionKind.NAMESPACE,
), ),
DynamicCompletionRule( DynamicCompletionRule(("namespace", "eval"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
("namespace", "eval"), frozenset({2}), DynamicCompletionKind.NAMESPACE DynamicCompletionRule(("namespace", "exists"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
), DynamicCompletionRule(("namespace", "parent"), frozenset({2}), DynamicCompletionKind.NAMESPACE),
DynamicCompletionRule(
("namespace", "exists"), frozenset({2}), DynamicCompletionKind.NAMESPACE
),
DynamicCompletionRule(
("namespace", "parent"), frozenset({2}), DynamicCompletionKind.NAMESPACE
),
# Path-taking commands. Source files are narrowed to Tcl while directories # Path-taking commands. Source files are narrowed to Tcl while directories
# remain visible so users can continue navigating. # remain visible so users can continue navigating.
DynamicCompletionRule(("cd",), frozenset({1}), DynamicCompletionKind.PATH), DynamicCompletionRule(("cd",), frozenset({1}), DynamicCompletionKind.PATH),
DynamicCompletionRule( DynamicCompletionRule(("load",), frozenset({1}), DynamicCompletionKind.PATH, (".dll", ".so", ".dylib")),
("load",), frozenset({1}), DynamicCompletionKind.PATH, (".dll", ".so", ".dylib")
),
DynamicCompletionRule(("open",), frozenset({1}), DynamicCompletionKind.PATH), DynamicCompletionRule(("open",), frozenset({1}), DynamicCompletionKind.PATH),
DynamicCompletionRule( DynamicCompletionRule(("source",), frozenset({1, 3}), DynamicCompletionKind.PATH, (".tcl",)),
("source",), frozenset({1, 3}), DynamicCompletionKind.PATH, (".tcl",)
),
*( *(
DynamicCompletionRule( DynamicCompletionRule(
("file", subcommand), ("file", subcommand),
@@ -666,12 +625,7 @@ SUBCOMMAND_SNIPPET_ITEMS = {
TCL_COMMAND_NAMES = tuple( TCL_COMMAND_NAMES = tuple(
sorted( sorted({path[0] for path in SUBCOMMANDS_BY_PATH} | {path[0] for path in OPTIONS_BY_PATH} | set(TCL_COMMAND_SNIPPET_ITEMS) | {rule.path[0] for rule in DYNAMIC_COMPLETION_RULES})
{path[0] for path in SUBCOMMANDS_BY_PATH}
| {path[0] for path in OPTIONS_BY_PATH}
| set(TCL_COMMAND_SNIPPET_ITEMS)
| {rule.path[0] for rule in DYNAMIC_COMPLETION_RULES}
)
) )
TCL_COMMAND_ITEMS = tuple( TCL_COMMAND_ITEMS = tuple(
@@ -688,9 +642,7 @@ TCL_COMMAND_ITEMS = tuple(
) )
def line_prefix_at_position( def line_prefix_at_position(source_lines: Sequence[str], position: Position) -> str | None:
source_lines: Sequence[str], position: Position
) -> str | None:
"""Return the current line before an LSP UTF-16 position.""" """Return the current line before an LSP UTF-16 position."""
if position.line < 0 or position.line >= len(source_lines): if position.line < 0 or position.line >= len(source_lines):
@@ -703,9 +655,7 @@ def line_prefix_at_position(
return line[:codepoint_offset] return line[:codepoint_offset]
def tcl_argument_completion( def tcl_argument_completion(source_lines: Sequence[str], position: Position) -> TclArgumentCompletion | None:
source_lines: Sequence[str], position: Position
) -> TclArgumentCompletion | None:
"""Describe static and dynamic argument completion at ``position``. """Describe static and dynamic argument completion at ``position``.
``None`` means that the cursor is not at a command-specific completion ``None`` means that the cursor is not at a command-specific completion
@@ -751,13 +701,14 @@ def tcl_argument_completion(
for (path, argument_index), values in VALUES_BY_POSITION.items(): for (path, argument_index), values in VALUES_BY_POSITION.items():
if active_index == argument_index and tuple(words[: len(path)]) == path: if active_index == argument_index and tuple(words[: len(path)]) == path:
return TclArgumentCompletion( return _merge_dynamic_completion(
items=_completion_items( _completion_items(
values, values,
CompletionItemKind.Value, CompletionItemKind.Value,
f"{' '.join(path)} value", f"{' '.join(path)} value",
), ),
active_prefix=active_prefix, dynamic_completion,
active_prefix,
) )
for path in sorted(OPTIONS_BY_PATH, key=len, reverse=True): for path in sorted(OPTIONS_BY_PATH, key=len, reverse=True):
@@ -771,7 +722,7 @@ def tcl_argument_completion(
active_prefix, active_prefix,
) )
if option_completion is not None: if option_completion is not None:
if active_prefix.startswith("-"): if active_prefix.startswith("-") or (path == ("unset",) and active_index == 1 and not active_prefix):
return option_completion return option_completion
return _merge_dynamic_completion( return _merge_dynamic_completion(
(*argument_items, *option_completion.items), (*argument_items, *option_completion.items),
@@ -780,9 +731,7 @@ def tcl_argument_completion(
) )
if argument_items: if argument_items:
return _merge_dynamic_completion( return _merge_dynamic_completion(argument_items, dynamic_completion, active_prefix)
argument_items, dynamic_completion, active_prefix
)
return dynamic_completion return dynamic_completion
@@ -827,9 +776,7 @@ def _option_completion(
if active_prefix and not active_prefix.startswith("-"): if active_prefix and not active_prefix.startswith("-"):
return None return None
remaining_options = tuple( remaining_options = tuple(option.label for option in options if option.label not in used_options)
option.label for option in options if option.label not in used_options
)
return TclArgumentCompletion( return TclArgumentCompletion(
items=_completion_items( items=_completion_items(
remaining_options, remaining_options,
@@ -840,16 +787,9 @@ def _option_completion(
) )
def _dynamic_completion( def _dynamic_completion(words: Sequence[str], active_index: int, active_prefix: str) -> TclArgumentCompletion | None:
words: Sequence[str], active_index: int, active_prefix: str for rule in sorted(DYNAMIC_COMPLETION_RULES, key=lambda item: len(item.path), reverse=True):
) -> TclArgumentCompletion | None: if active_index in rule.argument_indices and tuple(words[: len(rule.path)]) == rule.path:
for rule in sorted(
DYNAMIC_COMPLETION_RULES, key=lambda item: len(item.path), reverse=True
):
if (
active_index in rule.argument_indices
and tuple(words[: len(rule.path)]) == rule.path
):
return TclArgumentCompletion( return TclArgumentCompletion(
dynamic_kind=rule.kind, dynamic_kind=rule.kind,
active_prefix=active_prefix, active_prefix=active_prefix,
@@ -873,9 +813,7 @@ def _merge_dynamic_completion(
) )
def _completion_items( def _completion_items(labels: Sequence[str], kind: CompletionItemKind, detail: str) -> tuple[CompletionItem, ...]:
labels: Sequence[str], kind: CompletionItemKind, detail: str
) -> tuple[CompletionItem, ...]:
return tuple( return tuple(
CompletionItem( CompletionItem(
label=label, label=label,
@@ -913,9 +851,7 @@ def path_completion_items(
except (OSError, ValueError): except (OSError, ValueError):
return () return ()
allowed_extensions = { allowed_extensions = {extension.casefold() for extension in completion.path_extensions}
extension.casefold() for extension in completion.path_extensions
}
replace_start = max( replace_start = max(
0, 0,
position.character - len(raw_prefix.encode("utf-16-le")) // 2, position.character - len(raw_prefix.encode("utf-16-le")) // 2,
@@ -933,28 +869,17 @@ def path_completion_items(
is_directory = entry.is_dir() is_directory = entry.is_dir()
except OSError: except OSError:
continue continue
if ( if not is_directory and allowed_extensions and entry.suffix.casefold() not in allowed_extensions:
not is_directory
and allowed_extensions
and entry.suffix.casefold() not in allowed_extensions
):
continue continue
escaped_name = "".join( escaped_name = "".join(f"\\{character}" if character.isspace() else character for character in entry.name)
f"\\{character}" if character.isspace() else character
for character in entry.name
)
new_text = f"{normalized_directory}{escaped_name}" new_text = f"{normalized_directory}{escaped_name}"
if is_directory: if is_directory:
new_text += "/" new_text += "/"
items.append( items.append(
CompletionItem( CompletionItem(
label=new_text, label=new_text,
kind=( kind=(CompletionItemKind.Folder if is_directory else CompletionItemKind.File),
CompletionItemKind.Folder
if is_directory
else CompletionItemKind.File
),
detail="Directory" if is_directory else "File", detail="Directory" if is_directory else "File",
text_edit=TextEdit(range=replace_range, new_text=new_text), text_edit=TextEdit(range=replace_range, new_text=new_text),
) )
+79
View File
@@ -0,0 +1,79 @@
"""Parameter presentation for statically resolved TclOO calls."""
from dataclasses import dataclass
import lsprotocol.types as lsp
from tclint.lexer import TclSyntaxError
from tclint.syntax_tree import BracedWord, Command
from tools.parser import CustomParser
from tools.signature_help import _active_argument, _contains_cursor
from tools.tcloo_completion import resolved_method_calls
@dataclass(frozen=True)
class MethodParameter:
name: str
label: str
variadic: bool = False
def method_parameters(parameters: str) -> list[MethodParameter]:
parser = CustomParser()
try:
words = parser.parse_list(BracedWord(parameters, pos=(1, 1))).children
result = []
for index, word in enumerate(words):
parts = parser.parse_list(word).children
if not parts or len(parts) > 2:
return []
name = parts[0].contents
if name is None:
return []
variadic = name == "args" and len(parts) == 1 and index == len(words) - 1
label = "{" + word.contents + "}" if len(parts) == 2 else name
result.append(MethodParameter(name, label, variadic))
return result
except TclSyntaxError:
return []
def method_signature_help(source: str, position: lsp.Position, external_classes=None, tree=None) -> lsp.SignatureHelp | None:
lines = source.split("\n")
if position.line >= len(lines):
return None
# AST columns are codepoints; LSP columns are UTF-16 code units.
prefix = lines[position.line].encode("utf-16-le")[:position.character * 2].decode("utf-16-le", errors="ignore")
cursor = (position.line, len(prefix))
candidates = [call for call in resolved_method_calls(source, external_classes, tree)
if _contains_cursor(call.command, lines, cursor)]
if not candidates:
return None
call = max(candidates, key=lambda candidate: candidate.command.pos)
def nested_active(node):
return any(
isinstance(child, Command) and _contains_cursor(child, lines, cursor)
or nested_active(child)
for child in node.children
)
# Let the inner command's own signature provider handle its arguments.
if nested_active(call.command):
return None
argument = _active_argument(call.command, cursor) - call.argument_offset
if argument < 0:
return None
parameters = method_parameters(call.parameters)
label = call.label
infos = []
for parameter in parameters:
label += " "
start = len(label.encode("utf-16-le")) // 2
label += parameter.label
infos.append(lsp.ParameterInformation(label=(start, len(label.encode("utf-16-le")) // 2)))
active = min(argument, len(parameters) - 1) if parameters else None
return lsp.SignatureHelp(
signatures=[lsp.SignatureInformation(label=label, parameters=infos, active_parameter=active)],
active_signature=0, active_parameter=active,
)
+333
View File
@@ -0,0 +1,333 @@
"""Static TclOO inference using local and indexed classes, without executing Tcl."""
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
import re
import lsprotocol.types as lsp
from tclint.lexer import TclSyntaxError
from tclint.syntax_tree import BracedWord, Command, CommandSub, Script, VarSub
from tools.parser import CustomParser
from tools.signature_help import _active_argument, _contains_cursor
from tools.tcl_command_completion import line_prefix_at_position
_LEADING_RECEIVER = re.compile(r"\s*([A-Za-z_]\w*)\s+[\w:]*$")
def may_contain_classes(source, external_classes=None) -> bool:
"""Cheap pre-check: without any class, TclOO analysis yields nothing."""
return bool(external_classes) or "oo::class" in source
def _may_be_receiver(name, source_lines, external_classes) -> bool:
"""Whether a bare command word can name a class, an object or `my`.
Objects and local classes only come from `... create <name>`, so a word
never created anywhere cannot resolve and needs no full-document parse.
"""
if name in {"my", "self", "next"}:
return True
if any(key.rsplit("::", 1)[-1] == name for key in external_classes or ()):
return True
created = re.compile(rf"\bcreate\s+[{{\"]?(?:[\w:]*::)?{re.escape(name)}\b")
return any("create" in line and created.search(line) for line in source_lines)
@dataclass
class ClassInfo:
methods: dict[str, tuple[str, Script | None]] = field(default_factory=dict)
namespace: str = ""
constructor: str = ""
definition: lsp.Location | None = None
method_definitions: dict[str, lsp.Location] = field(default_factory=dict)
constructor_definition: lsp.Location | None = None
@dataclass
class MethodCall:
command: Command
label: str
parameters: str
argument_offset: int = 1
definition: lsp.Location | None = None
def name_location(node, uri, source):
"""Locate the literal name, excluding braces/quotes, using UTF-16 columns."""
if not uri or node.contents is None or node.contents_pos is None:
return None
line, column = node.contents_pos
lines = source.splitlines()
prefix = lines[line - 1][:column - 1] if line <= len(lines) else ""
start = len(prefix.encode("utf-16-le")) // 2
end = start + len(node.contents.encode("utf-16-le")) // 2
return lsp.Location(uri=uri, range=lsp.Range(
start=lsp.Position(line=line - 1, character=start),
end=lsp.Position(line=line - 1, character=end)))
def parse_completion_source(source, pos=None):
# Complete open delimiters while editing; never evaluate the user's code.
for _ in range(16):
try:
return CustomParser().parse(source, pos=pos)
except TclSyntaxError as error:
message = str(error)
closing = next((char for text, char in (
("end of command substitution", "]"),
("match for brace", "}"), ("match for quote", '"'),
) if text in message), None)
if closing is None:
return None
source += closing
return None
def _body(node):
if isinstance(node, Script):
return node
if isinstance(node, BracedWord):
return parse_completion_source(node.contents, node.contents_pos)
return None
def _qualified(name, namespace):
return name if name.startswith("::") else f"{namespace}::{name}"
def _cursor_may_be_method_word(tree, source_lines, position) -> bool:
"""Whether the innermost command at the cursor is at its first argument.
The marker parse below can only succeed there, and the current document's
tree has the same structure apart from the marker.
"""
line = source_lines[position.line]
# AST columns are codepoints; LSP columns are UTF-16 code units.
prefix = line.encode("utf-16-le")[:position.character * 2].decode("utf-16-le", errors="ignore")
cursor = (position.line, len(prefix))
lines = list(source_lines)
innermost = None
def walk(node):
nonlocal innermost
start, end = getattr(node, "pos", None), getattr(node, "end_pos", None)
if start is not None and end is not None and not start[0] - 1 <= cursor[0] <= end[0] - 1:
return
if isinstance(node, Command) and _contains_cursor(node, lines, cursor):
innermost = node
for child in node.children:
walk(child)
walk(tree)
return innermost is None or _active_argument(innermost, cursor) == 0
def tcloo_completions(
source_lines: Sequence[str], position: lsp.Position, external_classes=None,
current_tree: Callable[[], Script | None] | None = None,
) -> list[lsp.CompletionItem] | None:
"""Return receiver-specific methods, or None outside a known OO context.
`current_tree` lazily returns the parsed, unmodified document (or None) so
cursors that cannot hold a method name skip the full marker reparse.
"""
prefix = line_prefix_at_position(source_lines, position)
if prefix is None:
return None
match = re.search(r"[\w:]*$", prefix)
typed = match.group()
# Only a method word, never a variable substitution or method argument.
word_start = len(prefix) - len(typed)
if word_start == 0 or prefix[word_start - 1] not in " \t":
return None
if not external_classes and not any("oo::class" in line for line in source_lines):
return None
continued = position.line > 0 and source_lines[position.line - 1].endswith("\\")
if not continued:
# The first word is the command itself, never a method name.
if not prefix[:word_start].strip():
return None
receiver = _LEADING_RECEIVER.match(prefix)
if receiver is not None and not _may_be_receiver(receiver.group(1), source_lines, external_classes):
return None
tree = current_tree() if current_tree is not None else None
if tree is not None and not _cursor_may_be_method_word(tree, source_lines, position):
return None
marker = "__nx_tcloo_completion_cursor__"
lines = list(source_lines)
suffix = lines[position.line][len(prefix):]
remaining = re.match(r"[\w:]*", suffix).group()
lines[position.line] = prefix + marker + suffix[len(remaining):]
tree = parse_completion_source("\n".join(lines))
if tree is None:
return None
classes, result, _ = _analyze(tree, typed, marker, external_classes)
if result is None:
return None
cls, internal = result
methods = classes[cls].methods if cls else {"new": ("args", None), "create": ("name args", None)}
methods = dict(methods)
if cls:
methods.setdefault("destroy", ("", None))
suffix = source_lines[position.line][len(prefix):]
remaining = re.match(r"[\w:]*", suffix).group()
start = position.character - len(typed.encode("utf-16-le")) // 2
end = position.character + len(remaining.encode("utf-16-le")) // 2
return [lsp.CompletionItem(
label=name, kind=lsp.CompletionItemKind.Method,
detail=f"{cls or 'class'} {name} {signature}".rstrip(),
text_edit=lsp.TextEdit(range=lsp.Range(
start=lsp.Position(line=position.line, character=start),
end=lsp.Position(line=position.line, character=end)), new_text=name),
) for name, (signature, _) in sorted(methods.items())
if name.startswith(typed) and (internal or not name.startswith("_") and not name[:1].isupper())]
def _collect_classes(tree, classes, uri=None, source=""):
contexts = []
def collect(script, namespace=""):
if script is None:
return
for cmd in script.children:
if not isinstance(cmd, Command):
continue
args = cmd.args
routine = (cmd.routine.contents or "").removeprefix("::")
if routine == "namespace" and len(args) == 3 and args[0].contents == "eval" and args[1].contents:
collect(_body(args[2]), _qualified(args[1].contents, namespace))
elif routine == "oo::class" and len(args) == 3 and args[0].contents == "create" and args[1].contents:
name = _qualified(args[1].contents, namespace)
info = ClassInfo(namespace=namespace)
info.definition = name_location(args[1], uri, source)
classes[name] = info
body = _body(args[2])
if body is None:
continue
for method in body.children:
if not isinstance(method, Command):
continue
ma = method.args
if method.routine.contents == "method" and len(ma) == 3 and ma[0].contents:
method_body = _body(ma[2])
info.methods[ma[0].contents] = (ma[1].contents or "", method_body)
location = name_location(ma[0], uri, source)
if location is not None:
info.method_definitions[ma[0].contents] = location
contexts.append((method_body, namespace, name))
elif method.routine.contents in {"constructor", "destructor"} and ma:
if method.routine.contents == "constructor" and len(ma) == 2:
info.constructor = ma[0].contents or ""
info.constructor_definition = name_location(method.routine, uri, source)
contexts.append((_body(ma[-1]), namespace, name))
collect(tree)
return contexts
def indexed_classes(tree, uri=None, source=""):
classes = {}
_collect_classes(tree, classes, uri, source)
return classes
def _analyze(tree, typed="", marker="", external_classes=None, uri=None, source=""):
classes = dict(external_classes or {})
calls = []
contexts = _collect_classes(tree, classes, uri, source)
result = None
def receiver(node, env, objects, namespace, owner, depth=0):
if depth > 12:
return None
if isinstance(node, VarSub):
return env.get(node.value)
if isinstance(node, CommandSub) and len(node.children) == 1:
return returned(node.children[0], env, objects, namespace, owner, depth + 1)
name = node.contents
return objects.get(_qualified(name, namespace)) if name else None
def returned(cmd, env, objects, namespace, owner, depth=0):
if not isinstance(cmd, Command) or depth > 12:
return None
args = cmd.args
name = cmd.routine.contents
if name == "self" and not args:
return owner
qualified = _qualified(name, namespace) if name else None
if qualified in classes and args and args[0].contents in {"new", "create"}:
return qualified
cls = owner if name == "my" else receiver(cmd.routine, env, objects, namespace, owner, depth + 1)
if cls not in classes or not args:
return None
method = classes[cls].methods.get(args[0].contents)
if method is None or method[1] is None:
return None
# Only infer unconditional final returns; conditional results stay unknown.
commands = [c for c in method[1].children if isinstance(c, Command)]
if commands and commands[-1].routine.contents == "return" and len(commands[-1].args) == 1:
return receiver(commands[-1].args[0], {}, objects, classes[cls].namespace, cls, depth + 1)
return None
def walk(script, env, objects, namespace="", owner=None):
nonlocal result
if script is None:
return
for cmd in script.children:
if not isinstance(cmd, Command):
continue
args = cmd.args
name = cmd.routine.contents
cls = owner if name == "my" else receiver(cmd.routine, env, objects, namespace, owner)
method_name = args[0].contents if args else None
if cls in classes and method_name in classes[cls].methods:
calls.append(MethodCall(cmd, f"{cls} {method_name}", classes[cls].methods[method_name][0],
definition=classes[cls].method_definitions.get(method_name)))
elif name and _qualified(name, namespace) in classes and method_name in {"new", "create"}:
cls = _qualified(name, namespace)
parameters = classes[cls].constructor
if method_name == "create":
parameters = "objectName " + parameters
calls.append(MethodCall(cmd, f"{cls} {method_name}", parameters,
definition=classes[cls].constructor_definition or classes[cls].definition))
if marker and args and args[0].contents == typed + marker:
cls = owner if name == "my" else receiver(cmd.routine, env, objects, namespace, owner)
if cls in classes:
result = (cls, name == "my")
elif name and _qualified(name, namespace) in classes:
result = (None, False)
return
# Command substitutions can contain the completion receiver.
for node in cmd.children:
if isinstance(node, CommandSub):
walk(node, env, objects, namespace, owner)
if name == "set" and len(args) == 2 and args[0].contents:
env[args[0].contents] = receiver(args[1], env, objects, namespace, owner)
elif name == "unset":
for arg in args:
env.pop(arg.contents, None)
elif name == "proc" and len(args) == 3:
walk(_body(args[2]), {}, objects.copy(), namespace)
elif name == "namespace" and len(args) == 3 and args[0].contents == "eval" and args[1].contents:
walk(_body(args[2]), {}, objects, _qualified(args[1].contents, namespace))
elif name and _qualified(name, namespace) in classes and len(args) >= 2 and args[0].contents == "create" and args[1].contents:
objects[_qualified(args[1].contents, namespace)] = _qualified(name, namespace)
else:
for arg in args:
if isinstance(arg, Script):
# Branch-local facts are not propagated beyond the branch.
walk(arg, env.copy(), objects.copy(), namespace, owner)
walk(tree, {}, {})
for body, namespace, owner in contexts:
walk(body, {}, {}, namespace, owner)
return classes, result, calls
def resolved_method_calls(source, external_classes=None, tree=None):
if not may_contain_classes(source, external_classes):
return []
if tree is None:
tree = parse_completion_source(source)
return _analyze(tree, external_classes=external_classes)[2] if tree is not None else []
+38
View File
@@ -0,0 +1,38 @@
"""Definition targets for literal TclOO classes and resolved method calls."""
from tools.tcloo_completion import _analyze, may_contain_classes, name_location, parse_completion_source
from tools.tcloo_symbols import class_symbols
def tcloo_definition(source, uri, position, external_classes=None, tree=None):
if not may_contain_classes(source, external_classes):
return None
if tree is None:
tree = parse_completion_source(source)
if tree is None:
return None
classes, _, calls = _analyze(tree, external_classes=external_classes, uri=uri, source=source)
def contains(location):
if location is None:
return False
start, end = location.range.start, location.range.end
return (start.line, start.character) <= (position.line, position.character) < (end.line, end.character)
targets = {}
declarations, references = class_symbols(tree, classes, targets)
for node in references:
if contains(name_location(node, uri, source)):
return classes[targets[node.pos]].definition
for name, node in declarations.items():
if contains(name_location(node, uri, source)):
return classes[name].definition
for call in calls:
if call.command.args and contains(name_location(call.command.args[0], uri, source)):
return call.definition
# F12 on a declaration itself should stay on that declaration.
for info in classes.values():
for location in [*info.method_definitions.values(), info.constructor_definition]:
if location is not None and location.uri == uri and contains(location):
return location
return None
+57
View File
@@ -0,0 +1,57 @@
"""Class declarations and references shared by completion and highlighting."""
import lsprotocol.types as lsp
from tclint.syntax_tree import Command
from tools.tcloo_completion import _body, _qualified
def class_symbols(tree, external_classes=None, reference_targets=None):
"""Return qualified class declarations and statically resolved name nodes."""
declarations = {}
commands = []
def walk(node, namespace="", in_class=False):
if node is None:
return
if isinstance(node, Command):
args = node.args
name = (node.routine.contents or "").removeprefix("::")
commands.append((node, namespace))
if (name == "namespace" and len(args) == 3
and args[0].contents == "eval" and args[1].contents):
walk(_body(args[2]), _qualified(args[1].contents, namespace), in_class)
return
if (name == "oo::class" and len(args) == 3
and args[0].contents == "create" and args[1].contents):
declarations[_qualified(args[1].contents, namespace)] = args[1]
walk(_body(args[2]), namespace, True)
return
if in_class and name in {"method", "constructor", "destructor"} and args:
walk(_body(args[-1]), namespace, True)
return
for child in node.children:
walk(child, namespace, in_class)
walk(tree)
known_classes = set(external_classes or ()) | declarations.keys()
references = []
for command, namespace in commands:
name = command.routine.contents
target = next((candidate for candidate in (
_qualified(name, namespace), _qualified(name, ""),
) if candidate in known_classes), None) if name else None
if target:
references.append(command.routine)
if reference_targets is not None:
reference_targets[command.routine.pos] = target
return declarations, references
def class_completion_items(tree):
declarations, _ = class_symbols(tree)
return [lsp.CompletionItem(
label=name.removeprefix("::"),
kind=lsp.CompletionItemKind.Class,
detail=f"TclOO class {name}",
) for name in sorted(declarations)]
@@ -47,20 +47,9 @@ def _document(path: Path, source: str) -> TextDocument:
) )
def _completion_server( def _completion_server(tmp_path: Path, monkeypatch) -> tuple[TclLanguageServer, TextDocument, str]:
tmp_path: Path, monkeypatch
) -> tuple[TclLanguageServer, TextDocument, str]:
declared_builtin = standard_items.nx_variables[0].label declared_builtin = standard_items.nx_variables[0].label
current_source = ( current_source = f"set globalValue 1\nproc localProc {{}} {{ return }}\nproc caller {{argument}} {{\n global {declared_builtin}\n set localValue 2\n puts $local\n localP\n}}\n"
"set globalValue 1\n"
"proc localProc {} { return }\n"
"proc caller {argument} {\n"
f" global {declared_builtin}\n"
" set localValue 2\n"
" puts $local\n"
" localP\n"
"}\n"
)
workspace_source = """set ::workspaceValue 1 workspace_source = """set ::workspaceValue 1
proc workspaceProc {} { return } proc workspaceProc {} { return }
""" """
@@ -103,6 +92,22 @@ def _argument_completion_labels(source: str) -> set[str] | None:
return {item.label for item in completion.items} return {item.label for item in completion.items}
def test_unset_space_shows_options_then_variables(tmp_path, monkeypatch):
server, document, _ = _completion_server(tmp_path, monkeypatch)
document = server.workspace.get_text_document(document.uri)
for version, tail in enumerate(["unset ", "unset -", "unset -nocomplain ", "unset -- ", "unset global"], start=2):
source = "set globalValue 1\n" + tail
document._source = source
document.version = version
items = _complete(document, lsp.Position(line=1, character=len(tail)))
labels = {item.label for item in items}
if tail in {"unset ", "unset -"}:
assert labels == {"nocomplain"}
else:
assert "globalValue" in labels
assert "-nocomplain" not in labels
def test_array_keys_complete_in_set_and_substitution(tmp_path: Path, monkeypatch): def test_array_keys_complete_in_set_and_substitution(tmp_path: Path, monkeypatch):
server, _, _ = _completion_server(tmp_path, monkeypatch) server, _, _ = _completion_server(tmp_path, monkeypatch)
workspace = _document( workspace = _document(
@@ -110,19 +115,16 @@ def test_array_keys_complete_in_set_and_substitution(tmp_path: Path, monkeypatch
"set ::lib_flag(enabled) 1\nset ::lib_flag(external) 1\n", "set ::lib_flag(enabled) 1\nset ::lib_flag(external) 1\n",
) )
assert server.update_poco_completion_for_file(workspace) assert server.update_poco_completion_for_file(workspace)
source = ( source = "set lib_flag(enabled) 0\nset lib_flag(empty) 1\nset other(wrong) 1\nproc hidden {} { set lib_flag(private) 1 }\nset lib_flag()\nputs $lib_flag(en)\nputs 😀; set lib_flag(em\n"
"set lib_flag(enabled) 0\n"
"set lib_flag(empty) 1\n"
"set other(wrong) 1\n"
"proc hidden {} { set lib_flag(private) 1 }\n"
"set lib_flag()\n"
"puts $lib_flag(en)\n"
"puts 😀; set lib_flag(em\n"
)
current = _document(tmp_path / "arrays-current.tcl", source) current = _document(tmp_path / "arrays-current.tcl", source)
server.workspace.put_text_document(lsp.TextDocumentItem( server.workspace.put_text_document(
uri=current.uri, language_id="tcl", version=1, text=source, lsp.TextDocumentItem(
)) uri=current.uri,
language_id="tcl",
version=1,
text=source,
)
)
assert server.update_poco_completion_for_file(current) assert server.update_poco_completion_for_file(current)
items = _complete(current, _position_after(source, "set lib_flag(", 3)) items = _complete(current, _position_after(source, "set lib_flag(", 3))
assert [item.label for item in items] == ["empty", "enabled", "external"] assert [item.label for item in items] == ["empty", "enabled", "external"]
@@ -159,32 +161,28 @@ def test_dynamic_array_index_keeps_variable_identity(tmp_path: Path, monkeypatch
assert variable_name(tree.children[3].args[0]) is None assert variable_name(tree.children[3].args[0]) is None
path = tmp_path / "dynamic.tcl" path = tmp_path / "dynamic.tcl"
index = build_file_symbol_index(str(path), path.as_uri(), tree) index = build_file_symbol_index(str(path), path.as_uri(), tree)
definition = next( definition = next(item for item in index.occurrences if item.identity.name == "::custom_flag" and item.is_definition)
item for item in index.occurrences
if item.identity.name == "::custom_flag" and item.is_definition
)
assert definition.range.start.character == 4 assert definition.range.start.character == 4
assert definition.range.end.character == 15 assert definition.range.end.character == 15
assert definition.array_element is None assert definition.array_element is None
assert any(item.identity.name == "::mom_path_name" for item in index.occurrences) assert any(item.identity.name == "::mom_path_name" for item in index.occurrences)
highlighter = _Highlighter([], {}) highlighter = _Highlighter([], {})
tree.accept(highlighter, recurse=True) tree.accept(highlighter, recurse=True)
assert any( assert any(position == (0, 4) and length == 11 and kind == "variable" for position, length, kind, _ in highlighter._tokens)
position == (0, 4) and length == 11 and kind == "variable"
for position, length, kind, _ in highlighter._tokens
)
server, _, _ = _completion_server(tmp_path, monkeypatch) server, _, _ = _completion_server(tmp_path, monkeypatch)
current = _document(path, source) current = _document(path, source)
server.workspace.put_text_document(lsp.TextDocumentItem( server.workspace.put_text_document(
uri=current.uri, language_id="tcl", version=1, text=source, lsp.TextDocumentItem(
)) uri=current.uri,
language_id="tcl",
version=1,
text=source,
)
)
assert server.update_poco_completion_for_file(current) assert server.update_poco_completion_for_file(current)
items = _complete(current, _position_after(source, "puts $custom")) items = _complete(current, _position_after(source, "puts $custom"))
assert "custom_flag" in {item.label for item in items} assert "custom_flag" in {item.label for item in items}
workspace_items = next( workspace_items = next(items for item_path, items in server.completion_items_by_file_snapshot().items() if server.paths_equal(item_path, str(path)))
items for item_path, items in server.completion_items_by_file_snapshot().items()
if server.paths_equal(item_path, str(path))
)
assert {"quoted_flag", "command_flag"} <= {item.label for item in workspace_items} assert {"quoted_flag", "command_flag"} <= {item.label for item in workspace_items}
@@ -216,18 +214,25 @@ def test_literal_array_components_complete_around_substitutions(tmp_path: Path,
offset = marked.index("|") offset = marked.index("|")
line = marked.replace("|", "") line = marked.replace("|", "")
items = array_element_completions( items = array_element_completions(
[line], lsp.Position(line=0, character=offset), [line],
server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"), lsp.Position(line=0, character=offset),
server.navigation_snapshot().values(),
str(tmp_path / "caller.tcl"),
) )
assert {item.label for item in items} == labels assert {item.label for item in items} == labels
edit = next(item.text_edit for item in items if item.label == selected) edit = next(item.text_edit for item in items if item.label == selected)
item = next(item for item in items if item.label == selected) item = next(item for item in items if item.label == selected)
assert item.insert_text_format == lsp.InsertTextFormat.PlainText assert item.insert_text_format == lsp.InsertTextFormat.PlainText
assert line[:edit.range.start.character] + edit.new_text + line[edit.range.end.character:] == expected assert line[: edit.range.start.character] + edit.new_text + line[edit.range.end.character :] == expected
assert array_element_completions( assert (
["set custom_flag(from_move,$::mom"], lsp.Position(line=0, character=31), array_element_completions(
server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"), ["set custom_flag(from_move,$::mom"],
) is None lsp.Position(line=0, character=31),
server.navigation_snapshot().values(),
str(tmp_path / "caller.tcl"),
)
is None
)
def _argument_completion_request(source: str): def _argument_completion_request(source: str):
@@ -257,9 +262,7 @@ def test_variable_completion_filters_and_ranks_candidates(tmp_path: Path, monkey
other_builtin = standard_items.nx_variables[1] other_builtin = standard_items.nx_variables[1]
assert declared_builtin.label in by_label assert declared_builtin.label in by_label
assert other_builtin.label in by_label assert other_builtin.label in by_label
assert by_label[declared_builtin.label].documentation == ( assert by_label[declared_builtin.label].documentation == (declared_builtin.documentation)
declared_builtin.documentation
)
assert by_label["localValue"].sort_text.startswith("000:") assert by_label["localValue"].sort_text.startswith("000:")
assert by_label["globalValue"].sort_text.startswith("100:") assert by_label["globalValue"].sort_text.startswith("100:")
assert by_label["workspaceValue"].sort_text.startswith("200:") assert by_label["workspaceValue"].sort_text.startswith("200:")
@@ -287,18 +290,9 @@ def test_command_completion_filters_and_ranks_candidates(tmp_path: Path, monkeyp
def test_completion_context_handles_nested_commands_and_utf16(): def test_completion_context_handles_nested_commands_and_utf16():
assert ( assert completion_context(["set result [work"], lsp.Position(line=0, character=16)) == CompletionContext.COMMAND
completion_context(["set result [work"], lsp.Position(line=0, character=16)) assert completion_context(["😀 puts $value"], lsp.Position(line=0, character=14)) == CompletionContext.VARIABLE
== CompletionContext.COMMAND assert completion_context(["puts value"], lsp.Position(line=0, character=10)) == CompletionContext.GENERAL
)
assert (
completion_context(["😀 puts $value"], lsp.Position(line=0, character=14))
== CompletionContext.VARIABLE
)
assert (
completion_context(["puts value"], lsp.Position(line=0, character=10))
== CompletionContext.GENERAL
)
def test_string_subcommands_and_compare_options_are_context_aware(): def test_string_subcommands_and_compare_options_are_context_aware():
@@ -310,9 +304,7 @@ def test_string_subcommands_and_compare_options_are_context_aware():
"-length", "-length",
"-nocase", "-nocase",
} }
assert _argument_completion_labels("string compare -nocase ") == { assert _argument_completion_labels("string compare -nocase ") == {"-length"}
"-length"
}
assert _argument_completion_labels("string compare -length ") is None assert _argument_completion_labels("string compare -length ") is None
@@ -336,22 +328,14 @@ def test_string_completion_inside_braced_conditions_and_bodies():
subcommands = _argument_completion_labels(prefix + "[string ") subcommands = _argument_completion_labels(prefix + "[string ")
assert subcommands is not None assert subcommands is not None
assert {"compare", "equal", "is"} <= subcommands assert {"compare", "equal", "is"} <= subcommands
assert _argument_completion_labels(prefix + "[string compare -") == { assert _argument_completion_labels(prefix + "[string compare -") == {"-length", "-nocase"}
"-length", "-nocase" assert _argument_completion_labels(prefix + "[string compare -nocase ") == {"-length"}
}
assert _argument_completion_labels(
prefix + "[string compare -nocase "
) == {"-length"}
def test_closed_braced_arguments_do_not_change_completion_context(): def test_closed_braced_arguments_do_not_change_completion_context():
assert _argument_completion_labels("puts {[string compare }") is None assert _argument_completion_labels("puts {[string compare }") is None
assert _argument_completion_labels( assert _argument_completion_labels("if {[string equal a b]} {string compare ") == {"-length", "-nocase"}
"if {[string equal a b]} {string compare " assert _argument_completion_labels("if {[string equal a b] && [string is integer ") == {"-failindex", "-strict"}
) == {"-length", "-nocase"}
assert _argument_completion_labels(
"if {[string equal a b] && [string is integer "
) == {"-failindex", "-strict"}
def test_dict_array_namespace_file_and_info_subcommands(): def test_dict_array_namespace_file_and_info_subcommands():
@@ -393,9 +377,7 @@ def test_dict_array_namespace_file_and_info_subcommands():
assert {"args", "body", "commands", "exists", "procs", "vars"} <= info_items assert {"args", "body", "commands", "exists", "procs", "vars"} <= info_items
def test_variable_context_still_takes_priority_inside_tcl_command( def test_variable_context_still_takes_priority_inside_tcl_command(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
_, current, source = _completion_server(tmp_path, monkeypatch) _, current, source = _completion_server(tmp_path, monkeypatch)
command_source = source.replace( command_source = source.replace(
" puts $local\n", " puts $local\n",
@@ -417,9 +399,7 @@ def test_variable_context_still_takes_priority_inside_tcl_command(
assert "localValue" in {item.label for item in items} assert "localValue" in {item.label for item in items}
def test_lsp_completion_returns_only_matching_command_options( def test_lsp_completion_returns_only_matching_command_options(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
_, current, _ = _completion_server(tmp_path, monkeypatch) _, current, _ = _completion_server(tmp_path, monkeypatch)
source = "string compare " source = "string compare "
current = _document(tmp_path / "current.tcl", source) current = _document(tmp_path / "current.tcl", source)
@@ -439,9 +419,7 @@ def test_lsp_completion_returns_only_matching_command_options(
assert all(item.sort_text.startswith("000:") for item in items) assert all(item.sort_text.startswith("000:") for item in items)
def test_space_trigger_does_not_open_broad_fallback_completion( def test_space_trigger_does_not_open_broad_fallback_completion(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
_, current, _ = _completion_server(tmp_path, monkeypatch) _, current, _ = _completion_server(tmp_path, monkeypatch)
source = "set value " source = "set value "
current = _document(tmp_path / "current.tcl", source) current = _document(tmp_path / "current.tcl", source)
@@ -530,9 +508,7 @@ def test_path_completion_is_relative_filtered_and_tcl_safe(tmp_path: Path):
assert items[0].text_edit.range.start.character == len("source ") assert items[0].text_edit.range.start.character == len("source ")
def test_lsp_source_completion_reads_paths_from_document_directory( def test_lsp_source_completion_reads_paths_from_document_directory(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
server, current, _ = _completion_server(tmp_path, monkeypatch) server, current, _ = _completion_server(tmp_path, monkeypatch)
scripts = tmp_path / "scripts" scripts = tmp_path / "scripts"
scripts.mkdir() scripts.mkdir()
@@ -556,9 +532,7 @@ def test_lsp_source_completion_reads_paths_from_document_directory(
assert "scripts/ignored.txt" not in labels assert "scripts/ignored.txt" not in labels
def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders( def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
_, current, source = _completion_server(tmp_path, monkeypatch) _, current, source = _completion_server(tmp_path, monkeypatch)
command_items = _complete(current, _position_after(source, "localP", occurrence=1)) command_items = _complete(current, _position_after(source, "localP", occurrence=1))
command_by_label = {item.label: item for item in command_items} command_by_label = {item.label: item for item in command_items}
@@ -575,14 +549,10 @@ def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders(
switch_arguments = _argument_completion_request("switch ") switch_arguments = _argument_completion_request("switch ")
assert switch_arguments is not None assert switch_arguments is not None
assert {"switch block", "-exact", "-glob", "-regexp"} <= { assert {"switch block", "-exact", "-glob", "-regexp"} <= {item.label for item in switch_arguments.items}
item.label for item in switch_arguments.items
}
def test_semantic_variable_and_procedure_argument_completion( def test_semantic_variable_and_procedure_argument_completion(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
_, current, source = _completion_server(tmp_path, monkeypatch) _, current, source = _completion_server(tmp_path, monkeypatch)
variable_items = _complete(current, _position_after(source, " set ")) variable_items = _complete(current, _position_after(source, " set "))
@@ -613,9 +583,7 @@ def test_semantic_variable_and_procedure_argument_completion(
assert "string" not in procedure_labels assert "string" not in procedure_labels
def test_namespace_argument_completion_uses_navigation_index( def test_namespace_argument_completion_uses_navigation_index(tmp_path: Path, monkeypatch):
tmp_path: Path, monkeypatch
):
server, current, _ = _completion_server(tmp_path, monkeypatch) server, current, _ = _completion_server(tmp_path, monkeypatch)
namespace_source = "namespace eval tools { proc helper {} { return } }\n" namespace_source = "namespace eval tools { proc helper {} { return } }\n"
namespace_document = _document(tmp_path / "namespaces.tcl", namespace_source) namespace_document = _document(tmp_path / "namespaces.tcl", namespace_source)
+37 -17
View File
@@ -1,4 +1,5 @@
import sys import sys
import threading
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -27,27 +28,46 @@ def test_debug_endpoint_rejects_invalid_port(monkeypatch, port):
_debug_server._debug_endpoint() _debug_server._debug_endpoint()
def test_connect_debugger_retries_until_adapter_is_ready(monkeypatch): class FakeDebugpy:
class FakeDebugpy: def __init__(self, refuse=False, block_wait=False):
def __init__(self): self.refuse = refuse
self.connect_calls = 0 self.block_wait = block_wait
self.wait_calls = 0 self.connect_calls = 0
self.wait_calls = 0
def connect(self, endpoint): def connect(self, endpoint):
assert endpoint == ("127.0.0.1", 5678) assert endpoint == ("127.0.0.1", 5678)
self.connect_calls += 1 self.connect_calls += 1
if self.connect_calls < 3: if self.refuse:
raise ConnectionRefusedError("listener is starting") raise ConnectionRefusedError("no listener")
def wait_for_client(self): def wait_for_client(self):
self.wait_calls += 1 self.wait_calls += 1
if self.block_wait:
threading.Event().wait()
def test_connect_debugger_connects_once_and_waits_for_client():
fake_debugpy = FakeDebugpy() fake_debugpy = FakeDebugpy()
monkeypatch.setattr(_debug_server.time, "sleep", lambda _seconds: None)
_debug_server._connect_debugger( _debug_server._connect_debugger(fake_debugpy, "127.0.0.1", 5678, timeout=1.0)
fake_debugpy, "127.0.0.1", 5678, timeout=1.0
)
assert fake_debugpy.connect_calls == 3 assert fake_debugpy.connect_calls == 1
assert fake_debugpy.wait_calls == 1 assert fake_debugpy.wait_calls == 1
def test_connect_debugger_does_not_retry_refused_connection():
# debugpy.connect() cannot be called a second time after a refused connection.
fake_debugpy = FakeDebugpy(refuse=True)
with pytest.raises(RuntimeError, match="No debugpy listener"):
_debug_server._connect_debugger(fake_debugpy, "127.0.0.1", 5678, timeout=1.0)
assert fake_debugpy.connect_calls == 1
def test_connect_debugger_times_out_on_stale_adapter():
fake_debugpy = FakeDebugpy(block_wait=True)
with pytest.raises(RuntimeError, match="stale debugpy adapter"):
_debug_server._connect_debugger(fake_debugpy, "127.0.0.1", 5678, timeout=0.1)
+223
View File
@@ -0,0 +1,223 @@
"""Block templates and addresses reaching NX commands through variables and procs."""
from pathlib import Path
import lsprotocol.types as lsp # type: ignore
from pygls.workspace import Workspace
import lsp_server
from lsp_tclserver import TclLanguageServer
from tools.def_flow import build_wrapper_table
PSC = """<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Sourcing><Sequence>
<Layer Name="Service" SubFolder="service">
<DefinedEvents><Filename Name="service"/></DefinedEvents>
</Layer>
</Sequence></Sourcing>
</Configuration>
"""
DEF = """MACHINE Default
FORMATTING
{
ADDRESS SPOS
{
FORMAT Coordinate
}
BLOCK_TEMPLATE steady_rest
{
Text[M60]
}
BLOCK_TEMPLATE absolute_mode
{
Text[G90]
}
}
"""
LIBRARY = """proc LIB_call_cycle {cycle {prefix ""}} {
set block $cycle ; regsub -all "," $block "_" block
if {[catch {set line [MOM_do_template $block CREATE]} err]} {
return
}
}
proc LIB_outer {mode name} {
LIB_call_cycle $name
}
proc LIB_force {address} {
MOM_force Once $address
}
proc LIB_log {message} {
puts $message
}
"""
CALLER = """LIB_call_cycle "absolute_mode"
LIB_outer on steady_rest
LIB_force SPOS
LIB_log steady_rest
puts steady_rest
proc local {} {
set t "steady_rest"
MOM_do_template $t
set unused absolute_mode
foreach b {"steady_rest" absolute_mode} { MOM_do_template $b }
set l [list "absolute_mode"]
lappend l steady_rest
foreach x $l { LIB_call_cycle $x }
}
"""
def _project(tmp_path: Path, monkeypatch):
(tmp_path / "service").mkdir()
(tmp_path / "post.psc").write_text(PSC, encoding="utf-8")
(tmp_path / "service" / "service.def").write_text(DEF, encoding="utf-8")
library = tmp_path / "library.tcl"
library.write_text(LIBRARY, encoding="utf-8")
caller = tmp_path / "caller.tcl"
caller.write_text(CALLER, encoding="utf-8")
server = TclLanguageServer(name="def-flow-test", version="1", max_workers=1)
server.protocol._workspace = Workspace( # pylint: disable=protected-access
root_uri=tmp_path.as_uri(),
sync_kind=lsp.TextDocumentSyncKind.Incremental,
workspace_folders=[lsp.WorkspaceFolder(uri=tmp_path.as_uri(), name="root")],
position_encoding=lsp.PositionEncodingKind.Utf16,
)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
server.refresh_psc_scripts([tmp_path])
for path, text in ((library, LIBRARY), (caller, CALLER)):
server.workspace.put_text_document(lsp.TextDocumentItem(uri=path.as_uri(), language_id="tcl", version=1, text=text))
server.update_poco_completion_for_file(server.workspace.get_text_document(path.as_uri()))
return server, caller
def _position(needle: str, occurrence: int = 0) -> lsp.Position:
index = -1
for _ in range(occurrence + 1):
index = CALLER.index(needle, index + 1)
line = CALLER.count("\n", 0, index)
return lsp.Position(line=line, character=index - (CALLER.rfind("\n", 0, index) + 1) + 1)
def _hover(caller: Path, needle: str, occurrence: int = 0):
return lsp_server.hover(
lsp.HoverParams(text_document=lsp.TextDocumentIdentifier(uri=caller.as_uri()), position=_position(needle, occurrence))
)
def _definition(caller: Path, needle: str, occurrence: int = 0):
return lsp_server.goto_definition(
lsp.DefinitionParams(text_document=lsp.TextDocumentIdentifier(uri=caller.as_uri()), position=_position(needle, occurrence))
)
def _hover_title(hover) -> str | None:
return hover and hover.contents.value.split("\n", 1)[0]
def test_wrapper_table_follows_parameters_through_nested_procs(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
table = server.def_wrapper_table()
assert table["::LIB_call_cycle"] == {0: frozenset({"block_template"})}
assert table["::LIB_outer"] == {1: frozenset({"block_template"})}
assert table["::LIB_force"] == {0: frozenset({"address"})}
assert "::LIB_log" not in table
def test_build_wrapper_table_stops_on_recursion():
flows = [
("::a", ((0, ("call", "::b", "::b", 0)),)),
("::b", ((0, ("call", "::a", "::a", 0)), (0, ("def", "address")))),
]
assert build_wrapper_table(flows) == {"::a": {0: frozenset({"address"})}, "::b": {0: frozenset({"address"})}}
def test_literal_argument_of_wrapper_proc_is_a_template(tmp_path, monkeypatch):
_, caller = _project(tmp_path, monkeypatch)
hover = _hover(caller, "absolute_mode")
assert _hover_title(hover).startswith("**Block template** `absolute_mode`")
assert (hover.range.start.line, hover.range.start.character, hover.range.end.character) == (0, 16, 29)
[location] = _definition(caller, "absolute_mode")
assert Path(location.uri).name == "service.def"
assert location.range.start.line == 12
def test_nested_wrapper_and_address_wrapper(tmp_path, monkeypatch):
_, caller = _project(tmp_path, monkeypatch)
assert _hover_title(_hover(caller, "steady_rest")).startswith("**Block template** `steady_rest`")
assert _hover_title(_hover(caller, "SPOS")).startswith("**Address** `SPOS`")
def test_same_name_without_flow_is_not_a_template(tmp_path, monkeypatch):
_, caller = _project(tmp_path, monkeypatch)
for needle, occurrence in (("steady_rest", 1), ("steady_rest", 2), ("absolute_mode", 1)):
assert _hover(caller, needle, occurrence) is None, (needle, occurrence)
assert _definition(caller, needle, occurrence) is None, (needle, occurrence)
def test_literals_flowing_through_local_variables(tmp_path, monkeypatch):
_, caller = _project(tmp_path, monkeypatch)
cases = (
("steady_rest", 3), # set t "steady_rest"; MOM_do_template $t
("steady_rest", 4), # foreach b {"steady_rest" ...}
("absolute_mode", 2), # foreach b {... absolute_mode}
("absolute_mode", 3), # set l [list "absolute_mode"]; foreach x $l { LIB_call_cycle $x }
("steady_rest", 5), # lappend l steady_rest
)
for needle, occurrence in cases:
assert _hover_title(_hover(caller, needle, occurrence)).startswith(f"**Block template** `{needle}`"), occurrence
assert _definition(caller, needle, occurrence), (needle, occurrence)
def test_derived_names_are_not_renamed(tmp_path, monkeypatch):
_, caller = _project(tmp_path, monkeypatch)
params = lsp.PrepareRenameParams(
text_document=lsp.TextDocumentIdentifier(uri=caller.as_uri()), position=_position("absolute_mode")
)
assert lsp_server.prepare_rename(params) is None
def _warnings(server, tmp_path: Path, source: str):
uri = (tmp_path / "check.tcl").as_uri()
server.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source))
diagnostics = server.lint(server.workspace.get_text_document(uri))
return [
(diagnostic.code, diagnostic.message, diagnostic.range.start.line, diagnostic.range.start.character, diagnostic.range.end.character)
for diagnostic in diagnostics
if diagnostic.code in {"unknown-block-template", "unknown-address"}
]
def test_unknown_template_and_address_are_warned(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
source = 'proc p {} {\n MOM_do_template stedy_rest\n MOM_force Once SPOS "SPSO"\n}\n'
assert _warnings(server, tmp_path, source) == [
("unknown-block-template", "Block template 'stedy_rest' is not declared in any loaded .def file", 1, 20, 30),
("unknown-address", "Address 'SPSO' is not declared in any loaded .def file", 2, 25, 29),
]
def test_known_and_dynamic_names_are_not_warned(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
source = 'MOM_do_template steady_rest CREATE\nMOM_do_template $name\nMOM_do_template "CYCLE_$x"\nMOM_force Once SPOS\n'
assert _warnings(server, tmp_path, source) == []
def test_no_warnings_without_loaded_def_files(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
server.def_documents = {}
assert _warnings(server, tmp_path, "MOM_do_template stedy_rest\n") == []
def test_def_change_invalidates_cached_diagnostics(tmp_path, monkeypatch):
server, caller = _project(tmp_path, monkeypatch)
server.compute_diagnostics(server.workspace.get_text_document(caller.as_uri()))
assert server.diagnostic_snapshot(caller.as_uri()) is not None
(tmp_path / "service" / "service.def").write_text(DEF.replace("absolute_mode", "incremental_mode"), encoding="utf-8")
server.refresh_def_symbols([tmp_path])
assert server.diagnostic_snapshot(caller.as_uri()) is None
@@ -0,0 +1,289 @@
"""Go to Definition, hover, references and rename between Tcl and .def files."""
from collections import namedtuple
from pathlib import Path
import lsprotocol.types as lsp # type: ignore
from pygls.workspace import Workspace
import lsp_server
from lsp_tclserver import TclLanguageServer
from tools.def_symbols import ADDRESS, BLOCK_TEMPLATE, FORMAT, parse_def_document
PSC = """<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Sourcing><Sequence>
<Layer Name="Service" SubFolder="service">
<DefinedEvents><Filename Name="service"/></DefinedEvents>
</Layer>
</Sequence></Sourcing>
</Configuration>
"""
DEF = """MACHINE Default
FORMATTING
{
FORMAT Coordinate "&__4.3_"
ADDRESS SPOS
{
FORMAT Coordinate
FORCE off
MAX 99999.999 Abort
MIN -99999.999 Abort
LEADER "SPOS="
}
# ADDRESS commented_out
BLOCK_TEMPLATE steady_rest
{
SPOS[$mom_pos(0)]
Text[M60]\\opt
}
}
"""
TCL = """proc MOM_steady {} {
MOM_do_template steady_rest
MOM_force Once SPOS X
MOM_ask_address_value "SPOS"
set name steady_rest
}
"""
def _project(tmp_path: Path, monkeypatch):
(tmp_path / "service").mkdir()
(tmp_path / "post.psc").write_text(PSC, encoding="utf-8")
def_file = tmp_path / "service" / "service.def"
def_file.write_text(DEF, encoding="utf-8")
tcl_file = tmp_path / "caller.tcl"
tcl_file.write_text(TCL, encoding="utf-8")
server = TclLanguageServer(name="def-navigation-test", version="1", max_workers=1)
server.protocol._workspace = Workspace( # pylint: disable=protected-access
root_uri=tmp_path.as_uri(),
sync_kind=lsp.TextDocumentSyncKind.Incremental,
workspace_folders=[lsp.WorkspaceFolder(uri=tmp_path.as_uri(), name="root")],
position_encoding=lsp.PositionEncodingKind.Utf16,
)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
server.refresh_psc_scripts([tmp_path])
server.workspace.put_text_document(
lsp.TextDocumentItem(uri=tcl_file.as_uri(), language_id="tcl", version=1, text=TCL)
)
server.update_poco_completion_for_file(server.workspace.get_text_document(tcl_file.as_uri()))
return server, def_file, tcl_file
def _position(source: str, needle: str, occurrence: int = 0, offset: int = 1) -> lsp.Position:
index = -1
for _ in range(occurrence + 1):
index = source.index(needle, index + 1)
line = source.count("\n", 0, index)
column = index - (source.rfind("\n", 0, index) + 1)
return lsp.Position(line=line, character=column + offset)
def _tcl_params(tcl_file: Path, needle: str, occurrence: int = 0):
return lsp.TextDocumentIdentifier(uri=tcl_file.as_uri()), _position(TCL, needle, occurrence)
# The client sends custom .def requests as plain JSON; pygls exposes them as namedtuples.
_Doc = namedtuple("Object", ["uri"])
_Pos = namedtuple("Object", ["line", "character"])
_Params = namedtuple("Object", ["textDocument", "position", "text", "includeDeclaration", "newName"])
def _def_params(def_file: Path, needle: str, occurrence: int = 0, text: str = DEF, offset: int = 1, **extra):
position = _position(text, needle, occurrence, offset)
return _Params(
_Doc(def_file.as_uri()),
_Pos(position.line, position.character),
text,
extra.get("includeDeclaration", True),
extra.get("newName", ""),
)
def _lines(locations):
return sorted((Path(location.uri).name, location.range.start.line, location.range.start.character) for location in locations)
def test_parse_def_document_declarations_and_references():
document = parse_def_document(DEF)
kinds = [(item.kind, item.name) for item in document.declarations]
assert kinds == [(FORMAT, "Coordinate"), (ADDRESS, "SPOS"), (BLOCK_TEMPLATE, "steady_rest")]
address = document.declarations[1]
assert (address.line, address.start, address.end) == (5, 12, 16)
assert dict(address.properties)["LEADER"] == '"SPOS="'
assert [(ref.name, ref.line, ref.container) for ref in document.references] == [
("SPOS", 16, "steady_rest"),
("Text", 17, "steady_rest"),
]
assert document.declarations[2].text.splitlines()[-1].strip() == "}"
def test_tcl_goto_definition_of_template_and_address(tmp_path, monkeypatch):
_, def_file, tcl_file = _project(tmp_path, monkeypatch)
document, position = _tcl_params(tcl_file, "steady_rest")
result = lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position))
assert _lines(result) == [("service.def", 14, 19)]
for needle, occurrence in (("SPOS", 0), ("SPOS", 1)):
document, position = _tcl_params(tcl_file, needle, occurrence)
result = lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position))
assert _lines(result) == [("service.def", 5, 12)]
def test_tcl_goto_definition_ignores_plain_words_and_unknown_names(tmp_path, monkeypatch):
_, _, tcl_file = _project(tmp_path, monkeypatch)
# "set name steady_rest" is no template argument.
document, position = _tcl_params(tcl_file, "steady_rest", 1)
assert lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position)) is None
document, position = lsp.TextDocumentIdentifier(uri=tcl_file.as_uri()), _position(TCL, "SPOS X", offset=5)
assert lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position)) is None
def test_tcl_hover_shows_template_body_and_address_properties(tmp_path, monkeypatch):
_, _, tcl_file = _project(tmp_path, monkeypatch)
document, position = _tcl_params(tcl_file, "steady_rest")
hover = lsp_server.hover(lsp.HoverParams(text_document=document, position=position))
assert "Block template" in hover.contents.value
assert "SPOS[$mom_pos(0)]" in hover.contents.value
assert "```def" in hover.contents.value
document, position = _tcl_params(tcl_file, "SPOS")
value = lsp_server.hover(lsp.HoverParams(text_document=document, position=position)).contents.value
assert "| Format | `Coordinate` → `\"&__4.3_\"` |" in value
assert '| Leader | `"SPOS="` |' in value
assert "| Min | `-99999.999 Abort` |" in value
assert "| Max | `99999.999 Abort` |" in value
assert "| Modality | `off` (modal, output only on change) |" in value
def test_tcl_references_include_def_declaration_and_template_elements(tmp_path, monkeypatch):
_, _, tcl_file = _project(tmp_path, monkeypatch)
document, position = _tcl_params(tcl_file, "SPOS")
result = lsp_server.references(
lsp.ReferenceParams(
text_document=document,
position=position,
context=lsp.ReferenceContext(include_declaration=True),
)
)
assert _lines(result) == [
("caller.tcl", 2, 19),
("caller.tcl", 3, 27),
("service.def", 5, 12),
("service.def", 16, 8),
]
result = lsp_server.references(
lsp.ReferenceParams(
text_document=document,
position=position,
context=lsp.ReferenceContext(include_declaration=False),
)
)
assert ("service.def", 5, 12) not in _lines(result)
def test_tcl_rename_updates_def_and_tcl(tmp_path, monkeypatch):
_, def_file, tcl_file = _project(tmp_path, monkeypatch)
document, position = _tcl_params(tcl_file, "SPOS")
prepared = lsp_server.prepare_rename(lsp.PrepareRenameParams(text_document=document, position=position))
assert prepared.placeholder == "SPOS"
edit = lsp_server.rename(lsp.RenameParams(text_document=document, position=position, new_name="STEADY_POS"))
edits = {Path(uri).name: [(e.range.start.line, e.range.start.character) for e in items] for uri, items in edit.changes.items()}
assert edits == {"caller.tcl": [(3, 27), (2, 19)], "service.def": [(16, 8), (5, 12)]}
assert all(e.new_text == "STEADY_POS" for items in edit.changes.values() for e in items)
def test_undeclared_names_are_not_renamed(tmp_path, monkeypatch):
_, _, tcl_file = _project(tmp_path, monkeypatch)
document = lsp.TextDocumentIdentifier(uri=tcl_file.as_uri())
position = _position(TCL, "SPOS X", offset=5)
assert lsp_server.prepare_rename(lsp.PrepareRenameParams(text_document=document, position=position)) is None
def test_def_requests_resolve_declarations_and_elements(tmp_path, monkeypatch):
_, def_file, _ = _project(tmp_path, monkeypatch)
# Address element inside a block template -> ADDRESS declaration.
result = lsp_server.def_definition(_def_params(def_file, "SPOS[", offset=1))
assert _lines(result) == [("service.def", 5, 12)]
hover = lsp_server.def_hover(_def_params(def_file, "steady_rest"))
assert "Text[M60]" in hover.contents.value
references = lsp_server.def_references(_def_params(def_file, "ADDRESS SPOS", offset=9))
assert _lines(references) == [
("caller.tcl", 2, 19),
("caller.tcl", 3, 27),
("service.def", 5, 12),
("service.def", 16, 8),
]
assert lsp_server.def_hover(_def_params(def_file, "MACHINE")) is None
def test_def_requests_use_unsaved_text(tmp_path, monkeypatch):
_, def_file, _ = _project(tmp_path, monkeypatch)
text = DEF.replace("BLOCK_TEMPLATE steady_rest", "BLOCK_TEMPLATE steady_rest_new")
params = _def_params(def_file, "steady_rest_new", text=text, newName="rest")
assert lsp_server.def_prepare_rename(params).placeholder == "steady_rest_new"
edit = lsp_server.def_rename(params)
assert list(edit.changes) == [def_file.as_uri()]
def test_def_rename_updates_tcl_callers(tmp_path, monkeypatch):
_, def_file, _ = _project(tmp_path, monkeypatch)
edit = lsp_server.def_rename(_def_params(def_file, "steady_rest", newName="lunette"))
edits = {Path(uri).name: [(e.range.start.line, e.range.start.character) for e in items] for uri, items in edit.changes.items()}
assert edits == {"caller.tcl": [(1, 20)], "service.def": [(14, 19)]}
assert lsp_server.def_rename(_def_params(def_file, "steady_rest", newName="bad name")) is None
LAYERED_PSC = """<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Sourcing><Sequence>
<Layer Name="Controller" SubFolder="ctrl"><DefinedEvents><Filename Name="ctrl"/></DefinedEvents></Layer>
<Layer Name="OEM" SubFolder="oem"><DefinedEvents><Filename Name="oem"/></DefinedEvents></Layer>
</Sequence></Sourcing>
</Configuration>
"""
def test_template_in_several_layers_shows_the_last_loaded_one(tmp_path, monkeypatch):
for folder, text in (("ctrl", "Text[M17]"), ("oem", "Text[RET]")):
(tmp_path / folder).mkdir()
(tmp_path / folder / f"{folder}.def").write_text(
f"MACHINE X\n\nFORMATTING\n{{\n BLOCK_TEMPLATE end_of_subprogram\n {{\n {text}\n }}\n}}\n", encoding="utf-8"
)
(tmp_path / "post.psc").write_text(LAYERED_PSC, encoding="utf-8")
source = "MOM_do_template end_of_subprogram\n"
caller = tmp_path / "caller.tcl"
caller.write_text(source, encoding="utf-8")
server = TclLanguageServer(name="def-layer-test", version="1", max_workers=1)
server.protocol._workspace = Workspace( # pylint: disable=protected-access
root_uri=tmp_path.as_uri(),
sync_kind=lsp.TextDocumentSyncKind.Incremental,
workspace_folders=[lsp.WorkspaceFolder(uri=tmp_path.as_uri(), name="root")],
position_encoding=lsp.PositionEncodingKind.Utf16,
)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
server.refresh_psc_scripts([tmp_path])
server.workspace.put_text_document(lsp.TextDocumentItem(uri=caller.as_uri(), language_id="tcl", version=1, text=source))
server.update_poco_completion_for_file(server.workspace.get_text_document(caller.as_uri()))
document = lsp.TextDocumentIdentifier(uri=caller.as_uri())
position = lsp.Position(line=0, character=20)
hover = lsp_server.hover(lsp.HoverParams(text_document=document, position=position)).contents.value
assert hover.startswith("**Block template** `end_of_subprogram` — oem.def:5")
assert "Text[RET]" in hover and "Text[M17]" not in hover
assert hover.endswith("_Overrides ctrl.def:5_")
[location] = lsp_server.goto_definition(lsp.DefinitionParams(text_document=document, position=position))
assert location.uri.endswith("oem/oem.def")
references = lsp_server.references(
lsp.ReferenceParams(text_document=document, position=position, context=lsp.ReferenceContext(include_declaration=True))
)
assert {Path(location.uri).name for location in references} == {"ctrl.def", "oem.def", "caller.tcl"}
@@ -0,0 +1,279 @@
"""Completion of .def block templates and addresses from PSC DefinedEvents."""
import itertools
from pathlib import Path
import lsprotocol.types as lsp # type: ignore
from pygls.workspace import Workspace
import lsp_server
from lsp_tclserver import TclLanguageServer
from tools.def_symbols import parse_def_symbols
from tools.file_sourcing import psc_defined_event_files
PSC = """<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Sourcing><Sequence>
<Layer Name="Service" SubFolder="service">
<Scripts><Filename Name="service"/></Scripts>
<DefinedEvents><Filename Name="service" Processing="auto"/></DefinedEvents>
</Layer>
<Layer Name="Empty"><DefinedEvents/></Layer>
</Sequence></Sourcing>
</Configuration>
"""
DEF = """MACHINE Default
FORMATTING
{
ADDRESS SPOS
{
FORMAT Coordinate
}
ADDRESS X {
}
\tBLOCK_TEMPLATE external_subprogram
\t{
\t\tText[$lib_spf(value,subprogram_output_name)]
\t}
\tBLOCK_TEMPLATE steady_rest
\t{
\t\tText[M60]
\t}
#\tBLOCK_TEMPLATE commented_out
}
"""
def _project(tmp_path: Path, monkeypatch):
(tmp_path / "service").mkdir()
psc = tmp_path / "post.psc"
psc.write_text(PSC, encoding="utf-8")
(tmp_path / "service" / "service.def").write_text(DEF, encoding="utf-8")
(tmp_path / "service" / "service.tcl").write_text("proc svc {} {}\n", encoding="utf-8")
server = TclLanguageServer(name="block-template-test", version="1", max_workers=1)
server.protocol._workspace = Workspace( # pylint: disable=protected-access
root_uri=tmp_path.as_uri(),
sync_kind=lsp.TextDocumentSyncKind.Incremental,
workspace_folders=[lsp.WorkspaceFolder(uri=tmp_path.as_uri(), name="root")],
position_encoding=lsp.PositionEncodingKind.Utf16,
)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
server.refresh_psc_scripts([tmp_path])
return server, psc
_versions = itertools.count(1)
def _complete(server, tmp_path: Path, source: str, trigger: str | None = None):
uri = (tmp_path / "caller.tcl").as_uri()
# A new version per request, the server caches lines by (uri, version).
server.workspace.put_text_document(
lsp.TextDocumentItem(uri=uri, language_id="tcl", version=next(_versions), text=source)
)
lines = source.split("\n")
context = None
if trigger is not None:
context = lsp.CompletionContext(
trigger_kind=lsp.CompletionTriggerKind.TriggerCharacter,
trigger_character=trigger,
)
result = lsp_server.on_completion(
lsp.CompletionParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=lsp.Position(line=len(lines) - 1, character=len(lines[-1])),
context=context,
)
)
return sorted(result.items, key=lambda item: item.sort_text or "")
def test_parse_def_symbols_ignores_comments():
symbols = parse_def_symbols(DEF)
assert symbols.block_templates == ("external_subprogram", "steady_rest")
assert symbols.addresses == ("SPOS", "X")
def test_psc_defined_event_files_resolve_def_paths(tmp_path, monkeypatch):
_, psc = _project(tmp_path, monkeypatch)
assert psc_defined_event_files(psc) == [(tmp_path / "service" / "service.def").resolve()]
def test_mom_do_template_offers_templates_then_variables_on_space(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
items = _complete(server, tmp_path, "set globalValue 1\nMOM_do_template ", trigger=" ")
labels = [item.label for item in items]
assert labels[:2] == ["external_subprogram", "steady_rest"]
assert items[0].detail == "Block template (service.def)"
variable = next(item for item in items if item.label == "globalValue")
assert variable.insert_text == "$globalValue"
assert "svc" not in labels
def test_mom_do_template_ranks_templates_before_variables(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
items = _complete(server, tmp_path, "set globalValue 1\nMOM_do_template st")
labels = [item.label for item in items]
assert labels[:2] == ["external_subprogram", "steady_rest"]
assert "globalValue" in labels
def test_mom_do_template_dollar_still_completes_variables(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
items = _complete(server, tmp_path, "set globalValue 1\nMOM_do_template $glob")
labels = {item.label for item in items}
assert "globalValue" in labels
assert "steady_rest" not in labels
def test_def_change_refreshes_block_templates(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
def_file = tmp_path / "service" / "service.def"
def_file.write_text("BLOCK_TEMPLATE new_block\n{\n}\n", encoding="utf-8")
lsp_server.did_change_watched_files(lsp.DidChangeWatchedFilesParams(changes=[
lsp.FileEvent(uri=def_file.as_uri(), type=lsp.FileChangeType.Changed)]))
assert [item.label for item in server.block_template_items()] == ["new_block"]
assert server.address_items() == []
def test_mom_ask_address_value_offers_addresses_then_variables(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
items = _complete(server, tmp_path, "set globalValue 1\nMOM_ask_address_value ", trigger=" ")
labels = [item.label for item in items]
assert labels[:2] == ["SPOS", "X"]
assert items[0].detail == "Address (service.def)"
assert "globalValue" in labels
assert "steady_rest" not in labels
def test_mom_force_and_suppress_offer_mode_or_variable_then_addresses(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
for command in ("MOM_force", "MOM_suppress"):
items = _complete(server, tmp_path, f"set globalValue 1\n{command} ", trigger=" ")
labels = [item.label for item in items]
assert set(labels[:3]) == {"Always", "Once", "Off"}
variable = next(item for item in items if item.label == "globalValue")
assert variable.insert_text == "$globalValue"
assert "SPOS" not in labels
for source in (f"{command} Once ", f"{command} Always SPOS "):
labels = [item.label for item in _complete(server, tmp_path, source, trigger=" ")]
assert labels[:2] == ["SPOS", "X"]
assert "Once" not in labels
def _edit(items, label):
item = next(item for item in items if item.label == label)
return item.text_edit.new_text, (item.text_edit.range.start.character, item.text_edit.range.end.character)
def test_def_symbols_and_modes_are_inserted_quoted(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
source = "MOM_do_template st"
assert _edit(_complete(server, tmp_path, source), "steady_rest") == ('"steady_rest"', (16, 18))
source = "MOM_force "
assert _edit(_complete(server, tmp_path, source, trigger=" "), "Once") == ('"Once"', (10, 10))
source = "MOM_force Once SP"
assert _edit(_complete(server, tmp_path, source), "SPOS") == ('"SPOS"', (15, 17))
def test_typed_quotes_are_replaced_not_doubled(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
# Opening quote typed by the user.
source = 'MOM_ask_address_value "SP'
assert _edit(_complete(server, tmp_path, source), "SPOS") == ('"SPOS"', (22, 25))
# Closing quote inserted by the editor after the cursor.
uri = (tmp_path / "caller.tcl").as_uri()
source = 'MOM_ask_address_value "SP"'
server.workspace.put_text_document(
lsp.TextDocumentItem(uri=uri, language_id="tcl", version=next(_versions), text=source)
)
items = lsp_server.on_completion(
lsp.CompletionParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=lsp.Position(line=0, character=25),
)
).items
assert _edit(items, "SPOS") == ('"SPOS"', (22, 26))
def test_block_list_prefix_offers_keyword_and_stays_incomplete(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
uri = (tmp_path / "caller.tcl").as_uri()
source = "MOM_do_template BLOCK_L"
server.workspace.put_text_document(
lsp.TextDocumentItem(uri=uri, language_id="tcl", version=next(_versions), text=source)
)
result = lsp_server.on_completion(
lsp.CompletionParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=lsp.Position(line=0, character=len(source)),
)
)
assert result.is_incomplete
keyword = result.items[0]
assert keyword.label == "BLOCK_LIST"
assert keyword.command.command == "editor.action.triggerSuggest"
def test_block_list_shows_all_templates_quoted(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
items = _complete(server, tmp_path, "set a 1\n BLOCK_LIST")
assert [item.label for item in items] == ["external_subprogram", "steady_rest"]
assert _edit(items, "steady_rest") == ('"steady_rest"', (4, 14))
assert all(item.filter_text.startswith("BLOCK_LIST") for item in items)
def test_block_list_item_resolves_to_template_preview(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
items = _complete(server, tmp_path, "set a 1\n BLOCK_LIST")
assert all(item.documentation is None for item in items)
item = next(item for item in items if item.label == "steady_rest")
resolved = lsp_server.on_completion_resolve(item)
assert resolved.documentation.kind == lsp.MarkupKind.Markdown
assert "**Block template** `steady_rest`" in resolved.documentation.value
assert "Text[M60]" in resolved.documentation.value
def test_address_list_item_resolves_to_address_table(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
items = _complete(server, tmp_path, "set a 1\n ADDR_LIST")
item = next(item for item in items if item.label == "SPOS")
resolved = lsp_server.on_completion_resolve(item)
assert "| Format | `Coordinate` |" in resolved.documentation.value
def test_block_list_ignores_variables_and_other_words(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
for source in ("set x $BLOCK_LIST", "set x MY_BLOCK_LIST", "set x steady"):
labels = [item.label for item in _complete(server, tmp_path, source)]
assert "BLOCK_LIST" not in labels
def test_addr_list_shows_all_addresses_quoted(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
items = _complete(server, tmp_path, "MOM_force Once ADDR_LIST")
assert [item.label for item in items] == ["SPOS", "X"]
assert _edit(items, "SPOS") == ('"SPOS"', (15, 24))
assert all(item.filter_text.startswith("ADDR_LIST") for item in items)
def test_addr_list_prefix_offers_keyword(tmp_path, monkeypatch):
server, _ = _project(tmp_path, monkeypatch)
uri = (tmp_path / "caller.tcl").as_uri()
source = "ADDR"
server.workspace.put_text_document(
lsp.TextDocumentItem(uri=uri, language_id="tcl", version=next(_versions), text=source)
)
result = lsp_server.on_completion(
lsp.CompletionParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=lsp.Position(line=0, character=len(source)),
)
)
assert result.is_incomplete
assert result.items[0].label == "ADDR_LIST"
assert "BLOCK_LIST" not in {item.label for item in result.items}
@@ -0,0 +1,58 @@
"""Formatting with NxFormatter."""
from tclint.format import FormatterOpts
from tools.formatter import NxFormatter
from tools.parser import CustomParser
def _format(source: str) -> str:
formatter = NxFormatter(
FormatterOpts(
indent="\t",
spaces_in_braces=False,
balanced_spaces_in_braces=False,
max_blank_lines=500,
indent_namespace_eval=True,
indent_mixed_tab_size=0,
emacs=False,
debug_whitespace=False,
)
)
return formatter.format_top(source, CustomParser())
def test_uplevel_body_is_indented():
source = "proc a {} {\n\tuplevel #0 {\n\tset x 1\n\t\tset y 2\n }\n}\n"
assert _format(source) == "proc a {} {\n\tuplevel #0 {\n\t\tset x 1\n\t\tset y 2\n\t}\n}\n"
def test_uplevel_without_level_and_with_variable_level():
source = "uplevel {\nset x 1\n}\nuplevel $lvl {\nset y 2\n}\nuplevel set z 3\n"
assert _format(source) == (
"uplevel {\n\tset x 1\n}\nuplevel $lvl {\n\tset y 2\n}\nuplevel set z 3\n"
)
def test_comment_gets_space_after_hash():
source = (
"#Comment\n"
"# already spaced\n"
"#\tTabbed\n"
"#\n"
"##########\n"
"set x 1 ;#inline\n"
"proc a {} {\n"
"\t#nested\n"
"}\n"
)
assert _format(source) == (
"# Comment\n"
"# already spaced\n"
"#\tTabbed\n"
"#\n"
"##########\n"
"set x 1 ;# inline\n"
"proc a {} {\n"
"\t# nested\n"
"}\n"
)
@@ -0,0 +1,131 @@
import sys
from pathlib import Path
import pytest
THIS_DIR = Path(__file__).parent
SRC_DIR = THIS_DIR.parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.insert(0, str(SRC_DIR))
from lsp_tclserver import TclLanguageServer # noqa: E402
from pygls.workspace.text_document import TextDocument # noqa: E402
from tclint.lexer import TclSyntaxError # noqa: E402
from tclint.syntax_tree import Node # noqa: E402
from tools.incremental_parse import reparse # noqa: E402
from tools.parser import CustomParser # noqa: E402
SOURCE = """\
# header comment
set a 1; set b 2
proc first {x} {
global mom_pos
if {$x > 0} {
MOM_output_literal "first $x"
}
return [expr {$x + 1}]
}
proc second {} {
set list [list a b \\
c d]
return $list
}
lappend ::handlers {second}
"""
def _parse(text, pos=None):
parser = CustomParser()
tree = parser.parse(text, pos=pos)
return tree, list(parser.violations)
def _differences(a, b, path="root"):
if type(a) is not type(b):
return f"{path}: {type(a).__name__} != {type(b).__name__}"
for key in a.__dict__.keys() | b.__dict__.keys():
first, second = a.__dict__.get(key), b.__dict__.get(key)
if isinstance(first, Node):
difference = _differences(first, second, f"{path}.{key}")
elif isinstance(first, (list, tuple)) and first and isinstance(first[0], Node):
if len(first) != len(second):
return f"{path}.{key}: {len(first)} != {len(second)}"
difference = next(
(d for i, (x, y) in enumerate(zip(first, second)) if (d := _differences(x, y, f"{path}.{key}[{i}]"))),
None,
)
else:
difference = None if first == second else f"{path}.{key}: {first!r} != {second!r}"
if difference:
return difference
return None
def _violations(violations):
return [(str(v.id), v.message, v.start, v.end) for v in violations]
@pytest.mark.parametrize("old, new", [
("MOM_output_literal \"first $x\"", "MOM_output_literal \"first $x\" extra"),
(" return $list\n", " return $list\n puts done\n"),
("proc second {} {", "\nproc second {} {"),
("set a 1; set b 2\n", ""),
("# header comment\n", "# header comment\nset inserted 0\n"),
("lappend ::handlers {second}\n", "lappend ::handlers {second}\nproc third {} {}\n"),
(" c d]", " c d e]"),
("global mom_pos", "global mom_pos mom_out_angle_pos"),
])
def test_incremental_tree_matches_full_parse(old, new):
edited = SOURCE.replace(old, new, 1)
assert edited != SOURCE
previous = (SOURCE, *_parse(SOURCE))
result = reparse(*previous, edited, _parse)
assert result is not None
expected = _parse(edited)
assert _differences(result[0], expected[0]) is None
assert _violations(result[1]) == _violations(expected[1])
def test_continuation_across_the_edit_forces_full_parse():
edited = SOURCE.replace("set a 1; set b 2", "set a 1; set b 2 \\")
assert reparse(SOURCE, *_parse(SOURCE), edited, _parse) is None
def test_quote_closing_outside_the_edit_is_left_to_the_full_parse():
edited = SOURCE.replace("set a 1; set b 2", 'set a "1; set b 2')
try:
result = reparse(SOURCE, *_parse(SOURCE), edited, _parse)
except TclSyntaxError:
result = None
assert result is None
def test_unchanged_commands_are_reused_and_never_mutated():
tree, violations = _parse(SOURCE)
edited = SOURCE.replace("return $list", "return [lsort $list]")
new_tree, _ = reparse(SOURCE, tree, violations, edited, _parse)
assert new_tree.children[0] is tree.children[0]
# Commands after the edit are shifted copies; the old tree stays valid.
inserted = SOURCE.replace("proc first", "\n\nproc first")
shifted_tree, _ = reparse(SOURCE, tree, violations, inserted, _parse)
assert shifted_tree.children[-1] is not tree.children[-1]
assert shifted_tree.children[-1].line == tree.children[-1].line + 2
assert tree.children[-1].line == _parse(SOURCE)[0].children[-1].line
def test_server_reparses_edits_incrementally(tmp_path, monkeypatch):
server = TclLanguageServer(name="incremental-test", version="1", max_workers=1)
uri = (tmp_path / "edit.tcl").as_uri()
first = server.get_tree(TextDocument(uri=uri, source=SOURCE, version=1, language_id="tcl"))
parsed_sources = []
parse_source = server._parse_source
monkeypatch.setattr(server, "_parse_source", lambda text, pos=None: parsed_sources.append(text) or parse_source(text, pos))
server.clear_cache_for_uri(uri)
edited = SOURCE.replace("return $list", "return [lsort $list]")
second = server.get_tree(TextDocument(uri=uri, source=edited, version=2, language_id="tcl"))
assert parsed_sources and all(text != edited for text in parsed_sources)
assert second.children[0] is first.children[0]
assert _differences(second, _parse(edited)[0]) is None
@@ -0,0 +1,109 @@
import os
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 tools.index_cache as index_cache # noqa: E402
from lsp_tclserver import TclLanguageServer # noqa: E402
from pygls.workspace.text_document import TextDocument # noqa: E402
from tools.index_cache import IndexCache # noqa: E402
def _index_from_disk(server, path: Path) -> bool:
return server.update_poco_completion_for_file(
TextDocument(uri=path.as_uri(), language_id="tcl"),
cache_tree=False,
require_file_exists=True,
from_disk=True,
)
def _warm_server(cache_dir: Path) -> TclLanguageServer:
server = TclLanguageServer(name="cache-test", version="1", max_workers=1)
server.index_cache = IndexCache.load(cache_dir)
return server
def _fail_build(*_args, **_kwargs):
raise AssertionError("file was parsed although it is cached")
def test_second_start_uses_cached_index(tmp_path: Path, monkeypatch):
source = tmp_path / "post.tcl"
source.write_text("proc cached_proc {a b} { return $a }\n", encoding="utf-8")
cache_dir = tmp_path / "storage"
server = _warm_server(cache_dir)
assert _index_from_disk(server, source)
server.index_cache.save()
restarted = _warm_server(cache_dir)
monkeypatch.setattr(restarted, "_build_file_index", _fail_build)
assert _index_from_disk(restarted, source)
assert "cached_proc" in restarted.custom_function_names_snapshot()
assert restarted.proc_metadata_snapshot(str(source))[0]["cached_proc"] == ["a", "b"]
def test_changed_file_is_parsed_again(tmp_path: Path):
source = tmp_path / "post.tcl"
source.write_text("proc old_proc {} {}\n", encoding="utf-8")
cache_dir = tmp_path / "storage"
server = _warm_server(cache_dir)
assert _index_from_disk(server, source)
server.index_cache.save()
source.write_text("proc new_proc {} {}\n", encoding="utf-8")
stat = source.stat()
os.utime(source, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000))
restarted = _warm_server(cache_dir)
assert _index_from_disk(restarted, source)
names = restarted.custom_function_names_snapshot()
assert "new_proc" in names and "old_proc" not in names
def test_code_change_discards_the_cache(tmp_path: Path, monkeypatch):
source = tmp_path / "post.tcl"
source.write_text("proc cached_proc {} {}\n", encoding="utf-8")
cache_dir = tmp_path / "storage"
server = _warm_server(cache_dir)
assert _index_from_disk(server, source)
server.index_cache.save()
# E.g. an updated tclint: a different fingerprint must not load old entries.
monkeypatch.setattr(index_cache, "code_fingerprint", lambda: "other tclint")
restarted = _warm_server(cache_dir)
built = []
build = restarted._build_file_index
monkeypatch.setattr(restarted, "_build_file_index", lambda *args: built.append(args) or build(*args))
assert _index_from_disk(restarted, source)
assert built
def test_damaged_cache_is_ignored(tmp_path: Path, monkeypatch):
cache_dir = tmp_path / "storage"
cache_dir.mkdir()
(cache_dir / index_cache.CACHE_FILE).write_bytes(b"not a cache")
source = tmp_path / "post.tcl"
source.write_text("proc fresh_proc {} {}\n", encoding="utf-8")
server = _warm_server(cache_dir)
assert _index_from_disk(server, source)
server.index_cache.save()
restarted = _warm_server(cache_dir)
monkeypatch.setattr(restarted, "_build_file_index", _fail_build)
assert _index_from_disk(restarted, source)
def test_open_documents_never_touch_the_cache(tmp_path: Path):
source = tmp_path / "post.tcl"
source.write_text("proc on_disk {} {}\n", encoding="utf-8")
server = _warm_server(tmp_path / "storage")
unsaved = TextDocument(uri=source.as_uri(), source="proc unsaved {} {}\n", version=3, language_id="tcl")
assert server.update_poco_completion_for_file(unsaved)
server.index_cache.save()
assert not (tmp_path / "storage" / index_cache.CACHE_FILE).exists()
@@ -267,3 +267,35 @@ def test_variable_and_workspace_request_caches_are_reused(tmp_path: Path):
assert first_completions is second_completions assert first_completions is second_completions
assert first_names is second_names assert first_names is second_names
assert "cached_proc" in first_names assert "cached_proc" in first_names
def test_navigation_definitions_are_cached_until_the_index_changes(tmp_path: Path):
server = _server()
first = _document(tmp_path / "first.tcl", "proc first_proc {} {}")
assert server.update_poco_completion_for_file(first)
indexes, definitions = server.navigation_state()
assert server.navigation_state()[1] is definitions
assert {identity.name for identity in definitions} >= {"::first_proc"}
second = _document(tmp_path / "second.tcl", "proc second_proc {} {}")
assert server.update_poco_completion_for_file(second)
indexes, definitions = server.navigation_state()
assert second.path in indexes
assert {identity.name for identity in definitions} >= {"::first_proc", "::second_proc"}
def test_background_parse_does_not_wait_for_the_request_parser(tmp_path: Path):
server = _server()
document = _document(tmp_path / "background.tcl", "proc background_proc {} {}")
finished = Event()
def index():
assert server.update_poco_completion_for_file(document, cache_tree=False)
finished.set()
with server._parser_lock:
with ThreadPoolExecutor(max_workers=1) as executor:
executor.submit(index)
assert finished.wait(timeout=5)
assert "background_proc" in server.custom_function_names_snapshot()
@@ -0,0 +1,131 @@
from pathlib import Path
import lsprotocol.types as lsp
from pygls.workspace import Workspace
from pygls.workspace.text_document import TextDocument
import lsp_server
from lsp_tclserver import TclLanguageServer
from tools.file_sourcing import psc_script_files
from tools.tcloo_completion import tcloo_completions
from tools.semantic_tokens import TOKEN_TYPE_INDEX
CLASS = '''oo::class create MCS {
method initOrg {dx dy dz} {return [self]}
method toStr {{precision 7}} {}
}
proc helper {value} {}
'''
def setup_project(tmp_path, monkeypatch):
root = tmp_path / "project"
root.mkdir()
library = tmp_path / "external library"
library.mkdir()
script = library / "geometry.tcl"
script.write_text(CLASS, encoding="utf-8")
psc = root / "post.psc"
psc.write_text('''<Post><Layer Name="Geometry" SubFolder="../external library">
<Scripts><Filename Name="geometry" /></Scripts>
</Layer></Post>''', encoding="utf-8")
server = TclLanguageServer(name="psc-test", version="1")
server.protocol._workspace = Workspace(root_uri=root.as_uri(), sync_kind=lsp.TextDocumentSyncKind.Incremental,
workspace_folders=[], position_encoding=lsp.PositionEncodingKind.Utf16)
monkeypatch.setattr(lsp_server, "LSP_SERVER", server)
return server, root, psc, script
def caller(server, root, source):
uri = (root / "caller.tcl").as_uri()
server.clear_cache_for_uri(uri)
server.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source))
return uri, lsp.Position(line=len(source.splitlines()) - 1, character=len(source.splitlines()[-1]))
def test_psc_external_class_available_in_all_language_features(tmp_path, monkeypatch):
server, root, _, script = setup_project(tmp_path, monkeypatch)
lsp_server._refresh_psc_index()
assert any(server.paths_equal(script, path) for path in server.class_indexes)
assert {"MCS", "helper"} <= {item.label for item in server.completion_items_snapshot()}
uri, position = caller(server, root, "set mcs [MCS new]\n$mcs ")
result = lsp_server.on_completion(lsp.CompletionParams(text_document=lsp.TextDocumentIdentifier(uri=uri), position=position))
assert {"initOrg", "toStr"} <= {item.label for item in result.items}
uri, position = caller(server, root, "set mcs [MCS new]\n$mcs initOrg 1 ")
result = lsp_server.signature_help(lsp.SignatureHelpParams(text_document=lsp.TextDocumentIdentifier(uri=uri), position=position))
assert result.signatures[0].label == "::MCS initOrg dx dy dz"
assert result.active_parameter == 1
uri, _ = caller(server, root, "set mcs [MCS new]\n$mcs initOrg 1 2 3")
monkeypatch.setattr(lsp_server, "_get_settings_by_document", lambda doc: {"inlayHint": True})
hints = lsp_server.inlay_hints(lsp.InlayHintParams(text_document=lsp.TextDocumentIdentifier(uri=uri), range=lsp.Range(
start=lsp.Position(line=0, character=0), end=lsp.Position(line=2, character=0))))
assert [hint.label[0].value for hint in hints] == ["dx:", "dy:", "dz:"]
tokens = lsp_server.semantic_tokens(lsp.SemanticTokensParams(text_document=lsp.TextDocumentIdentifier(uri=uri))).data
assert TOKEN_TYPE_INDEX["class"] in tokens[3::5]
uri, position = caller(server, root, "set mcs [MC")
result = lsp_server.on_completion(lsp.CompletionParams(text_document=lsp.TextDocumentIdentifier(uri=uri), position=position))
assert any(item.label == "MCS" and item.kind == lsp.CompletionItemKind.Class for item in result.items)
def test_psc_refresh_removes_unlinked_external_classes(tmp_path, monkeypatch):
server, root, psc, script = setup_project(tmp_path, monkeypatch)
server.refresh_psc_scripts([root])
psc.write_text("<Post/>", encoding="utf-8")
lsp_server.did_change_watched_files(lsp.DidChangeWatchedFilesParams(changes=[
lsp.FileEvent(uri=psc.as_uri(), type=lsp.FileChangeType.Changed)]))
assert "::MCS" not in server.class_snapshot(root / "caller.tcl")
assert not any(server.paths_equal(script, path) for path in server.class_indexes)
assert "MCS" not in {item.label for item in server.completion_items_snapshot()}
def test_class_metadata_updates_and_local_override_does_not_mutate_index(tmp_path, monkeypatch):
server, root, _, script = setup_project(tmp_path, monkeypatch)
server.refresh_psc_scripts([root])
classes = server.class_snapshot(root / "caller.tcl")
source = "oo::class create MCS {method local {} {}}\nset mcs [MCS new]\n$mcs "
items = tcloo_completions(source.splitlines(), lsp.Position(line=2, character=5), classes)
assert {item.label for item in items} == {"local", "destroy"}
assert "local" not in classes["::MCS"].methods
document = TextDocument(uri=script.as_uri(), source="oo::class create MCS {method changed {} {}}", version=2)
assert server.update_poco_completion_for_file(document)
assert set(server.class_snapshot(root / "caller.tcl")["::MCS"].methods) == {"changed"}
server.remove_file_state(script.as_uri())
assert "::MCS" not in server.class_snapshot(root / "caller.tcl")
def test_psc_load_order_missing_files_and_xml_namespace(tmp_path, monkeypatch):
server, root, psc, script = setup_project(tmp_path, monkeypatch)
override = root / "override.tcl"
override.write_text("oo::class create MCS {method override {} {}}", encoding="utf-8")
psc.write_text(f'''<Post xmlns="urn:psc">
<Layer Name="Base" SubFolder="..\\external library"><Scripts><Filename Name="geometry.tcl"/></Scripts></Layer>
<Layer Name="Custom"><Scripts><Filename Name="{override.as_posix()}"/><Filename Name="missing.tcl"/></Scripts></Layer>
</Post>''', encoding="utf-8")
assert psc_script_files(psc) == [script, override, root / "missing.tcl"]
messages = []
server.refresh_psc_scripts([root], messages.append)
assert set(server.class_snapshot(root / "caller.tcl")["::MCS"].methods) == {"override"}
assert any("missing.tcl" in message for message in messages)
def test_psc_refresh_preserves_unsaved_open_library(tmp_path, monkeypatch):
server, root, _, script = setup_project(tmp_path, monkeypatch)
server.workspace.put_text_document(lsp.TextDocumentItem(uri=script.as_uri(), language_id="tcl", version=3,
text="oo::class create MCS {method unsaved {} {}}"))
server.refresh_psc_scripts([root])
assert set(server.class_snapshot(root / "caller.tcl")["::MCS"].methods) == {"unsaved"}
def test_psc_environment_folder_and_legacy_encoding(tmp_path, monkeypatch):
server, root, psc, script = setup_project(tmp_path, monkeypatch)
monkeypatch.setenv("UGII_CAM_SHOP_DOC_DIR", str(script.parent))
psc.write_text('''<Post><Layer SubFolder="UGII_CAM_SHOP_DOC_DIR">
<Scripts><Filename Name="geometry"/></Scripts></Layer></Post>''', encoding="utf-8")
script.write_bytes(("# Ältere Bibliothek\n" + CLASS).encode("cp1252"))
server.refresh_psc_scripts([root])
assert "::MCS" in server.class_snapshot(root / "caller.tcl")
@@ -0,0 +1,92 @@
"""Procedures stored in PostConfigurator COMMANDBLOCK properties."""
from pathlib import Path
import lsprotocol.types as lsp # type: ignore
from lsp_server import LSP_SERVER, goto_definition, references
from tools.parser import CustomParser
from tools.semantic_tokens import _Highlighter
from tools.stored_procs import stored_command_names
SOURCE = """proc custom_header {} {}
proc ::ns::output {args} {}
CONF_CTRL_tool set auto_preselect_last_template {custom_header}
CONF_CTRL_moves set return_safety_pos {{::ns::output 1} custom_header}
CONF_CTRL_moves set return_end_of_pgm {4th5th}
CONF_CTRL_tool set auto_preselect_template "custom_header"
set x {custom_header}
"""
def _names(source: str) -> list[tuple[str, int, int]]:
tree = CustomParser().parse(source)
return [name for command in tree.children for name in stored_command_names(command)]
def test_stored_command_names_take_the_first_word_of_braced_list_elements():
# Values like {4th5th} are options, not procedure names.
assert _names(SOURCE) == [
("custom_header", 3, 50),
("::ns::output", 4, 41),
("custom_header", 4, 57),
]
def test_stored_command_names_span_lines_and_skip_non_names():
source = "CONF_x set p {\n\t{first 1}\n\t\"second\"\n\t{$var}\n\t{a-b}\n}\n"
assert _names(source) == [("first", 2, 3), ("second", 3, 3)]
def _document(tmp_path: Path) -> str:
uri = (tmp_path / "stored.tcl").as_uri()
LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=SOURCE))
LSP_SERVER.update_poco_completion_for_file(LSP_SERVER.workspace.get_text_document(uri))
return uri
def _at(needle: str, occurrence: int = 0) -> lsp.Position:
index = -1
for _ in range(occurrence + 1):
index = SOURCE.index(needle, index + 1)
line = SOURCE.count("\n", 0, index)
return lsp.Position(line=line, character=index - (SOURCE.rfind("\n", 0, index) + 1) + 1)
def test_goto_definition_from_stored_proc(tmp_path):
uri = _document(tmp_path)
for needle, occurrence in (("custom_header", 1), ("custom_header", 2), ("output", 1)):
[location] = goto_definition(
lsp.DefinitionParams(text_document=lsp.TextDocumentIdentifier(uri=uri), position=_at(needle, occurrence))
)
assert location.range.start.line == (0 if needle == "custom_header" else 1)
def test_references_include_stored_procs_but_not_plain_strings(tmp_path):
uri = _document(tmp_path)
found = references(
lsp.ReferenceParams(
text_document=lsp.TextDocumentIdentifier(uri=uri),
position=_at("custom_header"),
context=lsp.ReferenceContext(include_declaration=False),
)
)
# The shared server also holds the files of other tests.
assert [(location.range.start.line, location.range.start.character) for location in found if location.uri == uri] == [(2, 49), (3, 56)]
def test_stored_procs_are_highlighted_only_when_known():
tree = CustomParser().parse(SOURCE)
highlighter = _Highlighter([], {"file": [lsp.CompletionItem(label="custom_header")]})
tree.accept(highlighter, recurse=True)
line = column = 0
functions = []
for token in highlighter.tokens():
column = column + token.offset if token.line == 0 else token.offset
line += token.line
if token.tok_type == "function":
functions.append((line, SOURCE.splitlines()[line][column:column + token.length]))
assert (2, "custom_header") in functions
assert (3, "custom_header") in functions
assert not any(text == "4th5th" for _, text in functions)
assert not any(line in {5, 6} for line, _ in functions)
@@ -0,0 +1,118 @@
import lsprotocol.types as lsp
import pytest
from tools.inlay_hint import InlayHintGenerator
from tools.parser import CustomParser
from tools.tcloo_arguments import method_parameters, method_signature_help
CLASS = """oo::class create MCS {
constructor {name {size 3}} {}
method initValue {i value} {return [self]}
method format {value {precision 7}} {}
method many {first args} {}
method empty {} {}
method internal {} {my initValue 0 10}
}
"""
def signature(source):
prefix, suffix = source.split("|")
position = lsp.Position(line=prefix.count("\n"), character=len(prefix.rsplit("\n", 1)[-1].encode("utf-16-le")) // 2)
return method_signature_help(prefix + suffix, position)
@pytest.mark.parametrize("tail, label, active", [
("set mcs [MCS new test]\n$mcs initValue |", "::MCS initValue i value", 0),
("set mcs [MCS new test]\n$mcs initValue 0 |", "::MCS initValue i value", 1),
("set mcs [MCS new test]\n$mcs format 12 |", "::MCS format value {precision 7}", 1),
("set mcs [MCS new test]\n$mcs many 1 2 3 |", "::MCS many first args", 1),
("set mcs [MCS new test]\n$mcs empty |", "::MCS empty", None),
("MCS create instance test\ninstance initValue 0 |", "::MCS initValue i value", 1),
("set mcs [[MCS new test] initValue 0 0]\n$mcs initValue |", "::MCS initValue i value", 0),
("set mcs [MCS new test]\nputs [$mcs initValue 0 |", "::MCS initValue i value", 1),
("set mcs [MCS new |", "::MCS new name {size 3}", 0),
("MCS create instance |", "::MCS create objectName name {size 3}", 1),
("set mcs [MCS new test]\nputs 😀; $mcs initValue 0 |", "::MCS initValue i value", 1),
])
def test_method_signatures(tail, label, active):
result = signature(CLASS + tail)
assert result is not None
assert result.signatures[0].label == label
assert result.active_parameter == active
def test_my_signature_and_parameter_spans():
result = signature(CLASS.replace("my initValue 0 10", "my initValue 0 |"))
assert result.active_parameter == 1
info = result.signatures[0]
assert [info.label[start:end] for start, end in (p.label for p in info.parameters)] == ["i", "value"]
@pytest.mark.parametrize("tail", [
"$unknown initValue |",
"set mcs [MCS new test]\nset mcs text\n$mcs initValue |",
"set mcs [MCS new test]\n$mcs initValue [unknown |] 3",
"set mcs [MCS new test]\n$mcs initValue|",
])
def test_unknown_receivers_and_nested_commands(tail):
assert signature(CLASS + tail) is None
def test_optional_parameter_with_spaced_default():
parameters = method_parameters('value {description {hello world}} args')
assert [p.name for p in parameters] == ["value", "description", "args"]
assert parameters[1].label == "{description {hello world}}"
assert parameters[-1].variadic
def test_method_inlay_hints_and_existing_preferences():
source = CLASS + "set mcs [MCS new test]\n$mcs initValue 0 10\n$mcs many 1 2 3\n$mcs initValue $i $other"
tree = CustomParser().parse(source)
start = len(CLASS.splitlines()) + 1
requested = lsp.Range(start=lsp.Position(line=start, character=0), end=lsp.Position(line=start + 2, character=100))
generator = InlayHintGenerator(source, {}, requested_range=requested)
hints = generator.generate(tree)
assert [hint.label[0].value for hint in hints] == ["i:", "value:", "first:", "args:", "args:", "value:"]
assert [source.splitlines()[hint.position.line][hint.position.character:] for hint in hints[:2]] == ["0 10", "10"]
assert "::MCS initValue i value" in hints[0].tooltip.value
generator = InlayHintGenerator(source, {}, requested_range=requested, parameter_names="literals")
assert len(generator.generate(tree)) == 5
generator = InlayHintGenerator(source, {}, parameter_names="none")
assert generator.generate(tree) == []
def test_lsp_signature_help_with_unfinished_bracket(tmp_path, monkeypatch):
import lsp_server
from test_completion_context import _completion_server
server, document, _ = _completion_server(tmp_path, monkeypatch)
source = CLASS + "set mcs [MCS new test]\nputs [$mcs initValue 0 "
document = server.workspace.get_text_document(document.uri)
document._source = source
document.version = 2
result = lsp_server.signature_help(lsp.SignatureHelpParams(
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
position=lsp.Position(line=len(source.splitlines()) - 1, character=len(source.splitlines()[-1])),
))
assert result.active_parameter == 1
assert result.signatures[0].label == "::MCS initValue i value"
def test_cached_tree_gives_same_signature_without_reparsing(monkeypatch):
import tools.tcloo_completion as tcloo
source = CLASS + "set mcs [MCS new test]\n$mcs initValue 0 "
position = lsp.Position(line=source.count("\n"), character=len(source.rsplit("\n", 1)[-1]))
expected = method_signature_help(source, position)
tree = CustomParser().parse(source)
parse_body = tcloo.parse_completion_source
def parse(text, pos=None):
# Braced class bodies are still parsed on demand, the document is not.
assert text != source, "unexpected full-document parse"
return parse_body(text, pos)
monkeypatch.setattr(tcloo, "parse_completion_source", parse)
assert method_signature_help(source, position, tree=tree) == expected
@@ -0,0 +1,231 @@
import lsprotocol.types as lsp
import pytest
from tools.tcloo_completion import ClassInfo, tcloo_completions
CLASS = """oo::class create MCS {
constructor {} {my initValue 0 0}
method initValue {i value} {return [self]}
method initOrg {x y z} {return [my initValue 0 $x]}
method toLst {} {return {1 2 3}}
method _private {} {return [self]}
}
"""
def complete(source):
offset = source.index("|")
before = source[:offset]
position = lsp.Position(line=before.count("\n"), character=len(before.rsplit("\n", 1)[-1].encode("utf-16-le")) // 2)
return tcloo_completions(source.replace("|", "").splitlines(), position)
@pytest.mark.parametrize("code", [
"set mcs [MCS new]\n$mcs |",
"set mcs [::MCS new]\n$mcs |",
"MCS create instance\ninstance |",
"set mcs [MCS create instance]\n$mcs |",
"set mcs [[MCS new] initValue 0 0]\n$mcs |",
"set mcs [[MCS new] initOrg 1 2 3]\n$mcs |",
"set a [MCS new]\nset mcs $a\n$mcs |",
"proc run {} {set mcs [MCS new]; $mcs |}",
"set mcs [MCS new]\nputs [$mcs |]",
"set mcs [MCS new]\nputs [$mcs |",
"[MCS new] |",
])
def test_instances_and_chains(code):
items = complete(CLASS + code)
assert {item.label for item in items} == {"initValue", "initOrg", "toLst", "destroy"}
assert next(item for item in items if item.label == "initValue").detail == "::MCS initValue i value"
def test_my_and_constructor():
for source in [CLASS.replace("my initValue 0 0", "my |"), CLASS.replace("return {1 2 3}", "my |")]:
assert "_private" in {item.label for item in complete(source)}
@pytest.mark.parametrize("code", [
"set mcs [MCS new]\nset mcs text\n$mcs |",
"set mcs [MCS new]\nunset mcs\n$mcs |",
"proc a {} {set mcs [MCS new]}\nproc b {} {$mcs |}",
"set mcs [MCS new]\nproc b {mcs} {$mcs |}",
"set mcs [[MCS new] toLst]\n$mcs |",
"$mcs |\nset mcs [MCS new]",
"set mcs [MCS new]\n$mcs initValue |",
"# my |",
"puts {my |}",
])
def test_unknown_and_non_command_contexts(code):
assert complete(CLASS + code) is None
def test_prefix_and_replacement():
items = complete(CLASS + "set mcs [MCS new]\n$mcs initV|alue")
assert [item.label for item in items] == ["initValue"]
assert items[0].text_edit.new_text == "initValue"
assert items[0].text_edit.range.start.character == 5
assert items[0].text_edit.range.end.character == 14
def test_namespace():
source = "namespace eval geometry {\n" + CLASS + "set mcs [MCS new]\n$mcs |\n}"
assert "initOrg" in {item.label for item in complete(source)}
def test_lsp_space_trigger(tmp_path, monkeypatch):
import lsp_server
from test_completion_context import _completion_server
server, document, _ = _completion_server(tmp_path, monkeypatch)
source = CLASS + "set mcs [MCS new]\n$mcs "
document = server.workspace.get_text_document(document.uri)
document._source = source
document.version = 2
items = lsp_server.on_completion(lsp.CompletionParams(
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
position=lsp.Position(line=len(source.splitlines()) - 1, character=5),
context=lsp.CompletionContext(trigger_kind=lsp.CompletionTriggerKind.TriggerCharacter, trigger_character=" "),
)).items
assert "initOrg" in {item.label for item in items}
@pytest.mark.parametrize("tail", ["set mcs [MC", "set mcs [MC]", "MC"])
def test_class_name_completion_while_editing(tmp_path, monkeypatch, tail):
import lsp_server
from test_completion_context import _completion_server
server, document, _ = _completion_server(tmp_path, monkeypatch)
source = CLASS + tail
document = server.workspace.get_text_document(document.uri)
document._source = source
document.version = 2
items = lsp_server.on_completion(lsp.CompletionParams(
text_document=lsp.TextDocumentIdentifier(uri=document.uri),
position=lsp.Position(line=len(source.splitlines()) - 1,
character=len(tail.rstrip("]"))),
)).items
classes = [item for item in items if item.label == "MCS"]
assert len(classes) == 1
assert classes[0].kind == lsp.CompletionItemKind.Class
def test_classes_are_indexed_with_their_own_kind(tmp_path, monkeypatch):
from test_completion_context import _completion_server, _document
server, _, _ = _completion_server(tmp_path, monkeypatch)
assert server.update_poco_completion_for_file(_document(tmp_path / "class.tcl", CLASS))
items = server.completion_items_snapshot()
assert any(item.label == "MCS" and item.kind == lsp.CompletionItemKind.Class for item in items)
assert "MCS" not in server.custom_function_names_snapshot()
def test_class_command_completions():
assert {item.label for item in complete(CLASS + "set mcs [MCS |]")} == {"new", "create"}
def test_semantic_class_declarations_and_uses():
from tools.parser import CustomParser
from tools.semantic_tokens import _Highlighter, TokenModifier
source = CLASS + 'set mcs [MCS new]\nset second [::MCS new]\nputs "MCS"\n'
tree = CustomParser().parse(source)
highlighter = _Highlighter([], {"MCS"})
highlighter.highlight_classes(tree)
tree.accept(highlighter, recurse=True)
line = col = 0
classified = []
for token in highlighter.tokens():
col = col + token.offset if token.line == 0 else token.offset
line += token.line
text = source.splitlines()[line][col:col + token.length]
classified.append((line, col, text, token.tok_type, token.tok_modifiers))
classes = [entry for entry in classified if entry[3] == "class"]
assert [entry[2] for entry in classes] == ["MCS", "MCS", "::MCS"]
assert TokenModifier.declaration in classes[0][4]
assert not any(entry[2] in {"MCS", "::MCS"} and entry[3] == "function" for entry in classified)
assert len({entry[:2] for entry in classified}) == len(classified)
def test_namespaced_class_symbols_and_method_body_references():
from tools.parser import CustomParser
from tools.tcloo_symbols import class_completion_items, class_symbols
tree = CustomParser().parse('''namespace eval geometry {
oo::class create MCS {
method duplicate {} {return [MCS new]}
}
set mcs [MCS new]
}
set mcs [geometry::MCS new]
set mcs [::geometry::MCS new]
''')
declarations, references = class_symbols(tree)
assert set(declarations) == {"::geometry::MCS"}
assert [node.contents for node in references] == ["MCS", "MCS", "geometry::MCS", "::geometry::MCS"]
assert class_completion_items(tree)[0].label == "geometry::MCS"
@pytest.mark.parametrize("code", [
"set mcs [MCS new]\nputs |",
"set mcs [MCS new]\n se|",
"set mcs [MCS new]\nMOM_output_literal |",
])
def test_non_receivers_skip_the_completion_reparse(code, monkeypatch):
import tools.tcloo_completion as tcloo
def fail(*_args, **_kwargs):
raise AssertionError("unexpected full-document parse")
monkeypatch.setattr(tcloo, "parse_completion_source", fail)
assert complete(CLASS + code) is None
def test_documents_without_classes_skip_the_completion_reparse(monkeypatch):
import tools.tcloo_completion as tcloo
monkeypatch.setattr(tcloo, "parse_completion_source", lambda *_: pytest.fail("parsed"))
assert complete("set value [expr 1]\n$value |") is None
def test_external_class_receiver_still_completes():
source = "Logger |"
position = lsp.Position(line=0, character=len(source) - 1)
items = tcloo_completions([source.replace("|", "")], position, {"::Logger": ClassInfo()})
assert {item.label for item in items} == {"new", "create"}
def complete_with_tree(source):
from tclint.lexer import TclSyntaxError
from tools.parser import CustomParser
offset = source.index("|")
before = source[:offset]
position = lsp.Position(line=before.count("\n"), character=len(before.rsplit("\n", 1)[-1].encode("utf-16-le")) // 2)
text = source.replace("|", "")
try:
tree = CustomParser().parse(text)
except TclSyntaxError:
tree = None
return tcloo_completions(text.splitlines(), position, current_tree=lambda: tree)
@pytest.mark.parametrize("code", [
"set mcs [MCS new]\n$mcs |",
"MCS create instance\ninstance |",
"set mcs [[MCS new] initOrg 1 2 3]\n$mcs |",
"proc run {} {set mcs [MCS new]; $mcs |}",
"set mcs [MCS new]\nputs [$mcs |]",
"set mcs [MCS new]\nputs [$mcs |",
"[MCS new] |",
"set mcs [MCS new]\n$mcs initV|alue",
])
def test_cached_tree_keeps_method_completions(code):
assert "initValue" in {item.label for item in complete_with_tree(CLASS + code)}
def test_cached_tree_skips_reparse_outside_first_argument(monkeypatch):
import tools.tcloo_completion as tcloo
monkeypatch.setattr(tcloo, "parse_completion_source", lambda *_: pytest.fail("parsed"))
assert complete_with_tree(CLASS + "set mcs [MCS new]\nif {$mcs ne {}} |{ puts 1 }") is None
@@ -0,0 +1,56 @@
import lsprotocol.types as lsp
from tools.parser import CustomParser
from tools.semantic_tokens import _Highlighter, TOKEN_TYPE_INDEX, TokenModifier
def test_methods_use_proc_colors_without_coloring_plain_arguments():
source = '''oo::class create MCS {
method initOrg {x y z} {return [self]}
method reset {} {my initOrg 0 0 0}
}
set obj [MCS new]
$obj initOrg 1 2 3
puts initOrg
# initOrg
'''
tree = CustomParser().parse(source)
highlighter = _Highlighter([], {})
highlighter.highlight_classes(tree)
highlighter.highlight_methods(tree, source, "file:///test.tcl")
tree.accept(highlighter, recurse=True)
line = column = 0
tokens = []
for token in highlighter.tokens():
column = column + token.offset if token.line == 0 else token.offset
line += token.line
text = source.splitlines()[line][column:column + token.length]
tokens.append((line, column, text, token.tok_type, token.tok_modifiers))
methods = [token for token in tokens if token[2] in {"initOrg", "reset"}]
assert [token[2] for token in methods] == ["initOrg", "reset", "initOrg", "initOrg"]
assert all(token[3] == "function" for token in methods)
assert TokenModifier.declaration in methods[0][4]
assert TokenModifier.declaration in methods[1][4]
assert all(token[3] == "class" for token in tokens if token[2] == "MCS")
assert len({token[:2] for token in tokens}) == len(tokens)
def test_psc_method_calls_are_function_tokens(tmp_path, monkeypatch):
import lsp_server
from test_psc_classes import setup_project, caller
server, root, _, _ = setup_project(tmp_path, monkeypatch)
server.refresh_psc_scripts([root])
source = "set obj [MCS new]\n$obj initOrg 1 2 3"
uri, _ = caller(server, root, source)
data = lsp_server.semantic_tokens(lsp.SemanticTokensParams(
text_document=lsp.TextDocumentIdentifier(uri=uri))).data
line = column = 0
tokens = {}
for index in range(0, len(data), 5):
delta, offset, length, kind, _ = data[index:index + 5]
column = column + offset if delta == 0 else offset
line += delta
tokens[(line, source.splitlines()[line][column:column + length])] = kind
assert tokens[(1, "initOrg")] == TOKEN_TYPE_INDEX["function"]
assert tokens[(0, "MCS")] == TOKEN_TYPE_INDEX["class"]
@@ -0,0 +1,94 @@
import lsprotocol.types as lsp
import pytest
from pygls import uris
from tools.tcloo_navigation import tcloo_definition
CLASS = '''oo::class create MCS {
constructor {value} {}
method initOrg {dx dy dz} {return [self]}
method toStr {} {my initOrg 1 2 3}
}
'''
def locate(source, classes=None):
prefix, suffix = source.split("|")
return tcloo_definition(prefix + suffix, "file:///caller.tcl", lsp.Position(
line=prefix.count("\n"),
character=len(prefix.rsplit("\n", 1)[-1].encode("utf-16-le")) // 2), classes)
def target_text(location, source):
assert location is not None
span = location.range
line = source.splitlines()[span.start.line].encode("utf-16-le")
return line[span.start.character * 2:span.end.character * 2].decode("utf-16-le")
@pytest.mark.parametrize("code, expected", [
("set mcs [M|CS new 0]", "MCS"),
("set mcs [::M|CS new 0]", "MCS"),
("set mcs [MCS n|ew 0]", "constructor"),
("MCS cr|eate instance 0", "constructor"),
("set mcs [MCS new 0]\n$mcs init|Org 1 2 3", "initOrg"),
("MCS create instance 0\ninstance to|Str", "toStr"),
("set mcs [[MCS new 0] initOrg 1 2 3]\n$mcs to|Str", "toStr"),
("set mcs [MCS new 0]\nputs [$mcs init|Org", "initOrg"),
("puts 😀; set mcs [M|CS new 0]", "MCS"),
])
def test_local_class_method_and_constructor_targets(code, expected):
source = CLASS + code
target = locate(source)
assert target.uri == "file:///caller.tcl"
assert target_text(target, source.replace("|", "")) == expected
def test_my_and_method_declaration():
for source in [CLASS.replace("my initOrg", "my init|Org"), CLASS.replace("method initOrg", "method init|Org")]:
assert target_text(locate(source), CLASS) == "initOrg"
@pytest.mark.parametrize("code", [
"set mcs [MCS new 0]\nset mcs text\n$mcs init|Org 1 2 3",
"$unknown init|Org 1 2 3",
"puts {M|CS}",
"# M|CS",
"set mcs [MCS new 0]\n$mcs initOrg to|Str 2 3",
])
def test_no_guessing_for_unknown_receivers_or_plain_text(code):
assert locate(CLASS + code) is None
def test_same_method_name_resolves_to_correct_class():
source = CLASS + "oo::class create Other {method initOrg {} {}}\nset obj [Other new]\n$obj init|Org"
target = locate(source)
assert target.range.start.line == 5
def test_namespaced_class_and_utf16_definition():
source = 'namespace eval geo {\nputs 😀; ' + CLASS + '\nset obj [MCS new 0]\n$obj init|Org 1 2 3\n}'
assert target_text(locate(source), source.replace("|", "")) == "initOrg"
source = 'puts 😀; ' + CLASS + '\nset obj [M|CS new 0]'
target = locate(source)
assert target_text(target, source.replace("|", "")) == "MCS"
def test_psc_definition_navigation_uses_library_uri(tmp_path, monkeypatch):
import lsp_server
from test_psc_classes import setup_project, caller, CLASS as LIBRARY_SOURCE
server, root, _, script = setup_project(tmp_path, monkeypatch)
server.refresh_psc_scripts([root])
for word, source in [
("MCS", "set obj [MCS new]"),
("initOrg", "set obj [MCS new]\n$obj initOrg 1 2 3"),
]:
uri, _ = caller(server, root, source)
lines = source.splitlines()
position = lsp.Position(line=len(lines) - 1, character=lines[-1].index(word) + 1)
result = lsp_server.goto_definition(lsp.DefinitionParams(text_document=lsp.TextDocumentIdentifier(uri=uri), position=position))
assert result is not None and len(result) == 1
assert server.paths_equal(script, uris.to_fs_path(result[0].uri))
assert target_text(result[0], LIBRARY_SOURCE) == word
+14 -52
View File
@@ -2,27 +2,13 @@
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "CDL Support", "name": "CDL Support",
"patterns": [ "patterns": [
{ { "include": "#comment" },
"include": "#comment" { "include": "#function" },
}, { "include": "#variables" },
{ { "include": "#types" },
"include": "#function" { "include": "#keywords" },
}, { "include": "#numbers" },
{ { "include": "#strings" }
"include": "#variables"
},
{
"include": "#types"
},
{
"include": "#keywords"
},
{
"include": "#numbers"
},
{
"include": "#strings"
}
], ],
"repository": { "repository": {
"keywords": { "keywords": {
@@ -70,11 +56,7 @@
{ {
"name": "keyword.control.cdl", "name": "keyword.control.cdl",
"match": "TYPE\\s+(o|g|i|d|b|s)\\b", "match": "TYPE\\s+(o|g|i|d|b|s)\\b",
"captures": { "captures": { "1": { "name": "storage.type.cs" } }
"1": {
"name": "storage.type.cs"
}
}
} }
] ]
}, },
@@ -83,38 +65,22 @@
{ {
"name": "keyword.control.cdl", "name": "keyword.control.cdl",
"match": "\\b(PARAM|EVENT)\\s+([a-zA-Z_]\\w*)\\b", "match": "\\b(PARAM|EVENT)\\s+([a-zA-Z_]\\w*)\\b",
"captures": { "captures": { "2": { "name": "variable.other.cdl" } }
"2": {
"name": "variable.other.cdl"
}
}
}, },
{ {
"name": "keyword.control.cdl", "name": "keyword.control.cdl",
"match": "\\bMACHINE\\s+([a-zA-Z_]\\w*)\\b", "match": "\\bMACHINE\\s+([a-zA-Z_]\\w*)\\b",
"captures": { "captures": { "1": { "name": "variable.other.cdl" } }
"1": {
"name": "variable.other.cdl"
}
}
}, },
{ {
"name": "keyword.control.cdl", "name": "keyword.control.cdl",
"match": "\\bCATEGORY\\s+((MILL|LATHE|DRILL)(\\s+(MILL|LATHE|DRILL))*)\\b", "match": "\\bCATEGORY\\s+((MILL|LATHE|DRILL|INVALID)(\\s+(MILL|LATHE|DRILL|INVALID))*)\\b",
"captures": { "captures": { "1": { "name": "variable.language.cdl" } }
"1": {
"name": "variable.language.cdl"
}
}
}, },
{ {
"name": "keyword.control.cdl", "name": "keyword.control.cdl",
"match": "\\bTOGGLE\\s+(OFF|ON|off|on)\\b", "match": "\\bTOGGLE\\s+(OFF|ON|off|on)\\b",
"captures": { "captures": { "1": { "name": "variable.other.constant" } }
"1": {
"name": "variable.other.constant"
}
}
} }
] ]
}, },
@@ -123,11 +89,7 @@
{ {
"name": "storage.type.function.cdl", "name": "storage.type.function.cdl",
"match": "\\bEVENT\\s+([a-zA-Z_]\\w*)\\b", "match": "\\bEVENT\\s+([a-zA-Z_]\\w*)\\b",
"captures": { "captures": { "1": { "name": "entity.name.function" } }
"1": {
"name": "entity.name.function"
}
}
} }
] ]
}, },
+51
View File
@@ -0,0 +1,51 @@
const assert = require("node:assert/strict")
const fs = require("node:fs")
const path = require("node:path")
const vm = require("node:vm")
const { test } = require("node:test")
const { transformSync } = require("esbuild")
const source = fs.readFileSync(path.join(__dirname, "../client/src/common/cdlEventHandler.ts"), "utf8")
const compiled = transformSync(source, { loader: "ts", format: "cjs" }).code
const context = { module: { exports: {} } }
vm.runInNewContext(compiled, context)
const { cdlEventHandlerAtLine, createCdlEventHandlerSnippet } = context.module.exports
test("TOGGLE Off adds the defined globals to the event handler", () => {
const cdl = `EVENT GDM_header
{
PARAM product_status {
TYPE o
OPTIONS "Serie", "Prototyp"
}
PARAM stm_param_mpf_name
{
TYPE s
TOGGLE Off
}
PARAM stm_param_wks_path {
TYPE s
TOGGLE Off
}
}`
const snippet = createCdlEventHandlerSnippet(cdlEventHandlerAtLine(cdl, 0))
assert.match(snippet, /global mom_stm_param_mpf_name_defined/)
assert.match(snippet, /global mom_stm_param_wks_path_defined/)
assert.match(snippet, /global mom_product_status\n/)
assert.doesNotMatch(snippet, /mom_product_status_defined/)
})
test("toggle detection ignores comments, strings, other events, and TOGGLE On", () => {
const cdl = `EVENT first {
PARAM plain { TYPE s UI_LABEL "TOGGLE Off { ignored }" }
PARAM enabled { TOGGLE On }
PARAM commented { TYPE s # TOGGLE Off
}
PARAM mom_disabled { TOGGLE off }
}
EVENT second { PARAM other { TOGGLE Off } }`
const snippet = createCdlEventHandlerSnippet(cdlEventHandlerAtLine(cdl, 0))
assert.match(snippet, /global mom_disabled_defined/)
assert.equal((snippet.match(/_defined/g) ?? []).length, 1)
assert.doesNotMatch(snippet, /mom_mom_|mom_other/)
})
+322 -38
View File
@@ -6,24 +6,24 @@ set te875st 11111
#set ::custom_flag(from_move,$::mom_path_name) 1 #set ::custom_flag(from_move,$::mom_path_name) 1
if {$main == 1 && 1 == 1} { if {$main == 1 && 1 == 1} {
puts "main" puts "main"
} }
proc test {} { proc test {} {
puts "main" puts "main"
proc llll {} {} proc llll {} {}
set rrrrrrr set rrrrrrr
} }
LIB_GE_command_buffer_edit_insert MOM_tool_change_LIB TOOL_CHANGE_AUTO {CUSTOM_after_tool_change_call} mytag after @TOOL_CHANGE_AUTO LIB_GE_command_buffer_edit_insert MOM_tool_change_LIB TOOL_CHANGE_AUTO {CUSTOM_after_tool_change_call} mytag after @TOOL_CHANGE_AUTO
MOM_abort MOM_abort
namespace eval myns { namespace eval myns {
proc add {a b} { proc add {a b} {
set sum [expr {$a + $b}] set sum [expr {$a + $b}]
return $sum return $sum
} }
set config "debug" set config "debug"
} }
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
@@ -31,7 +31,7 @@ namespace eval myns {
# Function to output a spacer line or empty line # Function to output a spacer line or empty line
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
proc SERVICE_spacer_output {type {length 20} {line_num 0} {output 1}} { proc SERVICE_spacer_output {type {length 20} {line_num 0} {output 1}} {
LIB_GE_message [string repeat $type $length] "output_$output" $line_num LIB_GE_message [string repeat $type $length] "output_$output" $line_num
} }
@@ -42,8 +42,8 @@ SERVICE_spacer_output "*" 2 0 0
# Function to delete the file # Function to delete the file
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
proc SERVICE_remove_file {file} { proc SERVICE_remove_file {file} {
if {![SERVICE_check_file_exists $file]} {return} if {![SERVICE_check_file_exists $file]} {return}
MOM_remove_file $file MOM_remove_file $file
} }
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
@@ -51,8 +51,8 @@ proc SERVICE_remove_file {file} {
# Function to check if the file exists # Function to check if the file exists
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
proc SERVICE_check_file_exists {file} { proc SERVICE_check_file_exists {file} {
if {[file exists $file]} {return 1} if {[file exists $file]} {return 1}
return 0 return 0
} }
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
@@ -60,19 +60,19 @@ proc SERVICE_check_file_exists {file} {
# Ask UDE Info for the Tool # Ask UDE Info for the Tool
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
proc SERVICE_ask_ude_tool {pos ude_name tool_name} { proc SERVICE_ask_ude_tool {pos ude_name tool_name} {
MOM_ask_ude_info $tool_name "tool" $pos MOM_ask_ude_info $tool_name "tool" $pos
if {[lsearch $::mom_result $ude_name] != -1} { if {[lsearch $::mom_result $ude_name] != -1} {
return 1 return 1
} }
return 0 return 0
} }
proc MOM_dummy_event_start {} { proc MOM_dummy_event_start {} {
global mom_new_item global mom_new_item
global mom_new_item_end global mom_new_item_end
#Put your UDE Handler Tcl here #Put your UDE Handler Tcl here
} }
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
@@ -80,12 +80,12 @@ proc MOM_dummy_event_start {} {
# Ask UDE Info for the Operation # Ask UDE Info for the Operation
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
proc SERVICE_ask_ude_operation {pos ude_name path_name} { proc SERVICE_ask_ude_operation {pos ude_name path_name} {
MOM_ask_ude_info $path_name "operation" $pos MOM_ask_ude_info $path_name "operation" $pos
if {[lsearch $::mom_result $ude_name] != -1} { if {[lsearch $::mom_result $ude_name] != -1} {
return 1 return 1
} }
return 0 return 0
} }
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
@@ -96,7 +96,7 @@ proc SERVICE_ask_ude_operation {pos ude_name path_name} {
# restore # restore
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
proc SERVICE_output_handling {handler} { proc SERVICE_output_handling {handler} {
set ::lib_ge(hidden_output) $handler set ::lib_ge(hidden_output) $handler
} }
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
@@ -105,21 +105,305 @@ proc SERVICE_output_handling {handler} {
# this function is called in start of program # this function is called in start of program
#_________________________________________________________________________________________________ #_________________________________________________________________________________________________
proc SERVICE_get_tool_data {} { proc SERVICE_get_tool_data {} {
global mom_tool_data global mom_tool_data
global mom_operation_info global mom_operation_info
set mom_tool_data(toollist) "" set mom_tool_data(toollist) ""
set operations $::mom_operation_name_list set operations $::mom_operation_name_list
foreach operation $operations { foreach operation $operations {
if {[lsearch -exact $mom_tool_data(toollist) $mom_operation_info($operation,tool_name)] == -1} { if {[lsearch -exact $mom_tool_data(toollist) $mom_operation_info($operation,tool_name)] == -1} {
lappend mom_tool_data(toollist) $mom_operation_info($operation,tool_name) lappend mom_tool_data(toollist) $mom_operation_info($operation,tool_name)
} }
} }
} }
LIB_GE_command_buffer_edit_replace MOM_end_of_program_LIB END_OF_PROGRAM @END_OF_PROG { LIB_GE_command_buffer_edit_replace MOM_end_of_program_LIB END_OF_PROGRAM @END_OF_PROG {
MOM_do_template "end_of_program_rewind" MOM_do_template "end_of_program_rewind"
} EndOfProgramRewind } EndOfProgramRewind
SERVICE_remove_file "test" SERVICE_remove_file "test"
oo::class create MCS {
variable values
constructor {args} {
for {set i 0} {$i <= 11} {incr i} {
my initValue $i 0.
}
}
destructor {
#puts "[self] is now deleted"
}
method fformat {value {precision 7}} {return [expr {round( 10.0 ** $precision * $value) / (10.0 ** $precision)}]}
method radtodeg {rad} {return [expr {$rad*180.0/(4.0*atan(1.0))}]}
method degtorad {deg} {return [expr {$deg*(4.0*atan(1.0))/180.0}]}
method duplicate {args} {return [[MCS new] initMCS [self]]}
method type {args} {return "::MCS"}
method toArray {args} {
for {set i 0} {$i < [array size values]} {incr i} {
append arr [string cat $i " "] ; append arr [string cat $values($i) " "]
}
return [string trimright $arr]
}
method toLst {args} {
for {set i 0} {$i < [array size values]} {incr i} {
lappend lst $values($i)
}
return $lst
}
method toStr {args} {
for {set i 0} {$i < [array size values]} {incr i} {
if {$i>0} {set str [string cat $str ", "]}
append str $values($i)
}
return $str
}
method toStrF {{precision 7}} {
for {set i 0} {$i < [array size values]} {incr i} {
if {$i>0} {set str [string cat $str ", "]}
append str [my fformat $values($i) 10]
}
return $str
}
method initValue {i value} {set values($i) [expr double($value)] ; return [self]}
method initArray {a} {upvar $a arr ; for {set i 0} {$i < [array size arr]} {incr i} {my initValue $i $arr($i)} ; return [self]}
method initArrayLst {arr_lst} {array set arr $arr_lst ; for {set i 0} {$i < [array size arr]} {incr i} {my initValue $i $arr($i)} ; return [self]}
method initLst {lst} {for {set i 0} {$i < [llength $lst]} {incr i} {my initValue $i [lindex $lst $i]} ; return [self]}
method initMCS {mcs} {
my identity
set p [$mcs org] ; my initOrgP $p ; $p destroy
set v [$mcs xVct] ; my initXvctV $v ; $v destroy
set v [$mcs yVct] ; my initYvctV $v ; $v destroy
set v [$mcs zVct] ; my initZvctV $v ; $v destroy
return [self]
}
method initMTX {mtx} {
set p [$mtx org] ; my initOrg $p ; $p destroy
set v [$mtx xVct] ; my initXvct $v ; $v destroy
set v [$mtx yVct] ; my initYvct $v ; $v destroy
set v [$mtx zVct] ; my initZvct $v ; $v destroy
return [self]
}
method initOrg {dx dy dz} {my initValue 0 $dx ; my initValue 1 $dy ; my initValue 2 $dz ; return [self]}
method initOrgP {p} {return [my initOrg [$p x] [$p y] [$p z]]}
method initXvct {dx dy dz} {my initValue 3 $dx ; my initValue 4 $dy ; my initValue 5 $dz ; return [self]}
method initXvctV {v} {return [my initXvct [$v x] [$v y] [$v z]]}
method initYvct {dx dy dz} {my initValue 6 $dx ; my initValue 7 $dy ; my initValue 8 $dz ; return [self]}
method initYvctV {v} {return [my initYvct [$v x] [$v y] [$v z]]}
method initZvct {dx dy dz} {my initValue 9 $dx ; my initValue 10 $dy ; my initValue 11 $dz ; return [self]}
method initZvctV {v} {return [my initZvct [$v x] [$v y] [$v z]]}
method initOrgArray {a} {upvar $a arr ; return [my initOrg $arr(0) $arr(1) $arr(2)]}
method initVctsArray {a} {
upvar $a arr
my initXvct $arr(0) $arr(1) $arr(2)
my initYvct $arr(3) $arr(4) $arr(5)
my initZvct $arr(6) $arr(7) $arr(8)
return [self]
}
method value {i} {return $values($i)}
method org {args} {return [Point3D new $values(0) $values(1) $values(2)]}
method xVct {args} {return [Vector3D new $values(3) $values(4) $values(5)]}
method yVct {args} {return [Vector3D new $values(6) $values(7) $values(8)]}
method zVct {args} {return [Vector3D new $values(9) $values(10) $values(11)]}
method same {mcs {precision 7}} {
set org [[self] org] ; set org_1 [$mcs org]
set vx [[self] xVct] ; set vx_1 [$mcs xVct]
set vy [[self] yVct] ; set vy_1 [$mcs yVct]
set vz [[self] zVct] ; set vz_1 [$mcs zVct]
if {[$org same $org_1 $precision] && [$vx same $vx_1 $precision] && [$vy same $vy_1 $precision] && [$vz same $vz_1 $precision]} {
set value 1
} else {
set value 0
}
$org destroy ; $org_1 destroy
$vx destroy ; $vx_1 destroy
$vy destroy ; $vy_1 destroy
$vz destroy ; $vz_1 destroy
return $value
}
}
set point [Point3D new]
$point add 1 2 3
$custom_flag(from_move,$::mom_path_name)
oo::class create Point3D {
variable x 0. ; variable y 0. ; variable z 0.
constructor {{dx 0.} {dy 0.} {dz 0.}} {my initX $dx ; my initY $dy ; my initZ $dz}
destructor {
#puts "[self] is now deleted"
}
method fformat {value {precision 7}} {return [expr {round( 10.0 ** $precision * $value) / (10.0 ** $precision)}]}
method radtodeg {rad} {return [expr {$rad*180.0/(4.0*atan(1.0))}]}
method degtorad {deg} {return [expr {$deg*(4.0*atan(1.0))/180.0}]}
method duplicate {args} {return [[Point3D new] initP [self]]}
method type {args} {return "::Point3D"}
method toArray {{i 0}} {return [list [incr i 0] $x [incr i] $y [incr i] $z]}
method toLst {args} {return [list $x $y $z]}
method toStr {args} {return "$x, $y, $z"}
method toStrF {{precision 7}} {return "[my fformat $x $precision], [my fformat $y $precision], [my fformat $z $precision]"}
method x {args} {return $x}
method y {args} {return $y}
method z {args} {return $z}
method initX {dx} {set x [expr double($dx)]}
method initY {dy} {set y [expr double($dy)]}
method initZ {dz} {set z [expr double($dz)]}
method init {dx dy dz} {my initX $dx ; my initY $dy ; my initZ $dz ; return [self]}
method initArray {a} {upvar $a arr ; return [my init $arr(0) $arr(1) $arr(2)]}
method initArrayLst {arr_lst} {array set arr $arr_lst ; return [my init $arr(0) $arr(1) $arr(2)]}
method initLst {lst} {return [my init [lindex $lst 0] [lindex $lst 1] [lindex $lst 2]]}
method initP {p} {return [my init [$p x] [$p y] [$p z]]}
method reset {args} {my init 0. 0. 0. ; return [self]}
method add {dx dy dz} {my init [expr {$x + $dx}] [expr {$y + $dy}] [expr {$z + $dz}] ; return [self]}
method addArray {a} {upvar $a arr ; return [my add $arr(0) $arr(1) $arr(2)]}
method addArrayLst {arr_lst} {array set arr $arr_lst ; return [my add $arr(0) $arr(1) $arr(2)]}
method addLst {lst} {return [my add [lindex $lst 0] [lindex $lst 1] [lindex $lst 2]]}
method addP {p} {return [my add [$p x] [$p y] [$p z]]}
method sub {dx dy dz} {my init [expr {$x - $dx}] [expr {$y - $dy}] [expr {$z - $dz}] ; return [self]}
method subArray {a} {upvar $a arr ; return [my sub $arr(0) $arr(1) $arr(2)]}
method subArrayLst {arr_lst} {array set arr $arr_lst ; return [my sub $arr(0) $arr(1) $arr(2)]}
method subLst {lst} {return [my sub [lindex $lst 0] [lindex $lst 1] [lindex $lst 2]]}
method subP {p} {return [my sub [$p x] [$p y] [$p z]]}
method dist {dx dy dz} {return [expr {sqrt([expr {$x - $dx}]**2 + [expr {$y - $dy}]**2 + [expr {$z - $dz}]**2)}]}
method distArray {a} {upvar $a arr ; return [my dist $arr(0) $arr(1) $arr(2)]}
method distArrayLst {arr_lst} {array set arr $arr_lst ; return [my dist $arr(0) $arr(1) $arr(2)]}
method distLst {lst} {return [my dist [lindex $lst 0] [lindex $lst 1] [lindex $lst 2]]}
method distP {p} {return [my dist [$p x] [$p y] [$p z]]}
method dist_to_line {pl1 pl2} {
set pl [[$pl2 duplicate] subP $pl1]
set pp [[$pl1 duplicate] subP [self]]
set vl [[Vector3D new] initV $pl]
set vp [[Vector3D new] initV $pp]
set l [$vl magnitude]
if {[my fformat $l 5]==0} {
set value 0.
} else {
set vc [$vl cross $vp]
set value [expr [$vc magnitude]/$l]
$vc destroy
}
$pl destroy ; $vl destroy
$pp destroy ; $vp destroy
return $value
}
method dist_to_lineV {v} {
set p1 [[Point3D new] init 0. 0. 0.]
set p2 [[Point3D new] initP $v]
set value [my dist_to_line $p1 $p2]
$p1 destroy
$p2 destroy
return $value
}
#midpoint method to calculate the midpoint between this point and another point. This can be done by averaging the x, y, and z coordinates of the two points.
method midpoint {dx dy dz} {return [Point3D new [expr {double([[self] x] + $dx)/2}] [expr {double([[self] y] + $dy)/2}] [expr {double([[self] z] + $dz)/2}]]}
method midpointArr {a} {upvar $a arr ; return [my midpoint $arr(0) $arr(1) $arr(2)]}
method midpointArrLst {arr} {array set arr $arr_lst ; return [my midpoint $arr(0) $arr(1) $arr(2)]}
method midpointLst {lst} {return [my midpoint [lindex $lst 0] [lindex $lst 1] [lindex $lst 2]]}
method midpointP {p} {return [my midpoint [$p x] [$p y] [$p z]]}
method same {p {precision 7}} {
if {[my fformat [expr {[[self] x] - [$p x]}] $precision] == 0. && [my fformat [expr {[[self] y] - [$p y]}] $precision] == 0. && [my fformat [expr {[[self] z] - [$p z]}] $precision] == 0.} {return 1} else {return 0}
}
#This method takes three arguments: a vector object "axis" as the axis of rotation, an angle of rotation, and a point object "center" as the center of rotation.
#First, it translates the point by subtracting the center of rotation,
#then it applies the Rodrigues' rotation formula to calculate the new coordinates of the point, and finally,
#it translates back the point by adding the center of rotation.
#The center parameter is defined as an optional parameter with a default value of a new Point3D object, initialized with the values (0,0,0).
method rotAround {u v w angle {x0 0.} {y0 0.} {z0 0.}} {
set x1 [expr {double($x) - $x0}] ; set y1 [expr {double($y) - $y0}] ; set z1 [expr {double($z) - $z0}]
set u [expr {double($u)}] ; set v [expr {double($v)}] ; set w [expr {double($w)}]
set a [my degtorad $angle]
set coss [expr {cos($a)}]
set sinn [expr {sin($a)}]
set x2 [expr {$u*($u*$x1 + $v*$y1 + $w*$z1)*(1 - $coss) + $x1*$coss + (-$w*$y1 + $v*$z1)*$sinn + $x0}]
set y2 [expr {$v*($u*$x1 + $v*$y1 + $w*$z1)*(1 - $coss) + $y1*$coss + ($w*$x1 - $u*$z1)*$sinn + $y0}]
set z2 [expr {$w*($u*$x1 + $v*$y1 + $w*$z1)*(1 - $coss) + $z1*$coss + (-$v*$x1 + $u*$y1)*$sinn + $z0}]
my init $x2 $y2 $z2
return [self]
}
method rotAroundLst {aLst angle {cLst {0. 0. 0.}}} {
return [my rotAround [lindex $aLst 0] [lindex $aLst 1] [lindex $aLst 2] $angle [lindex $cLst 0] [lindex $cLst 1] [lindex $cLst 2]]
}
method rotAroundVP {axis angle {center {}}} {
if {[catch {set s [$center type]}] || (![string equal [$center type] "::Point3D"] && ![string equal [$center type] "::Vector3D"])} {
set center [Point3D new]
set b_center true
}
my rotAround [$axis x] [$axis y] [$axis z] $angle [$center x] [$center y] [$center z]
if {[info exists b_center]} {$center destroy}
return [self]
}
method transform {mtx_from mtx_to} {
if {[catch {set s [$mtx_from type]}] || ![string equal [$mtx_from type] "::Matrix4x4"]} {
set mtx_from [[Matrix4x4 new] identity]
set b_mtx_from true
}
if {[catch {set s [$mtx_to type]}] || ![string equal [$mtx_to type] "::Matrix4x4"]} {
set mtx_to [[Matrix4x4 new] identity]
set b_mtx_to true
}
set m [[Matrix4x4 new] identity]
$m initValue 0 [my x]
$m initValue 4 [my y]
$m initValue 8 [my z]
$m initValue 12 1.
set trs [[[[$mtx_from duplicate] inverse] multiply $mtx_to false] inverse]
$m multiply $trs true
my init [$m value 0] [$m value 4] [$m value 8]
$m destroy
$trs destroy
if {[info exists b_mtx_from]} {$mtx_from destroy}
if {[info exists b_mtx_to]} {$mtx_to destroy}
return [self]
}
}
set v1 [MCS new]
$v1 initArray