Skip to content

Commit ae765a8

Browse files
committed
Add qDup Language Server Protocol (LSP) module
Introduce a new qDup-lsp module that provides editor-agnostic IDE support for qDup YAML scripts via the Language Server Protocol. The server offers context-aware completion for commands, modifiers, host config keys, role keys, and script references; diagnostics for unknown keys, undefined references, and unused definitions; and hover documentation sourced from the qDup reference docs. Includes a JBang launcher script for easy startup. Document symbols providing a hierarchical outline of scripts, hosts, roles, and states sections Support go-to-definition for script references, hover, and completion for ${{variable}} patterns in command values. Variables defined under states:/globals: are resolved within the current document and across workspace files.
1 parent 2f94fb7 commit ae765a8

38 files changed

Lines changed: 7421 additions & 0 deletions

pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<modules>
1313
<module>qDup-core</module>
1414
<module>qDup</module>
15+
<module>qDup-lsp</module>
1516
</modules>
1617

1718

qDup-core/src/main/java/io/hyperfoil/tools/qdup/config/yaml/Parser.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,7 @@ public Map<Object, Object> getMap(Object o) {
675675
private MapRepresenter mapRepresenter;
676676
private Map<String, FromString> noArgs;
677677
private Map<Class, CmdMapping> cmdMappings;
678+
private Map<String, List<String>> commandParameters;
678679
private boolean abortOnExitCode;
679680

680681
private Parser() {
@@ -689,6 +690,7 @@ public Object constructObject(Node node){
689690
mapRepresenter = new MapRepresenter();
690691
cmdMappings = new HashMap<>();
691692
noArgs = new HashMap<>();
693+
commandParameters = new HashMap<>();
692694
DumperOptions dumperOptions = new DumperOptions();
693695
dumperOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
694696
dumperOptions.setWidth(1024);
@@ -753,6 +755,34 @@ public void setAbortOnExitCode(boolean abortOnExitCode){
753755
this.abortOnExitCode = abortOnExitCode;
754756
}
755757

758+
/**
759+
* Returns the set of all registered command names (tags).
760+
*/
761+
public Set<String> getCommandNames() {
762+
Set<String> names = new LinkedHashSet<>();
763+
for (CmdMapping mapping : cmdMappings.values()) {
764+
String key = mapping.getKey();
765+
if (key != null && !key.startsWith("#")) {
766+
names.add(key);
767+
}
768+
}
769+
return Collections.unmodifiableSet(names);
770+
}
771+
772+
/**
773+
* Returns the set of command names that take no arguments.
774+
*/
775+
public Set<String> getNoArgCommandNames() {
776+
return Collections.unmodifiableSet(noArgs.keySet());
777+
}
778+
779+
/**
780+
* Returns the expected parameter keys for the given command, or an empty list if unknown.
781+
*/
782+
public List<String> getCommandParameters(String commandName) {
783+
return commandParameters.getOrDefault(commandName, Collections.emptyList());
784+
}
785+
756786
public Object representCommand(Cmd cmd) {
757787
return cmdMappings.containsKey(cmd.getClass()) ? cmdMappings.get(cmd.getClass()).getEncoder().encode(cmd) : "";
758788
}
@@ -781,6 +811,10 @@ public <T extends Cmd> void addCmd(Class<T> clazz, String tag, boolean noArg, Cm
781811
Construct construct = new CmdConstruct(tag, fromString, fromJson, expectedKeys);
782812
CmdMapping cmdMapping = new CmdMapping<T>(tag, encoder);
783813

814+
if (expectedKeys != null && expectedKeys.length > 0) {
815+
commandParameters.put(tag, List.of(expectedKeys));
816+
}
817+
784818
if (noArg) {
785819
this.noArgs.put(tag, fromString);
786820
mapRepresenter.addEncoding(clazz, (t) -> {

qDup-lsp/README.md

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
# qDup Language Server
2+
3+
A Language Server Protocol (LSP) implementation for qDup YAML scripts, providing IDE support for command completion, diagnostics, and hover documentation.
4+
5+
## Building
6+
7+
```bash
8+
# Build the fat JAR (includes all dependencies)
9+
mvn -pl qDup-lsp package -DskipTests
10+
11+
# Run unit tests
12+
mvn -pl qDup-lsp test
13+
```
14+
15+
The fat JAR is produced at `qDup-lsp/target/qDup-lsp-0.11.1-SNAPSHOT.jar`.
16+
17+
## Running
18+
19+
The server communicates via stdin/stdout using the JSON-RPC protocol defined by LSP.
20+
21+
### Using JBang (recommended)
22+
23+
The easiest way to run the server is with [JBang](https://www.jbang.dev/). No build step required — JBang resolves the dependency and launches the server directly:
24+
25+
```bash
26+
jbang qDup-lsp/qdup-lsp.java
27+
```
28+
29+
Install JBang if you don't have it:
30+
31+
```bash
32+
curl -Ls https://sh.jbang.dev | bash -s - app setup
33+
```
34+
35+
**Note:** The JBang script and the bundled VS Code extension depend on a SNAPSHOT version. You must build and install the artifact locally before using them:
36+
37+
```bash
38+
mvn -pl qDup-lsp install -DskipTests
39+
jbang qDup-lsp/qdup-lsp.java
40+
```
41+
42+
### Using the fat JAR
43+
44+
```bash
45+
java -jar qDup-lsp/target/qDup-lsp-0.11.1-SNAPSHOT.jar
46+
```
47+
48+
## Editor Setup
49+
50+
### Neovim (nvim-lspconfig)
51+
52+
Add a custom server configuration in your Neovim config. You can use either the JBang script or the fat JAR:
53+
54+
```lua
55+
local lspconfig = require('lspconfig')
56+
local configs = require('lspconfig.configs')
57+
58+
-- Option 1: Using JBang
59+
configs.qdup = {
60+
default_config = {
61+
cmd = { 'jbang', '/path/to/qdup-lsp.java' },
62+
filetypes = { 'yaml' },
63+
root_dir = lspconfig.util.find_git_ancestor,
64+
settings = {},
65+
},
66+
}
67+
68+
-- Option 2: Using the fat JAR
69+
-- configs.qdup = {
70+
-- default_config = {
71+
-- cmd = { 'java', '-jar', '/path/to/qDup-lsp-0.11.1-SNAPSHOT.jar' },
72+
-- filetypes = { 'yaml' },
73+
-- root_dir = lspconfig.util.find_git_ancestor,
74+
-- settings = {},
75+
-- },
76+
-- }
77+
78+
lspconfig.qdup.setup({})
79+
```
80+
81+
To limit activation to qDup files only, you can use an `on_attach` or `autocommand` that checks for qDup-specific top-level keys (`scripts:`, `roles:`, `hosts:`).
82+
83+
### VS Code
84+
85+
Install a generic LSP client extension such as [vscode-languageclient](https://github.com/AKosyak/vscode-glspc) or create a minimal extension with a `package.json`:
86+
87+
```json
88+
{
89+
"name": "qdup-lsp",
90+
"displayName": "qDup Language Support",
91+
"version": "0.1.0",
92+
"engines": { "vscode": "^1.75.0" },
93+
"activationEvents": ["onLanguage:yaml"],
94+
"main": "./extension.js",
95+
"contributes": {
96+
"configuration": {
97+
"properties": {
98+
"qdup.lsp.path": {
99+
"type": "string",
100+
"default": "",
101+
"description": "Path to the qDup LSP fat JAR"
102+
}
103+
}
104+
}
105+
}
106+
}
107+
```
108+
109+
With an `extension.js`:
110+
111+
```javascript
112+
const { LanguageClient, TransportKind } = require('vscode-languageclient/node');
113+
114+
let client;
115+
116+
function activate(context) {
117+
const jarPath = vscode.workspace.getConfiguration('qdup').get('lsp.path');
118+
const serverOptions = {
119+
command: 'java',
120+
args: ['-jar', jarPath],
121+
transport: TransportKind.stdio,
122+
};
123+
const clientOptions = {
124+
documentSelector: [{ scheme: 'file', language: 'yaml' }],
125+
};
126+
client = new LanguageClient('qdup', 'qDup Language Server', serverOptions, clientOptions);
127+
client.start();
128+
}
129+
130+
function deactivate() {
131+
return client?.stop();
132+
}
133+
134+
module.exports = { activate, deactivate };
135+
```
136+
137+
### Emacs (eglot)
138+
139+
```elisp
140+
;; Using JBang
141+
(with-eval-after-load 'eglot
142+
(add-to-list 'eglot-server-programs
143+
'(yaml-mode . ("jbang" "/path/to/qdup-lsp.java"))))
144+
145+
;; Or using the fat JAR
146+
;; (with-eval-after-load 'eglot
147+
;; (add-to-list 'eglot-server-programs
148+
;; '(yaml-mode . ("java" "-jar" "/path/to/qDup-lsp-0.11.1-SNAPSHOT.jar"))))
149+
```
150+
151+
### Helix
152+
153+
Add to `~/.config/helix/languages.toml`:
154+
155+
```toml
156+
[[language]]
157+
name = "yaml"
158+
language-servers = ["qdup-lsp"]
159+
160+
# Using JBang
161+
[language-server.qdup-lsp]
162+
command = "jbang"
163+
args = ["/path/to/qdup-lsp.java"]
164+
165+
# Or using the fat JAR
166+
# [language-server.qdup-lsp]
167+
# command = "java"
168+
# args = ["-jar", "/path/to/qDup-lsp-0.11.1-SNAPSHOT.jar"]
169+
```
170+
171+
## Features
172+
173+
### Completion
174+
175+
The server provides context-aware completions for:
176+
177+
| Context | Completions |
178+
|---|---|
179+
| Top-level keys | `name`, `scripts`, `hosts`, `roles`, `states`, `globals` |
180+
| Script commands | All 32+ qDup commands (`sh`, `regex`, `set-state`, etc.) |
181+
| Command modifiers | `then`, `else`, `watch`, `with`, `timer`, `on-signal`, `silent`, etc. |
182+
| Command parameters | Command-specific keys (e.g., `command`, `prompt` for `sh`) |
183+
| Host configuration | 21 host config keys (`hostname`, `username`, `port`, `identity`, etc.) |
184+
| Role properties | `hosts`, `setup-scripts`, `run-scripts`, `cleanup-scripts` |
185+
| Script references | Script names defined in the `scripts:` section |
186+
187+
### Diagnostics
188+
189+
The server validates documents and reports:
190+
191+
- **Errors:** Unknown top-level keys, unknown command names, unknown host config keys, unknown role keys
192+
- **Warnings:** Undefined script references in roles, undefined host references in roles
193+
- **Info:** Unused scripts not referenced by any role, unused hosts not referenced by any role
194+
195+
### Hover
196+
197+
Hovering over qDup elements shows documentation:
198+
199+
- **Commands** — description and usage from the qDup reference docs
200+
- **Command parameters** — per-parameter documentation (e.g., `sh.command`, `regex.pattern`)
201+
- **Modifiers** — description of `then`, `watch`, `timer`, `on-signal`, etc.
202+
- **Host config keys** — description of `hostname`, `port`, `identity`, etc.
203+
- **Top-level keys** — description of `scripts`, `roles`, `hosts`, etc.
204+
- **Role keys** — description of `setup-scripts`, `run-scripts`, etc.
205+
- **State variables** — value and source file for `${{variable}}` references
206+
207+
### Go to Definition
208+
209+
The language server supports go-to-definition (`textDocument/definition`) for:
210+
211+
- **Script references** — jump from role `setup-scripts` / `run-scripts` / `cleanup-scripts` entries to the corresponding definition under `scripts:`
212+
- **Host references** — jump from role host entries to the host definition under `hosts:`
213+
- **State variables** — `${{variable}}` references resolve to their definition under `states:` or `globals:`, including cross-file resolution across the workspace
214+
215+
### Document Symbols (Outline)
216+
217+
The language server provides document symbols so editors can show a hierarchical outline of qDup scripts. The outline groups:
218+
219+
- Top-level sections (`scripts`, `roles`, `hosts`, `states`)
220+
- Individual scripts and their commands
221+
- Nested structures such as `then`, `watch`, and other modifiers
222+
223+
Use your editor's outline or "Go to Symbol" view to navigate large qDup YAML files.
224+
225+
## Architecture
226+
227+
```
228+
io.hyperfoil.tools.qdup.lsp
229+
├── QDupLspLauncher # Entry point (stdin/stdout JSON-RPC)
230+
├── QDupLanguageServer # LanguageServer impl, declares capabilities
231+
├── QDupTextDocumentService # Completion, hover, diagnostics wiring
232+
├── QDupWorkspaceService # Stub
233+
├── QDupDocument # Parsed document model (text + SnakeYAML Node tree)
234+
├── YamlContext # Enum of cursor context types
235+
├── CursorContextResolver # Determines YamlContext from position + Node tree
236+
├── CompletionProvider # Produces CompletionItems based on context
237+
├── DiagnosticsProvider # Validates document, produces Diagnostics
238+
├── HoverProvider # Produces Hover content based on context
239+
└── CommandRegistry # Extracts command metadata from qDup-core Parser
240+
```
241+
242+
The `CommandRegistry` loads command metadata from the qDup-core `Parser` at startup via its public API, giving the LSP access to the same command definitions used by the qDup runtime. When the `Parser` is not available (e.g., classpath issues), it falls back to a hardcoded command list.
243+
244+
Document parsing uses SnakeYAML's `compose()` method to produce a `Node` tree with line/column positions. When `compose()` fails on broken YAML, a line-based fallback determines context from indentation and parent key patterns.

qDup-lsp/example.qdup.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: example qDup script
2+
scripts:
3+
test-script:
4+
- sh: echo "hello"
5+
- set-state: greeting
6+
hosts:
7+
local: me@localhost
8+
roles:
9+
test-role:
10+
hosts:
11+
- local
12+
run-scripts:
13+
- test-script

0 commit comments

Comments
 (0)