Compare commits
25
Commits
2026.9.700
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6edd2d3a2e | ||
|
|
2bc9122d79 | ||
|
|
6fb849b7c0 | ||
|
|
94bffa1f35 | ||
|
|
c8466d2e32 | ||
|
|
e9af7b4bb5 | ||
|
|
e539d78902 | ||
|
|
525a08c289 | ||
|
|
82951a3911 | ||
|
|
dde96506dd | ||
|
|
3f9091c713 | ||
|
|
5b027a2717 | ||
|
|
17fb79a346 | ||
|
|
0a8e8a7368 | ||
|
|
bdeb0d946d | ||
|
|
bd9c73452e | ||
|
|
a393ca3aec | ||
|
|
85a8684254 | ||
|
|
0fe03c20f7 | ||
|
|
513b1cb340 | ||
|
|
1d36b3bb77 | ||
|
|
12e6d27331 | ||
|
|
e5c1ba1908 | ||
|
|
81fe58e023 | ||
|
|
4c1837c557 |
@@ -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
|
||||
+303
-38
@@ -1,56 +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
|
||||
|
||||
- Reparse only the top-level TCL commands touched by an edit instead of the whole file
|
||||
- Cache the workspace index in the extension storage so restarts skip reparsing unchanged files; the cache is discarded automatically when the server or bundled tclint changes
|
||||
### Added
|
||||
|
||||
- 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
|
||||
|
||||
### Changed
|
||||
|
||||
- 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
|
||||
|
||||
### Documentation
|
||||
|
||||
- Document DEF block templates, addresses, `BLOCK_LIST`/`ADDR_LIST`, and the formatting changes in the README
|
||||
|
||||
## [2026.9.700] - 2026-09-24
|
||||
|
||||
### Added
|
||||
|
||||
- `BLOCK_LIST` and `ADDR_LIST` completion keywords that list all loaded block templates or addresses; the keyword is replaced by the selected quoted name
|
||||
|
||||
### Changed
|
||||
|
||||
- Format comments with a space after `#` (`#Comment` becomes `# Comment`); `##` separators, `#!`, and comments that already start with whitespace stay unchanged
|
||||
|
||||
## [2026.9.600] - 2026-09-24
|
||||
|
||||
### 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
|
||||
- Add Go to Definition for TclOO classes, constructors, and resolved methods, including PSC library definitions
|
||||
- Index PSC layer scripts (including external paths and legacy Windows encoding) and share TclOO class metadata across files for completion, signatures, inlay hints, and highlighting
|
||||
- Add document-local TclOO method completion for `new`/`create` instances, `my`, and statically inferred return chains
|
||||
|
||||
### 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
|
||||
- Show signature help and parameter inlay hints for resolved TclOO methods and constructors, including optional and variadic arguments
|
||||
- Add incoming and outgoing call hierarchy for custom TCL procedures and MOM event handlers
|
||||
- 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
|
||||
- Keep completion working while the file has syntax errors
|
||||
|
||||
## [0.0.1]
|
||||
## [2026.9.300] - 2026-09-10
|
||||
|
||||
- Initial release
|
||||
### Added
|
||||
|
||||
## [0.0.2]
|
||||
- Array key completion, including keys built with substitutions
|
||||
- Generated CDL event handler snippets declare `args`
|
||||
|
||||
- Add Autocomp for TYPE
|
||||
## [2026.9.220] - 2026-09-08
|
||||
|
||||
## [0.1.0]
|
||||
### Fixed
|
||||
|
||||
- Add Hover Feature
|
||||
- Completion inside unfinished braced arguments now recognizes nested commands
|
||||
|
||||
## [0.2.0]
|
||||
## [2026.9.210] - 2026-09-04
|
||||
|
||||
- Add DEF File Support
|
||||
### Added
|
||||
|
||||
## [2026.6.100]
|
||||
- Highlight escaped quoted strings and embedded variables in DEF files
|
||||
|
||||
- Fix several bugs
|
||||
## [2026.9.200] - 2026-09-03
|
||||
|
||||
## [2026.6.200]
|
||||
### Added
|
||||
|
||||
- Fix foramtting bug
|
||||
- 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
|
||||
|
||||
@@ -14,6 +14,7 @@ A comprehensive VS Code extension providing language support and remote debuggin
|
||||
- **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
|
||||
- **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`
|
||||
- **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
|
||||
|
||||
@@ -48,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
|
||||
`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
|
||||
|
||||
### Add a VS Code attach configuration
|
||||
@@ -98,13 +145,15 @@ Simply open any supported file type and enjoy:
|
||||
- Syntax highlighting
|
||||
- Error detection and linting
|
||||
- Code completion
|
||||
- Code formatting (Format Document command)
|
||||
- Code formatting (Format Document command), including `uplevel` bodies and a space after `#` in comments
|
||||
- Hover information
|
||||
- Signature help while entering procedure arguments
|
||||
- Incoming and outgoing call hierarchy for custom procedures and MOM event handlers
|
||||
- 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`
|
||||
- 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
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
defDocumentSymbolProvider,
|
||||
definitionCdlEventHandler
|
||||
} from "./common/handlers"
|
||||
import { registerDefProviders } from "./common/defProviders"
|
||||
import { registerLogger, traceError, traceLog, traceVerbose } from "./common/log/logging"
|
||||
import {
|
||||
checkVersion,
|
||||
@@ -234,6 +235,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
)
|
||||
|
||||
context.subscriptions.push(formatDefProvider)
|
||||
context.subscriptions.push(...registerDefProviders(() => client))
|
||||
|
||||
const cdlSymbolProvider = vscode.languages.registerDocumentSymbolProvider(
|
||||
{ scheme: "file", language: "cdl" },
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "nx-post-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",
|
||||
"version": "2026.9.600",
|
||||
"version": "2026.9.900",
|
||||
"publisher": "Christoph",
|
||||
"icon": "images/nx-1.png",
|
||||
"activationEvents": [
|
||||
|
||||
+182
-5
@@ -52,6 +52,16 @@ from tools.completion_items import (
|
||||
completion_context,
|
||||
ranked_completion_items,
|
||||
)
|
||||
from tools.def_navigation import (
|
||||
all_def_target_locations,
|
||||
def_declarations,
|
||||
def_hover_markdown,
|
||||
effective_def_locations,
|
||||
def_rename_edits,
|
||||
def_symbol_at,
|
||||
)
|
||||
from tools.def_flow import derived_def_symbol
|
||||
from tools.def_symbols import parse_def_document
|
||||
from tools.folding_ranges import build_folding_ranges
|
||||
from tools.index_cache import IndexCache
|
||||
from tools.inlay_hint import (
|
||||
@@ -59,6 +69,7 @@ from tools.inlay_hint import (
|
||||
build_builtin_inlay_signatures,
|
||||
)
|
||||
from tools.navigation import (
|
||||
DEF_SYMBOL_KINDS,
|
||||
SymbolIdentity,
|
||||
call_hierarchy_identity,
|
||||
call_hierarchy_items,
|
||||
@@ -277,7 +288,7 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams):
|
||||
|
||||
@LSP_SERVER.feature(
|
||||
lsp.TEXT_DOCUMENT_COMPLETION,
|
||||
lsp.CompletionOptions(trigger_characters=["$", " ", "-", "(", ","]),
|
||||
lsp.CompletionOptions(trigger_characters=["$", " ", "-", "(", ","], resolve_provider=True),
|
||||
)
|
||||
def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
||||
result = _on_completion(params)
|
||||
@@ -290,11 +301,23 @@ def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList:
|
||||
return result
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.COMPLETION_ITEM_RESOLVE)
|
||||
def on_completion_resolve(item: lsp.CompletionItem) -> lsp.CompletionItem:
|
||||
"""Show the .def declaration of a selected block template/address, as on hover."""
|
||||
target = item.data.get("def") if isinstance(item.data, dict) else None
|
||||
if item.documentation is None and isinstance(target, list) and len(target) == 2:
|
||||
markdown = def_hover_markdown(LSP_SERVER.def_documents_snapshot(), tuple(target))
|
||||
if markdown:
|
||||
item.documentation = lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=markdown)
|
||||
return item
|
||||
|
||||
|
||||
# Keywords that expand to all loaded .def names: keyword -> (items, description).
|
||||
SYMBOL_LIST_KEYWORDS = {
|
||||
"BLOCK_LIST": (lambda: LSP_SERVER.block_template_items(), "block templates"),
|
||||
"ADDR_LIST": (lambda: LSP_SERVER.address_items(), "addresses"),
|
||||
}
|
||||
DEF_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
||||
_WORD_BEFORE_CURSOR_RE = re.compile(r"(?<![$\w])[A-Za-z_]+$")
|
||||
|
||||
|
||||
@@ -709,6 +732,10 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
|
||||
else:
|
||||
return None
|
||||
|
||||
def_hover = _tcl_def_hover(document_uri, pos, token)
|
||||
if def_hover is not None:
|
||||
return def_hover
|
||||
|
||||
# 1) If token is a known MOM proc/variable, return built-in hover
|
||||
match = BUILTIN_HOVER_ITEMS.get(token)
|
||||
if match and match.get("kind") == "function":
|
||||
@@ -748,6 +775,59 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
|
||||
return None
|
||||
|
||||
|
||||
def _def_target(identity: SymbolIdentity) -> tuple[str, str] | None:
|
||||
return (identity.kind, identity.name) if identity.kind in DEF_SYMBOL_KINDS else None
|
||||
|
||||
|
||||
def _word_at(uri: str, position: lsp.Position) -> str | None:
|
||||
document = LSP_SERVER.workspace.get_text_document(uri)
|
||||
try:
|
||||
line = LSP_SERVER.get_lines(document)[position.line]
|
||||
except IndexError:
|
||||
return None
|
||||
for match in re.finditer(r"\b\w+\b", line):
|
||||
if match.start() <= position.character <= match.end():
|
||||
return match.group(0)
|
||||
return None
|
||||
|
||||
|
||||
def _tcl_def_symbol(uri: str, position: lsp.Position, token: str | None):
|
||||
"""The .def target at ``position``: a direct NX command argument or a derived name.
|
||||
|
||||
Returns (documents, target, range) or None.
|
||||
"""
|
||||
documents = LSP_SERVER.def_documents_snapshot()
|
||||
# Cheap guard: only names declared in a .def file are analyzed at all.
|
||||
kinds = {kind for kind in DEF_SYMBOL_KINDS if token and any(token in document.names(kind) for document in documents.values())}
|
||||
if not kinds:
|
||||
return None
|
||||
context = _navigation_context(uri, position)
|
||||
target = context and _def_target(context[3])
|
||||
if target:
|
||||
return documents, target, context[2].range
|
||||
try:
|
||||
tree = LSP_SERVER.get_tree(LSP_SERVER.workspace.get_text_document(uri))
|
||||
except TclSyntaxError:
|
||||
return None
|
||||
derived = derived_def_symbol(tree, position, LSP_SERVER.def_wrapper_table())
|
||||
if derived is None or derived[1] != token:
|
||||
return None
|
||||
found, name, range_ = derived
|
||||
kind = next((kind for kind in sorted(found & kinds) if def_declarations(documents, (kind, name))), None)
|
||||
return (documents, (kind, name), range_) if kind else None
|
||||
|
||||
|
||||
def _tcl_def_hover(uri: str, position: lsp.Position, token: str) -> lsp.Hover | None:
|
||||
symbol = _tcl_def_symbol(uri, position, token)
|
||||
if symbol is None:
|
||||
return None
|
||||
documents, target, range_ = symbol
|
||||
markdown = def_hover_markdown(documents, target)
|
||||
if not markdown:
|
||||
return None
|
||||
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=markdown), range=range_)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION)
|
||||
def goto_definition(params: lsp.DefinitionParams):
|
||||
"""Resolve TclOO declarations, then indexed proc and variable definitions."""
|
||||
@@ -767,9 +847,13 @@ def goto_definition(params: lsp.DefinitionParams):
|
||||
return [target]
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
if context is None:
|
||||
return None
|
||||
symbol = _tcl_def_symbol(params.text_document.uri, params.position, _word_at(params.text_document.uri, params.position))
|
||||
return symbol and effective_def_locations(symbol[0], symbol[1]) or None
|
||||
|
||||
indexes, definitions, _, identity = context
|
||||
target = _def_target(identity)
|
||||
if target is not None:
|
||||
return effective_def_locations(LSP_SERVER.def_documents_snapshot(), target) or None
|
||||
locations = [lsp.Location(uri=index.uri, range=occurrence.range) for index, occurrence in matching_occurrences(identity, indexes, definitions) if occurrence.is_definition]
|
||||
return _sorted_locations(locations) or None
|
||||
|
||||
@@ -808,9 +892,18 @@ def _sorted_locations(locations: list[lsp.Location]) -> list[lsp.Location]:
|
||||
def references(params: lsp.ReferenceParams) -> list[lsp.Location]:
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
if context is None:
|
||||
return []
|
||||
symbol = _tcl_def_symbol(params.text_document.uri, params.position, _word_at(params.text_document.uri, params.position))
|
||||
if symbol is None:
|
||||
return []
|
||||
indexes, _ = LSP_SERVER.navigation_state()
|
||||
return all_def_target_locations(symbol[0], indexes, symbol[1], params.context.include_declaration)
|
||||
|
||||
indexes, definitions, _, identity = context
|
||||
target = _def_target(identity)
|
||||
if target is not None:
|
||||
return all_def_target_locations(
|
||||
LSP_SERVER.def_documents_snapshot(), indexes, target, params.context.include_declaration
|
||||
)
|
||||
locations = [
|
||||
lsp.Location(uri=index.uri, range=occurrence.range)
|
||||
for index, occurrence in matching_occurrences(identity, indexes, definitions)
|
||||
@@ -858,7 +951,11 @@ def prepare_rename(params: lsp.PrepareRenameParams):
|
||||
return None
|
||||
|
||||
indexes, definitions, occurrence, identity = context
|
||||
if not _is_renamable(identity, indexes, definitions):
|
||||
target = _def_target(identity)
|
||||
if target is not None:
|
||||
if not def_declarations(LSP_SERVER.def_documents_snapshot(), target):
|
||||
return None
|
||||
elif not _is_renamable(identity, indexes, definitions):
|
||||
return None
|
||||
return lsp.PrepareRenamePlaceholder(range=occurrence.range, placeholder=occurrence.placeholder)
|
||||
|
||||
@@ -868,7 +965,7 @@ def prepare_rename(params: lsp.PrepareRenameParams):
|
||||
lsp.RenameOptions(prepare_provider=True),
|
||||
)
|
||||
def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None:
|
||||
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", params.new_name):
|
||||
if not DEF_NAME_RE.fullmatch(params.new_name):
|
||||
return None
|
||||
|
||||
context = _navigation_context(params.text_document.uri, params.position)
|
||||
@@ -876,6 +973,9 @@ def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None:
|
||||
return None
|
||||
|
||||
indexes, definitions, _, identity = context
|
||||
target = _def_target(identity)
|
||||
if target is not None:
|
||||
return def_rename_edits(LSP_SERVER.def_documents_snapshot(), indexes, target, params.new_name)
|
||||
if not _is_renamable(identity, indexes, definitions):
|
||||
return None
|
||||
|
||||
@@ -905,6 +1005,83 @@ def rename(params: lsp.RenameParams) -> lsp.WorkspaceEdit | None:
|
||||
return lsp.WorkspaceEdit(changes=changes)
|
||||
|
||||
|
||||
# .def files are not synchronized with the server; the client sends their
|
||||
# current text with each request.
|
||||
DEF_REQUEST_DEFINITION = "nxPostSupport/def/definition"
|
||||
DEF_REQUEST_HOVER = "nxPostSupport/def/hover"
|
||||
DEF_REQUEST_REFERENCES = "nxPostSupport/def/references"
|
||||
DEF_REQUEST_PREPARE_RENAME = "nxPostSupport/def/prepareRename"
|
||||
DEF_REQUEST_RENAME = "nxPostSupport/def/rename"
|
||||
|
||||
|
||||
def _def_request_context(params):
|
||||
uri = params.textDocument.uri
|
||||
position = lsp.Position(line=params.position.line, character=params.position.character)
|
||||
path = uris.to_fs_path(uri)
|
||||
documents = LSP_SERVER.def_documents_snapshot(path, params.text)
|
||||
symbol = def_symbol_at(parse_def_document(params.text), position)
|
||||
if symbol is None:
|
||||
return None
|
||||
target, range_, _ = symbol
|
||||
current = next(key for key in documents if LSP_SERVER.paths_equal(key, path))
|
||||
return documents, {current: uri}, target, range_
|
||||
|
||||
|
||||
@LSP_SERVER.feature(DEF_REQUEST_DEFINITION)
|
||||
def def_definition(params) -> list[lsp.Location] | None:
|
||||
context = _def_request_context(params)
|
||||
if context is None:
|
||||
return None
|
||||
documents, def_uris, target, _ = context
|
||||
return effective_def_locations(documents, target, def_uris) or None
|
||||
|
||||
|
||||
@LSP_SERVER.feature(DEF_REQUEST_HOVER)
|
||||
def def_hover(params) -> lsp.Hover | None:
|
||||
context = _def_request_context(params)
|
||||
if context is None:
|
||||
return None
|
||||
documents, _, target, range_ = context
|
||||
markdown = def_hover_markdown(documents, target)
|
||||
if markdown is None:
|
||||
return None
|
||||
return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=markdown), range=range_)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(DEF_REQUEST_REFERENCES)
|
||||
def def_references(params) -> list[lsp.Location]:
|
||||
context = _def_request_context(params)
|
||||
if context is None:
|
||||
return []
|
||||
documents, def_uris, target, _ = context
|
||||
include_declaration = bool(getattr(params, "includeDeclaration", True))
|
||||
indexes, _ = LSP_SERVER.navigation_state()
|
||||
return all_def_target_locations(documents, indexes, target, include_declaration, def_uris)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(DEF_REQUEST_PREPARE_RENAME)
|
||||
def def_prepare_rename(params):
|
||||
context = _def_request_context(params)
|
||||
if context is None:
|
||||
return None
|
||||
documents, _, target, range_ = context
|
||||
if not def_declarations(documents, target):
|
||||
return None
|
||||
return lsp.PrepareRenamePlaceholder(range=range_, placeholder=target[1])
|
||||
|
||||
|
||||
@LSP_SERVER.feature(DEF_REQUEST_RENAME)
|
||||
def def_rename(params) -> lsp.WorkspaceEdit | None:
|
||||
if not DEF_NAME_RE.fullmatch(params.newName):
|
||||
return None
|
||||
context = _def_request_context(params)
|
||||
if context is None:
|
||||
return None
|
||||
documents, def_uris, target, _ = context
|
||||
indexes, _ = LSP_SERVER.navigation_state()
|
||||
return def_rename_edits(documents, indexes, target, params.newName, def_uris)
|
||||
|
||||
|
||||
@LSP_SERVER.feature(lsp.WORKSPACE_SYMBOL)
|
||||
def workspace_symbol(params: lsp.WorkspaceSymbolParams):
|
||||
return workspace_symbols(LSP_SERVER.navigation_snapshot(), params.query)
|
||||
|
||||
+68
-14
@@ -18,7 +18,8 @@ from tools import checks, incremental_parse, parser
|
||||
from tools.completion_items import CompletionCollector
|
||||
from tools.tcloo_symbols import class_completion_items
|
||||
from tools.tcloo_completion import indexed_classes
|
||||
from tools.def_symbols import DefSymbols, read_def_symbols
|
||||
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.index_cache import FileStat, IndexCache, file_stat
|
||||
@@ -62,8 +63,8 @@ class TclLanguageServer(LanguageServer):
|
||||
self.psc_script_paths: list[str] = []
|
||||
self._psc_files: dict[str, list[pathlib.Path]] = {}
|
||||
self._psc_lock = threading.RLock()
|
||||
# .def path -> BLOCK_TEMPLATE/ADDRESS names, in PSC DefinedEvents order.
|
||||
self.def_symbols: dict[str, DefSymbols] = {}
|
||||
# .def path -> parsed declarations, in PSC DefinedEvents order.
|
||||
self.def_documents: dict[str, DefDocument] = {}
|
||||
self.navigation_indexes: dict[str, FileSymbolIndex] = {}
|
||||
self.variable_indexes: dict[
|
||||
str,
|
||||
@@ -97,6 +98,7 @@ class TclLanguageServer(LanguageServer):
|
||||
self._definition_identities_cache: tuple[
|
||||
int, frozenset[SymbolIdentity]
|
||||
] = (-1, frozenset())
|
||||
self._def_wrapper_cache: tuple[int, WrapperTable] = (-1, {})
|
||||
self._proc_metadata_cache: dict[
|
||||
str, tuple[int, dict[str, list[str]], dict[str, str]]
|
||||
] = {}
|
||||
@@ -213,6 +215,7 @@ class TclLanguageServer(LanguageServer):
|
||||
self._workspace_completion_cache = (-1, ())
|
||||
self._custom_function_names_cache = (-1, frozenset())
|
||||
self._definition_identities_cache = (-1, frozenset())
|
||||
self._def_wrapper_cache = (-1, {})
|
||||
self._proc_metadata_cache.clear()
|
||||
self._custom_inlay_cache.clear()
|
||||
|
||||
@@ -257,7 +260,7 @@ class TclLanguageServer(LanguageServer):
|
||||
|
||||
def refresh_def_symbols(self, roots, report=LOGGER.warning):
|
||||
"""Read the block templates and addresses of all .def files listed as PSC DefinedEvents."""
|
||||
symbols: dict[str, DefSymbols] = {}
|
||||
documents: dict[str, DefDocument] = {}
|
||||
for root in roots:
|
||||
for psc in get_all_psc_files(root):
|
||||
try:
|
||||
@@ -266,33 +269,54 @@ class TclLanguageServer(LanguageServer):
|
||||
report(f"Could not read PSC {psc}: {error}")
|
||||
continue
|
||||
for def_file in def_files:
|
||||
if str(def_file) in symbols:
|
||||
if str(def_file) in documents:
|
||||
continue
|
||||
try:
|
||||
symbols[str(def_file)] = read_def_symbols(def_file)
|
||||
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:
|
||||
self.def_symbols = symbols
|
||||
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 _def_symbol_items(self, attribute: str, kind, description: str) -> list[lsp.CompletionItem]:
|
||||
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:
|
||||
symbols = dict(self.def_symbols)
|
||||
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=kind,
|
||||
kind=item_kind,
|
||||
detail=f"{description} ({pathlib.Path(path).name})",
|
||||
data={"def": [kind, name]},
|
||||
)
|
||||
for path, def_symbols in symbols.items()
|
||||
for name in getattr(def_symbols, attribute)
|
||||
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_templates", lsp.CompletionItemKind.Struct, "Block template")
|
||||
return self._def_symbol_items(BLOCK_TEMPLATE, lsp.CompletionItemKind.Struct, "Block template")
|
||||
|
||||
def address_items(self) -> list[lsp.CompletionItem]:
|
||||
return self._def_symbol_items("addresses", lsp.CompletionItemKind.Field, "Address")
|
||||
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."""
|
||||
@@ -622,6 +646,17 @@ class TclLanguageServer(LanguageServer):
|
||||
)
|
||||
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:
|
||||
with self._index_lock:
|
||||
indexed_version = self._index_versions.get(filepath)
|
||||
@@ -876,8 +911,27 @@ class TclLanguageServer(LanguageServer):
|
||||
)
|
||||
)
|
||||
|
||||
diagnostics.extend(self._def_diagnostics(document))
|
||||
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]:
|
||||
return self.lint(document)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+156
-12
@@ -1,11 +1,53 @@
|
||||
"""Block templates and addresses declared in NX post definition (.def) files."""
|
||||
"""Block templates, addresses and formats declared in NX post definition (.def) files."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_BLOCK_TEMPLATE_RE = re.compile(r"^\s*BLOCK_TEMPLATE\s+([^\s{]+)", re.MULTILINE)
|
||||
_ADDRESS_RE = re.compile(r"^\s*ADDRESS\s+([^\s{]+)", re.MULTILINE)
|
||||
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)
|
||||
@@ -14,23 +56,125 @@ class DefSymbols:
|
||||
addresses: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _names(pattern: re.Pattern, source: str) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(pattern.findall(source)))
|
||||
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=_names(_BLOCK_TEMPLATE_RE, source),
|
||||
addresses=_names(_ADDRESS_RE, source),
|
||||
block_templates=document.names(BLOCK_TEMPLATE),
|
||||
addresses=document.names(ADDRESS),
|
||||
)
|
||||
|
||||
|
||||
def read_def_symbols(path: Path) -> DefSymbols:
|
||||
def read_def_source(path: Path) -> str:
|
||||
data = path.read_bytes()
|
||||
try:
|
||||
source = data.decode("utf-8-sig")
|
||||
return data.decode("utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
# Older Windows NX layers use the ANSI code page.
|
||||
source = data.decode("cp1252")
|
||||
return parse_def_symbols(source)
|
||||
return data.decode("cp1252")
|
||||
|
||||
|
||||
def read_def_symbols(path: Path) -> DefSymbols:
|
||||
return parse_def_symbols(read_def_source(path))
|
||||
|
||||
@@ -5,6 +5,17 @@ from pathlib import Path
|
||||
|
||||
import lsprotocol.types as lsp
|
||||
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
|
||||
|
||||
ROOT_NAMESPACE = "::"
|
||||
@@ -39,6 +50,8 @@ class FileSymbolIndex:
|
||||
uri: str
|
||||
occurrences: tuple[SymbolOccurrence, ...]
|
||||
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)
|
||||
@@ -268,6 +281,9 @@ def build_file_symbol_index(
|
||||
# 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)
|
||||
@@ -279,6 +295,7 @@ def build_file_symbol_index(
|
||||
*,
|
||||
is_definition: bool,
|
||||
declaration_range: lsp.Range | None = None,
|
||||
name_range: lsp.Range | None = None,
|
||||
) -> None:
|
||||
identity = shared(_proc_identity(raw_name, scope.namespace))
|
||||
caller = None
|
||||
@@ -296,7 +313,7 @@ def build_file_symbol_index(
|
||||
if is_definition
|
||||
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),
|
||||
is_definition=is_definition,
|
||||
symbol_kind=lsp.SymbolKind.Function,
|
||||
@@ -384,11 +401,13 @@ def build_file_symbol_index(
|
||||
)
|
||||
|
||||
parameters = command.args[1]
|
||||
parameter_names = []
|
||||
for parameter in getattr(parameters, "children", []):
|
||||
parameter_node = parameter
|
||||
if isinstance(parameter, List) and parameter.children:
|
||||
parameter_node = parameter.children[0]
|
||||
parameter_name = _static_contents(parameter_node)
|
||||
parameter_names.append(parameter_name or "")
|
||||
if parameter_name:
|
||||
add_variable(
|
||||
parameter_node,
|
||||
@@ -397,7 +416,12 @@ def build_file_symbol_index(
|
||||
is_definition=True,
|
||||
)
|
||||
|
||||
flow_facts.append([])
|
||||
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:
|
||||
if len(command.args) < 3 or _static_contents(command.args[0]) != "eval":
|
||||
@@ -435,6 +459,37 @@ def build_file_symbol_index(
|
||||
|
||||
if routine:
|
||||
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_ids = {id(node) for node in declaration_nodes}
|
||||
@@ -490,6 +545,7 @@ def build_file_symbol_index(
|
||||
uri=uri,
|
||||
occurrences=tuple(occurrences),
|
||||
document_range=_node_range(tree),
|
||||
def_flows=tuple(def_flows),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import attrs
|
||||
from common.load_data import standard_items
|
||||
from tclint.commands.plugins import PluginManager
|
||||
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.tcloo_symbols import class_symbols
|
||||
from tools.tcloo_completion import _analyze
|
||||
@@ -182,6 +183,11 @@ class _Highlighter(Visitor):
|
||||
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
|
||||
name = getattr(routine, "contents", None)
|
||||
if name:
|
||||
|
||||
@@ -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
|
||||
@@ -430,6 +430,7 @@ VALUES_BY_POSITION: dict[tuple[tuple[str, ...], int], tuple[str, ...]] = {
|
||||
(("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"),
|
||||
(("open",), 2): ("r", "r+", "w", "w+", "a", "a+"),
|
||||
(("package", "prefer"), 2): ("latest", "stable"),
|
||||
@@ -475,11 +476,14 @@ DYNAMIC_COMPLETION_RULES = (
|
||||
DynamicCompletionRule(("rename",), frozenset({1}), DynamicCompletionKind.PROCEDURE),
|
||||
# Namespace-taking commands.
|
||||
DynamicCompletionRule(("MOM_do_template",), frozenset({1}), DynamicCompletionKind.BLOCK_TEMPLATE),
|
||||
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(
|
||||
("namespace", "delete"),
|
||||
|
||||
@@ -47,20 +47,9 @@ def _document(path: Path, source: str) -> TextDocument:
|
||||
)
|
||||
|
||||
|
||||
def _completion_server(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> tuple[TclLanguageServer, TextDocument, str]:
|
||||
def _completion_server(tmp_path: Path, monkeypatch) -> tuple[TclLanguageServer, TextDocument, str]:
|
||||
declared_builtin = standard_items.nx_variables[0].label
|
||||
current_source = (
|
||||
"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"
|
||||
)
|
||||
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"
|
||||
workspace_source = """set ::workspaceValue 1
|
||||
proc workspaceProc {} { return }
|
||||
"""
|
||||
@@ -113,14 +102,10 @@ def test_unset_space_shows_options_then_variables(tmp_path, monkeypatch):
|
||||
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", "--"}
|
||||
assert labels == {"nocomplain"}
|
||||
else:
|
||||
assert "globalValue" in labels
|
||||
assert "-nocomplain" not in labels
|
||||
if tail == "unset -nocomplain ":
|
||||
assert "--" in labels
|
||||
else:
|
||||
assert "--" not in labels
|
||||
|
||||
|
||||
def test_array_keys_complete_in_set_and_substitution(tmp_path: Path, monkeypatch):
|
||||
@@ -130,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",
|
||||
)
|
||||
assert server.update_poco_completion_for_file(workspace)
|
||||
source = (
|
||||
"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"
|
||||
)
|
||||
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"
|
||||
current = _document(tmp_path / "arrays-current.tcl", source)
|
||||
server.workspace.put_text_document(lsp.TextDocumentItem(
|
||||
uri=current.uri, language_id="tcl", version=1, text=source,
|
||||
))
|
||||
server.workspace.put_text_document(
|
||||
lsp.TextDocumentItem(
|
||||
uri=current.uri,
|
||||
language_id="tcl",
|
||||
version=1,
|
||||
text=source,
|
||||
)
|
||||
)
|
||||
assert server.update_poco_completion_for_file(current)
|
||||
items = _complete(current, _position_after(source, "set lib_flag(", 3))
|
||||
assert [item.label for item in items] == ["empty", "enabled", "external"]
|
||||
@@ -179,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
|
||||
path = tmp_path / "dynamic.tcl"
|
||||
index = build_file_symbol_index(str(path), path.as_uri(), tree)
|
||||
definition = next(
|
||||
item for item in index.occurrences
|
||||
if item.identity.name == "::custom_flag" and item.is_definition
|
||||
)
|
||||
definition = next(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.end.character == 15
|
||||
assert definition.array_element is None
|
||||
assert any(item.identity.name == "::mom_path_name" for item in index.occurrences)
|
||||
highlighter = _Highlighter([], {})
|
||||
tree.accept(highlighter, recurse=True)
|
||||
assert any(
|
||||
position == (0, 4) and length == 11 and kind == "variable"
|
||||
for position, length, kind, _ in highlighter._tokens
|
||||
)
|
||||
assert any(position == (0, 4) and length == 11 and kind == "variable" for position, length, kind, _ in highlighter._tokens)
|
||||
server, _, _ = _completion_server(tmp_path, monkeypatch)
|
||||
current = _document(path, source)
|
||||
server.workspace.put_text_document(lsp.TextDocumentItem(
|
||||
uri=current.uri, language_id="tcl", version=1, text=source,
|
||||
))
|
||||
server.workspace.put_text_document(
|
||||
lsp.TextDocumentItem(
|
||||
uri=current.uri,
|
||||
language_id="tcl",
|
||||
version=1,
|
||||
text=source,
|
||||
)
|
||||
)
|
||||
assert server.update_poco_completion_for_file(current)
|
||||
items = _complete(current, _position_after(source, "puts $custom"))
|
||||
assert "custom_flag" in {item.label for item in items}
|
||||
workspace_items = next(
|
||||
items for item_path, items in server.completion_items_by_file_snapshot().items()
|
||||
if server.paths_equal(item_path, str(path))
|
||||
)
|
||||
workspace_items = next(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}
|
||||
|
||||
|
||||
@@ -236,18 +214,25 @@ def test_literal_array_components_complete_around_substitutions(tmp_path: Path,
|
||||
offset = marked.index("|")
|
||||
line = marked.replace("|", "")
|
||||
items = array_element_completions(
|
||||
[line], lsp.Position(line=0, character=offset),
|
||||
server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"),
|
||||
[line],
|
||||
lsp.Position(line=0, character=offset),
|
||||
server.navigation_snapshot().values(),
|
||||
str(tmp_path / "caller.tcl"),
|
||||
)
|
||||
assert {item.label for item in items} == labels
|
||||
edit = next(item.text_edit 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 line[:edit.range.start.character] + edit.new_text + line[edit.range.end.character:] == expected
|
||||
assert array_element_completions(
|
||||
["set custom_flag(from_move,$::mom"], lsp.Position(line=0, character=31),
|
||||
server.navigation_snapshot().values(), str(tmp_path / "caller.tcl"),
|
||||
) is None
|
||||
assert line[: edit.range.start.character] + edit.new_text + line[edit.range.end.character :] == expected
|
||||
assert (
|
||||
array_element_completions(
|
||||
["set custom_flag(from_move,$::mom"],
|
||||
lsp.Position(line=0, character=31),
|
||||
server.navigation_snapshot().values(),
|
||||
str(tmp_path / "caller.tcl"),
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def _argument_completion_request(source: str):
|
||||
@@ -277,9 +262,7 @@ def test_variable_completion_filters_and_ranks_candidates(tmp_path: Path, monkey
|
||||
other_builtin = standard_items.nx_variables[1]
|
||||
assert declared_builtin.label in by_label
|
||||
assert other_builtin.label in by_label
|
||||
assert by_label[declared_builtin.label].documentation == (
|
||||
declared_builtin.documentation
|
||||
)
|
||||
assert by_label[declared_builtin.label].documentation == (declared_builtin.documentation)
|
||||
assert by_label["localValue"].sort_text.startswith("000:")
|
||||
assert by_label["globalValue"].sort_text.startswith("100:")
|
||||
assert by_label["workspaceValue"].sort_text.startswith("200:")
|
||||
@@ -307,18 +290,9 @@ def test_command_completion_filters_and_ranks_candidates(tmp_path: Path, monkeyp
|
||||
|
||||
|
||||
def test_completion_context_handles_nested_commands_and_utf16():
|
||||
assert (
|
||||
completion_context(["set result [work"], lsp.Position(line=0, character=16))
|
||||
== CompletionContext.COMMAND
|
||||
)
|
||||
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
|
||||
)
|
||||
assert completion_context(["set result [work"], lsp.Position(line=0, character=16)) == CompletionContext.COMMAND
|
||||
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():
|
||||
@@ -330,9 +304,7 @@ def test_string_subcommands_and_compare_options_are_context_aware():
|
||||
"-length",
|
||||
"-nocase",
|
||||
}
|
||||
assert _argument_completion_labels("string compare -nocase ") == {
|
||||
"-length"
|
||||
}
|
||||
assert _argument_completion_labels("string compare -nocase ") == {"-length"}
|
||||
assert _argument_completion_labels("string compare -length ") is None
|
||||
|
||||
|
||||
@@ -356,22 +328,14 @@ def test_string_completion_inside_braced_conditions_and_bodies():
|
||||
subcommands = _argument_completion_labels(prefix + "[string ")
|
||||
assert subcommands is not None
|
||||
assert {"compare", "equal", "is"} <= subcommands
|
||||
assert _argument_completion_labels(prefix + "[string compare -") == {
|
||||
"-length", "-nocase"
|
||||
}
|
||||
assert _argument_completion_labels(
|
||||
prefix + "[string compare -nocase "
|
||||
) == {"-length"}
|
||||
assert _argument_completion_labels(prefix + "[string compare -") == {"-length", "-nocase"}
|
||||
assert _argument_completion_labels(prefix + "[string compare -nocase ") == {"-length"}
|
||||
|
||||
|
||||
def test_closed_braced_arguments_do_not_change_completion_context():
|
||||
assert _argument_completion_labels("puts {[string compare }") is None
|
||||
assert _argument_completion_labels(
|
||||
"if {[string equal a b]} {string compare "
|
||||
) == {"-length", "-nocase"}
|
||||
assert _argument_completion_labels(
|
||||
"if {[string equal a b] && [string is integer "
|
||||
) == {"-failindex", "-strict"}
|
||||
assert _argument_completion_labels("if {[string equal a b]} {string compare ") == {"-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():
|
||||
@@ -413,9 +377,7 @@ def test_dict_array_namespace_file_and_info_subcommands():
|
||||
assert {"args", "body", "commands", "exists", "procs", "vars"} <= info_items
|
||||
|
||||
|
||||
def test_variable_context_still_takes_priority_inside_tcl_command(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
def test_variable_context_still_takes_priority_inside_tcl_command(tmp_path: Path, monkeypatch):
|
||||
_, current, source = _completion_server(tmp_path, monkeypatch)
|
||||
command_source = source.replace(
|
||||
" puts $local\n",
|
||||
@@ -437,9 +399,7 @@ def test_variable_context_still_takes_priority_inside_tcl_command(
|
||||
assert "localValue" in {item.label for item in items}
|
||||
|
||||
|
||||
def test_lsp_completion_returns_only_matching_command_options(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
def test_lsp_completion_returns_only_matching_command_options(tmp_path: Path, monkeypatch):
|
||||
_, current, _ = _completion_server(tmp_path, monkeypatch)
|
||||
source = "string compare "
|
||||
current = _document(tmp_path / "current.tcl", source)
|
||||
@@ -459,9 +419,7 @@ def test_lsp_completion_returns_only_matching_command_options(
|
||||
assert all(item.sort_text.startswith("000:") for item in items)
|
||||
|
||||
|
||||
def test_space_trigger_does_not_open_broad_fallback_completion(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
def test_space_trigger_does_not_open_broad_fallback_completion(tmp_path: Path, monkeypatch):
|
||||
_, current, _ = _completion_server(tmp_path, monkeypatch)
|
||||
source = "set value "
|
||||
current = _document(tmp_path / "current.tcl", source)
|
||||
@@ -550,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 ")
|
||||
|
||||
|
||||
def test_lsp_source_completion_reads_paths_from_document_directory(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
def test_lsp_source_completion_reads_paths_from_document_directory(tmp_path: Path, monkeypatch):
|
||||
server, current, _ = _completion_server(tmp_path, monkeypatch)
|
||||
scripts = tmp_path / "scripts"
|
||||
scripts.mkdir()
|
||||
@@ -576,9 +532,7 @@ def test_lsp_source_completion_reads_paths_from_document_directory(
|
||||
assert "scripts/ignored.txt" not in labels
|
||||
|
||||
|
||||
def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders(tmp_path: Path, monkeypatch):
|
||||
_, current, source = _completion_server(tmp_path, monkeypatch)
|
||||
command_items = _complete(current, _position_after(source, "localP", occurrence=1))
|
||||
command_by_label = {item.label: item for item in command_items}
|
||||
@@ -595,14 +549,10 @@ def test_command_and_dict_for_snippets_use_lsp_snippet_placeholders(
|
||||
|
||||
switch_arguments = _argument_completion_request("switch ")
|
||||
assert switch_arguments is not None
|
||||
assert {"switch block", "-exact", "-glob", "-regexp"} <= {
|
||||
item.label for item in switch_arguments.items
|
||||
}
|
||||
assert {"switch block", "-exact", "-glob", "-regexp"} <= {item.label for item in switch_arguments.items}
|
||||
|
||||
|
||||
def test_semantic_variable_and_procedure_argument_completion(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
def test_semantic_variable_and_procedure_argument_completion(tmp_path: Path, monkeypatch):
|
||||
_, current, source = _completion_server(tmp_path, monkeypatch)
|
||||
|
||||
variable_items = _complete(current, _position_after(source, " set "))
|
||||
@@ -633,9 +583,7 @@ def test_semantic_variable_and_procedure_argument_completion(
|
||||
assert "string" not in procedure_labels
|
||||
|
||||
|
||||
def test_namespace_argument_completion_uses_navigation_index(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
def test_namespace_argument_completion_uses_navigation_index(tmp_path: Path, monkeypatch):
|
||||
server, current, _ = _completion_server(tmp_path, monkeypatch)
|
||||
namespace_source = "namespace eval tools { proc helper {} { return } }\n"
|
||||
namespace_document = _document(tmp_path / "namespaces.tcl", namespace_source)
|
||||
|
||||
@@ -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"}
|
||||
@@ -227,6 +227,25 @@ def test_block_list_shows_all_templates_quoted(tmp_path, monkeypatch):
|
||||
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"):
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user