From ae765a892991fd145e262d6768322743ddfeaa59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Pedersen?= Date: Wed, 18 Feb 2026 11:05:57 +0100 Subject: [PATCH] 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. --- pom.xml | 1 + .../tools/qdup/config/yaml/Parser.java | 34 + qDup-lsp/README.md | 244 ++ qDup-lsp/example.qdup.yaml | 13 + qDup-lsp/pom.xml | 110 + qDup-lsp/qdup-lsp.java | 13 + .../tools/qdup/lsp/CommandRegistry.java | 124 + .../tools/qdup/lsp/CompletionProvider.java | 245 ++ .../tools/qdup/lsp/CursorContextResolver.java | 329 +++ .../tools/qdup/lsp/DefinitionProvider.java | 251 ++ .../tools/qdup/lsp/DiagnosticsProvider.java | 351 +++ .../qdup/lsp/DocumentSymbolProvider.java | 163 ++ .../tools/qdup/lsp/HoverProvider.java | 277 ++ .../tools/qdup/lsp/QDupDocument.java | 355 +++ .../tools/qdup/lsp/QDupLanguageServer.java | 131 + .../tools/qdup/lsp/QDupLspLauncher.java | 46 + .../qdup/lsp/QDupTextDocumentService.java | 248 ++ .../tools/qdup/lsp/QDupWorkspaceService.java | 32 + .../hyperfoil/tools/qdup/lsp/YamlContext.java | 52 + .../main/resources/command-docs.properties | 76 + .../src/main/resources/lsp-version.properties | 1 + .../tools/qdup/lsp/CommandRegistryTest.java | 126 + .../qdup/lsp/CompletionProviderTest.java | 181 ++ .../qdup/lsp/CursorContextResolverTest.java | 93 + .../qdup/lsp/DefinitionProviderTest.java | 268 ++ .../qdup/lsp/DiagnosticsProviderTest.java | 250 ++ .../qdup/lsp/DocumentSymbolProviderTest.java | 174 ++ .../tools/qdup/lsp/HoverProviderTest.java | 147 + .../tools/qdup/lsp/QDupDocumentTest.java | 191 ++ vscode/.gitignore | 3 + vscode/.vscodeignore | 5 + vscode/README.md | 88 + vscode/language-configuration.json | 15 + vscode/package-lock.json | 2541 +++++++++++++++++ vscode/package.json | 78 + vscode/server/qdup-lsp.java | 13 + vscode/src/extension.ts | 135 + vscode/tsconfig.json | 17 + 38 files changed, 7421 insertions(+) create mode 100644 qDup-lsp/README.md create mode 100644 qDup-lsp/example.qdup.yaml create mode 100644 qDup-lsp/pom.xml create mode 100755 qDup-lsp/qdup-lsp.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CommandRegistry.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CompletionProvider.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CursorContextResolver.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DefinitionProvider.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DiagnosticsProvider.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DocumentSymbolProvider.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/HoverProvider.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupDocument.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupLanguageServer.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupLspLauncher.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupTextDocumentService.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupWorkspaceService.java create mode 100644 qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/YamlContext.java create mode 100644 qDup-lsp/src/main/resources/command-docs.properties create mode 100644 qDup-lsp/src/main/resources/lsp-version.properties create mode 100644 qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CommandRegistryTest.java create mode 100644 qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CompletionProviderTest.java create mode 100644 qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CursorContextResolverTest.java create mode 100644 qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DefinitionProviderTest.java create mode 100644 qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DiagnosticsProviderTest.java create mode 100644 qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DocumentSymbolProviderTest.java create mode 100644 qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/HoverProviderTest.java create mode 100644 qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/QDupDocumentTest.java create mode 100644 vscode/.gitignore create mode 100644 vscode/.vscodeignore create mode 100644 vscode/README.md create mode 100644 vscode/language-configuration.json create mode 100644 vscode/package-lock.json create mode 100644 vscode/package.json create mode 100755 vscode/server/qdup-lsp.java create mode 100644 vscode/src/extension.ts create mode 100644 vscode/tsconfig.json diff --git a/pom.xml b/pom.xml index 3159875f..ea54c0cc 100644 --- a/pom.xml +++ b/pom.xml @@ -12,6 +12,7 @@ qDup-core qDup + qDup-lsp diff --git a/qDup-core/src/main/java/io/hyperfoil/tools/qdup/config/yaml/Parser.java b/qDup-core/src/main/java/io/hyperfoil/tools/qdup/config/yaml/Parser.java index e2301b69..2af5c16f 100644 --- a/qDup-core/src/main/java/io/hyperfoil/tools/qdup/config/yaml/Parser.java +++ b/qDup-core/src/main/java/io/hyperfoil/tools/qdup/config/yaml/Parser.java @@ -675,6 +675,7 @@ public Map getMap(Object o) { private MapRepresenter mapRepresenter; private Map noArgs; private Map cmdMappings; + private Map> commandParameters; private boolean abortOnExitCode; private Parser() { @@ -689,6 +690,7 @@ public Object constructObject(Node node){ mapRepresenter = new MapRepresenter(); cmdMappings = new HashMap<>(); noArgs = new HashMap<>(); + commandParameters = new HashMap<>(); DumperOptions dumperOptions = new DumperOptions(); dumperOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); dumperOptions.setWidth(1024); @@ -753,6 +755,34 @@ public void setAbortOnExitCode(boolean abortOnExitCode){ this.abortOnExitCode = abortOnExitCode; } + /** + * Returns the set of all registered command names (tags). + */ + public Set getCommandNames() { + Set names = new LinkedHashSet<>(); + for (CmdMapping mapping : cmdMappings.values()) { + String key = mapping.getKey(); + if (key != null && !key.startsWith("#")) { + names.add(key); + } + } + return Collections.unmodifiableSet(names); + } + + /** + * Returns the set of command names that take no arguments. + */ + public Set getNoArgCommandNames() { + return Collections.unmodifiableSet(noArgs.keySet()); + } + + /** + * Returns the expected parameter keys for the given command, or an empty list if unknown. + */ + public List getCommandParameters(String commandName) { + return commandParameters.getOrDefault(commandName, Collections.emptyList()); + } + public Object representCommand(Cmd cmd) { return cmdMappings.containsKey(cmd.getClass()) ? cmdMappings.get(cmd.getClass()).getEncoder().encode(cmd) : ""; } @@ -781,6 +811,10 @@ public void addCmd(Class clazz, String tag, boolean noArg, Cm Construct construct = new CmdConstruct(tag, fromString, fromJson, expectedKeys); CmdMapping cmdMapping = new CmdMapping(tag, encoder); + if (expectedKeys != null && expectedKeys.length > 0) { + commandParameters.put(tag, List.of(expectedKeys)); + } + if (noArg) { this.noArgs.put(tag, fromString); mapRepresenter.addEncoding(clazz, (t) -> { diff --git a/qDup-lsp/README.md b/qDup-lsp/README.md new file mode 100644 index 00000000..f3d96934 --- /dev/null +++ b/qDup-lsp/README.md @@ -0,0 +1,244 @@ +# qDup Language Server + +A Language Server Protocol (LSP) implementation for qDup YAML scripts, providing IDE support for command completion, diagnostics, and hover documentation. + +## Building + +```bash +# Build the fat JAR (includes all dependencies) +mvn -pl qDup-lsp package -DskipTests + +# Run unit tests +mvn -pl qDup-lsp test +``` + +The fat JAR is produced at `qDup-lsp/target/qDup-lsp-0.11.1-SNAPSHOT.jar`. + +## Running + +The server communicates via stdin/stdout using the JSON-RPC protocol defined by LSP. + +### Using JBang (recommended) + +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: + +```bash +jbang qDup-lsp/qdup-lsp.java +``` + +Install JBang if you don't have it: + +```bash +curl -Ls https://sh.jbang.dev | bash -s - app setup +``` + +**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: + +```bash +mvn -pl qDup-lsp install -DskipTests +jbang qDup-lsp/qdup-lsp.java +``` + +### Using the fat JAR + +```bash +java -jar qDup-lsp/target/qDup-lsp-0.11.1-SNAPSHOT.jar +``` + +## Editor Setup + +### Neovim (nvim-lspconfig) + +Add a custom server configuration in your Neovim config. You can use either the JBang script or the fat JAR: + +```lua +local lspconfig = require('lspconfig') +local configs = require('lspconfig.configs') + +-- Option 1: Using JBang +configs.qdup = { + default_config = { + cmd = { 'jbang', '/path/to/qdup-lsp.java' }, + filetypes = { 'yaml' }, + root_dir = lspconfig.util.find_git_ancestor, + settings = {}, + }, +} + +-- Option 2: Using the fat JAR +-- configs.qdup = { +-- default_config = { +-- cmd = { 'java', '-jar', '/path/to/qDup-lsp-0.11.1-SNAPSHOT.jar' }, +-- filetypes = { 'yaml' }, +-- root_dir = lspconfig.util.find_git_ancestor, +-- settings = {}, +-- }, +-- } + +lspconfig.qdup.setup({}) +``` + +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:`). + +### VS Code + +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`: + +```json +{ + "name": "qdup-lsp", + "displayName": "qDup Language Support", + "version": "0.1.0", + "engines": { "vscode": "^1.75.0" }, + "activationEvents": ["onLanguage:yaml"], + "main": "./extension.js", + "contributes": { + "configuration": { + "properties": { + "qdup.lsp.path": { + "type": "string", + "default": "", + "description": "Path to the qDup LSP fat JAR" + } + } + } + } +} +``` + +With an `extension.js`: + +```javascript +const { LanguageClient, TransportKind } = require('vscode-languageclient/node'); + +let client; + +function activate(context) { + const jarPath = vscode.workspace.getConfiguration('qdup').get('lsp.path'); + const serverOptions = { + command: 'java', + args: ['-jar', jarPath], + transport: TransportKind.stdio, + }; + const clientOptions = { + documentSelector: [{ scheme: 'file', language: 'yaml' }], + }; + client = new LanguageClient('qdup', 'qDup Language Server', serverOptions, clientOptions); + client.start(); +} + +function deactivate() { + return client?.stop(); +} + +module.exports = { activate, deactivate }; +``` + +### Emacs (eglot) + +```elisp +;; Using JBang +(with-eval-after-load 'eglot + (add-to-list 'eglot-server-programs + '(yaml-mode . ("jbang" "/path/to/qdup-lsp.java")))) + +;; Or using the fat JAR +;; (with-eval-after-load 'eglot +;; (add-to-list 'eglot-server-programs +;; '(yaml-mode . ("java" "-jar" "/path/to/qDup-lsp-0.11.1-SNAPSHOT.jar")))) +``` + +### Helix + +Add to `~/.config/helix/languages.toml`: + +```toml +[[language]] +name = "yaml" +language-servers = ["qdup-lsp"] + +# Using JBang +[language-server.qdup-lsp] +command = "jbang" +args = ["/path/to/qdup-lsp.java"] + +# Or using the fat JAR +# [language-server.qdup-lsp] +# command = "java" +# args = ["-jar", "/path/to/qDup-lsp-0.11.1-SNAPSHOT.jar"] +``` + +## Features + +### Completion + +The server provides context-aware completions for: + +| Context | Completions | +|---|---| +| Top-level keys | `name`, `scripts`, `hosts`, `roles`, `states`, `globals` | +| Script commands | All 32+ qDup commands (`sh`, `regex`, `set-state`, etc.) | +| Command modifiers | `then`, `else`, `watch`, `with`, `timer`, `on-signal`, `silent`, etc. | +| Command parameters | Command-specific keys (e.g., `command`, `prompt` for `sh`) | +| Host configuration | 21 host config keys (`hostname`, `username`, `port`, `identity`, etc.) | +| Role properties | `hosts`, `setup-scripts`, `run-scripts`, `cleanup-scripts` | +| Script references | Script names defined in the `scripts:` section | + +### Diagnostics + +The server validates documents and reports: + +- **Errors:** Unknown top-level keys, unknown command names, unknown host config keys, unknown role keys +- **Warnings:** Undefined script references in roles, undefined host references in roles +- **Info:** Unused scripts not referenced by any role, unused hosts not referenced by any role + +### Hover + +Hovering over qDup elements shows documentation: + +- **Commands** — description and usage from the qDup reference docs +- **Command parameters** — per-parameter documentation (e.g., `sh.command`, `regex.pattern`) +- **Modifiers** — description of `then`, `watch`, `timer`, `on-signal`, etc. +- **Host config keys** — description of `hostname`, `port`, `identity`, etc. +- **Top-level keys** — description of `scripts`, `roles`, `hosts`, etc. +- **Role keys** — description of `setup-scripts`, `run-scripts`, etc. +- **State variables** — value and source file for `${{variable}}` references + +### Go to Definition + +The language server supports go-to-definition (`textDocument/definition`) for: + +- **Script references** — jump from role `setup-scripts` / `run-scripts` / `cleanup-scripts` entries to the corresponding definition under `scripts:` +- **Host references** — jump from role host entries to the host definition under `hosts:` +- **State variables** — `${{variable}}` references resolve to their definition under `states:` or `globals:`, including cross-file resolution across the workspace + +### Document Symbols (Outline) + +The language server provides document symbols so editors can show a hierarchical outline of qDup scripts. The outline groups: + +- Top-level sections (`scripts`, `roles`, `hosts`, `states`) +- Individual scripts and their commands +- Nested structures such as `then`, `watch`, and other modifiers + +Use your editor's outline or "Go to Symbol" view to navigate large qDup YAML files. + +## Architecture + +``` +io.hyperfoil.tools.qdup.lsp +├── QDupLspLauncher # Entry point (stdin/stdout JSON-RPC) +├── QDupLanguageServer # LanguageServer impl, declares capabilities +├── QDupTextDocumentService # Completion, hover, diagnostics wiring +├── QDupWorkspaceService # Stub +├── QDupDocument # Parsed document model (text + SnakeYAML Node tree) +├── YamlContext # Enum of cursor context types +├── CursorContextResolver # Determines YamlContext from position + Node tree +├── CompletionProvider # Produces CompletionItems based on context +├── DiagnosticsProvider # Validates document, produces Diagnostics +├── HoverProvider # Produces Hover content based on context +└── CommandRegistry # Extracts command metadata from qDup-core Parser +``` + +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. + +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. diff --git a/qDup-lsp/example.qdup.yaml b/qDup-lsp/example.qdup.yaml new file mode 100644 index 00000000..fff646fc --- /dev/null +++ b/qDup-lsp/example.qdup.yaml @@ -0,0 +1,13 @@ +name: example qDup script +scripts: + test-script: + - sh: echo "hello" + - set-state: greeting +hosts: + local: me@localhost +roles: + test-role: + hosts: + - local + run-scripts: + - test-script diff --git a/qDup-lsp/pom.xml b/qDup-lsp/pom.xml new file mode 100644 index 00000000..dd8afe6c --- /dev/null +++ b/qDup-lsp/pom.xml @@ -0,0 +1,110 @@ + + + 4.0.0 + + + io.hyperfoil.tools + qDup-parent + 0.11.1-SNAPSHOT + + + qDup-lsp + qDup Language Server + Language Server Protocol implementation for qDup YAML scripts + + + 17 + 17 + UTF-8 + 0.23.1 + + + + + io.hyperfoil.tools + qDup-core + ${project.version} + + + org.eclipse.lsp4j + org.eclipse.lsp4j + ${version.lsp4j} + + + org.yaml + snakeyaml + + + junit + junit + test + + + + + + + src/main/resources + true + + lsp-version.properties + + + + src/main/resources + false + + lsp-version.properties + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${compiler-plugin.version} + + 17 + 17 + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + io.hyperfoil.tools.qdup.lsp.QDupLspLauncher + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${version.surefire-plugin} + + + + diff --git a/qDup-lsp/qdup-lsp.java b/qDup-lsp/qdup-lsp.java new file mode 100755 index 00000000..8925dc75 --- /dev/null +++ b/qDup-lsp/qdup-lsp.java @@ -0,0 +1,13 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 17+ +//DEPS io.hyperfoil.tools:qDup-lsp:0.11.1-SNAPSHOT + +import io.hyperfoil.tools.qdup.lsp.QDupLspLauncher; + +class qduplsp { + + public static void main(String... args) { + QDupLspLauncher.main(args); + } + +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CommandRegistry.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CommandRegistry.java new file mode 100644 index 00000000..2b5dd306 --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CommandRegistry.java @@ -0,0 +1,124 @@ +package io.hyperfoil.tools.qdup.lsp; + +import io.hyperfoil.tools.qdup.config.yaml.CmdMapping; +import io.hyperfoil.tools.qdup.config.yaml.HostDefinition; +import io.hyperfoil.tools.qdup.config.yaml.Parser; + +import java.util.*; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Extracts command metadata from the qDup-core Parser for use by the LSP. + * Uses Parser's public API to access command registration data. + */ +public class CommandRegistry { + + private static final Logger LOG = Logger.getLogger(CommandRegistry.class.getName()); + + private final Set commandNames = new LinkedHashSet<>(); + private final Set noArgCommands = new LinkedHashSet<>(); + private final Map> commandExpectedKeys = new LinkedHashMap<>(); + private final Set modifierKeys; + private final List hostConfigKeys; + + public CommandRegistry() { + this.modifierKeys = CmdMapping.COMMAND_KEYS; + this.hostConfigKeys = HostDefinition.KEYS; + loadFromParser(); + } + + private void loadFromParser() { + try { + Parser parser = Parser.getInstance(); + + commandNames.addAll(parser.getCommandNames()); + noArgCommands.addAll(parser.getNoArgCommandNames()); + + // Load expected keys for each command from Parser's public API + for (String cmd : commandNames) { + List params = parser.getCommandParameters(cmd); + if (!params.isEmpty()) { + commandExpectedKeys.put(cmd, new ArrayList<>(params)); + } + } + + // Add well-known expected keys for commands not covered by the public API + // (e.g., commands registered via CmdWithElseConstruct that don't track parameters) + addKnownExpectedKeys(); + + LOG.info("CommandRegistry loaded " + commandNames.size() + " commands, " + noArgCommands.size() + " no-arg commands"); + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to load commands from Parser, using fallback", e); + loadFallbackCommands(); + } + } + + private void addKnownExpectedKeys() { + // Only add if not already discovered via Parser API + commandExpectedKeys.putIfAbsent("abort", List.of("message", "skip-cleanup")); + commandExpectedKeys.putIfAbsent("add-prompt", List.of("prompt", "is-shell")); + commandExpectedKeys.putIfAbsent("countdown", List.of("name", "initial")); + commandExpectedKeys.putIfAbsent("download", List.of("path", "destination", "max-size")); + commandExpectedKeys.putIfAbsent("exec", List.of("command", "async", "silent")); + commandExpectedKeys.putIfAbsent("for-each", List.of("name", "input")); + commandExpectedKeys.putIfAbsent("queue-download", List.of("path", "destination", "max-size")); + commandExpectedKeys.putIfAbsent("regex", List.of("pattern", "miss", "autoConvert")); + commandExpectedKeys.putIfAbsent("script", List.of("name", "async")); + commandExpectedKeys.putIfAbsent("set-signal", List.of("name", "count", "reset")); + commandExpectedKeys.putIfAbsent("set-state", List.of("key", "value", "separator", "silent", "autoConvert")); + commandExpectedKeys.putIfAbsent("sh", List.of("command", "prompt", "ignore-exit-code", "silent")); + commandExpectedKeys.putIfAbsent("upload", List.of("path", "destination")); + commandExpectedKeys.putIfAbsent("wait-for", List.of("name", "initial")); + commandExpectedKeys.putIfAbsent("xml", List.of("operations", "path")); + } + + private void loadFallbackCommands() { + commandNames.addAll(List.of( + "abort", "add-prompt", "countdown", "ctrlC", "ctrl/", "ctrl\\", + "ctrlU", "ctrlZ", "done", "download", "echo", "exec", + "for-each", "js", "json", "log", "parse", "queue-download", + "read-signal", "read-state", "regex", "repeat-until", "script", + "send-text", "set-signal", "set-state", "sh", "signal", + "sleep", "upload", "wait-for", "xml" + )); + noArgCommands.addAll(List.of("ctrlC", "ctrl/", "ctrl\\", "ctrlU", "ctrlZ", "done", "echo")); + addKnownExpectedKeys(); + } + + public Set getCommandNames() { + return Collections.unmodifiableSet(commandNames); + } + + public Set getNoArgCommands() { + return Collections.unmodifiableSet(noArgCommands); + } + + public boolean isCommand(String name) { + return commandNames.contains(name); + } + + public boolean isNoArgCommand(String name) { + return noArgCommands.contains(name); + } + + public List getExpectedKeys(String commandName) { + return commandExpectedKeys.getOrDefault(commandName, Collections.emptyList()); + } + + public Set getModifierKeys() { + return modifierKeys; + } + + public List getHostConfigKeys() { + return hostConfigKeys; + } + + public Set getTopLevelKeys() { + return Set.of("name", "scripts", "hosts", "roles", "states", "globals"); + } + + public Set getRoleKeys() { + return Set.of("hosts", "setup-scripts", "run-scripts", "cleanup-scripts"); + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CompletionProvider.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CompletionProvider.java new file mode 100644 index 00000000..a08db9df --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CompletionProvider.java @@ -0,0 +1,245 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.CompletionItem; +import org.eclipse.lsp4j.CompletionItemKind; + +import java.util.*; + +/** + * Produces CompletionItems based on the cursor's YamlContext within a qDup document. + */ +public class CompletionProvider { + + private final CommandRegistry registry; + private final CursorContextResolver contextResolver; + private final Properties commandDocs; + + public CompletionProvider(CommandRegistry registry, CursorContextResolver contextResolver, Properties commandDocs) { + this.registry = registry; + this.contextResolver = contextResolver; + this.commandDocs = commandDocs; + } + + /** + * Provides completion items for the given position in the document. + */ + public List complete(QDupDocument document, int line, int character) { + YamlContext context = contextResolver.resolve(document, line, character); + return completeForContext(context, document, line, null); + } + + /** + * Provides completion items, with cross-file state variable support. + * If the cursor is inside a ${{...}} pattern, suggests state variable names from all documents. + */ + public List complete(QDupDocument document, int line, int character, Collection allDocs) { + String currentLine = document.getLine(line); + + // Check if cursor is inside a ${{...}} pattern + if (isInsideVariablePattern(currentLine, character)) { + return completeStateVariables(document, allDocs); + } + + YamlContext context = contextResolver.resolve(document, line, character); + return completeForContext(context, document, line, allDocs); + } + + /** + * Returns true if the cursor is inside a ${{...}} pattern on the given line. + */ + static boolean isInsideVariablePattern(String line, int character) { + return DefinitionProvider.extractVariableAt(line, character) != null + || isAtEmptyVariablePattern(line, character); + } + + /** + * Checks if the cursor is at an empty ${{}} or ${{ with nothing typed yet. + */ + private static boolean isAtEmptyVariablePattern(String line, int character) { + if (line == null || character < 3) { + return false; + } + // Search backward for "${{" + for (int i = Math.min(character, line.length()) - 1; i >= 2; i--) { + if (line.charAt(i) == '{' && line.charAt(i - 1) == '{' && line.charAt(i - 2) == '$') { + // Found "${{" — check we haven't passed a "}}" on the way + return true; + } + if (i >= 1 && line.charAt(i) == '}' && line.charAt(i - 1) == '}') { + return false; + } + } + return false; + } + + private List completeStateVariables(QDupDocument currentDoc, Collection allDocs) { + Set seen = new LinkedHashSet<>(); + List items = new ArrayList<>(); + + // Collect from current document first + addStateKeys(currentDoc, seen, items); + + // Then from other workspace documents + if (allDocs != null) { + for (QDupDocument other : allDocs) { + if (!other.getUri().equals(currentDoc.getUri()) && other.isParseSuccessful()) { + addStateKeys(other, seen, items); + } + } + } + + return items; + } + + private void addStateKeys(QDupDocument doc, Set seen, List items) { + for (String key : doc.getStateKeys()) { + if (seen.add(key)) { + CompletionItem item = new CompletionItem(key); + item.setKind(CompletionItemKind.Variable); + item.setDetail("State variable"); + items.add(item); + } + } + } + + /** + * Produces completion items for the given context. + */ + public List completeForContext(YamlContext context, QDupDocument document, int line, Collection allDocs) { + List items = new ArrayList<>(); + + switch (context) { + case TOP_LEVEL_KEY: + for (String key : registry.getTopLevelKeys()) { + items.add(createKeyCompletion(key, "Top-level qDup key", CompletionItemKind.Property)); + } + break; + + case SCRIPT_COMMAND_KEY: + for (String cmd : registry.getCommandNames()) { + String doc = commandDocs.getProperty(cmd, "qDup command"); + CompletionItem item = new CompletionItem(cmd); + item.setKind(CompletionItemKind.Function); + item.setDetail(doc); + if (registry.isNoArgCommand(cmd)) { + item.setInsertText(cmd); + } else { + item.setInsertText(cmd + ": "); + } + items.add(item); + } + break; + + case COMMAND_MODIFIER_KEY: + for (String mod : registry.getModifierKeys()) { + String doc = getModifierDoc(mod); + items.add(createKeyCompletion(mod, doc, CompletionItemKind.Keyword)); + } + // Add "else" which is not in COMMAND_KEYS but is commonly used + items.add(createKeyCompletion("else", "Commands to run when the parent command does not match", CompletionItemKind.Keyword)); + break; + + case COMMAND_PARAM_KEY: + String commandName = contextResolver.findCommandAtLine(document, line); + if (commandName != null) { + List keys = registry.getExpectedKeys(commandName); + for (String key : keys) { + items.add(createKeyCompletion(key, "Parameter for " + commandName, CompletionItemKind.Property)); + } + } + // Also suggest modifiers since they can appear at command level + for (String mod : registry.getModifierKeys()) { + items.add(createKeyCompletion(mod, getModifierDoc(mod), CompletionItemKind.Keyword)); + } + break; + + case HOST_CONFIG_KEY: + for (String key : registry.getHostConfigKeys()) { + items.add(createKeyCompletion(key, "Host configuration key", CompletionItemKind.Property)); + } + break; + + case ROLE_KEY: + for (String key : registry.getRoleKeys()) { + items.add(createKeyCompletion(key, "Role configuration key", CompletionItemKind.Property)); + } + break; + + case ROLE_SCRIPT_REF: + Set scriptNamesSeen = new LinkedHashSet<>(); + collectNames(document.getScriptNames(), scriptNamesSeen, items, "Script", CompletionItemKind.Reference); + if (allDocs != null) { + for (QDupDocument other : allDocs) { + if (!other.getUri().equals(document.getUri()) && other.isParseSuccessful()) { + collectNames(other.getScriptNames(), scriptNamesSeen, items, "Script", CompletionItemKind.Reference); + } + } + } + break; + + case ROLE_HOST_REF: + Set hostNamesSeen = new LinkedHashSet<>(); + collectNames(document.getHostNames(), hostNamesSeen, items, "Host", CompletionItemKind.Reference); + if (allDocs != null) { + for (QDupDocument other : allDocs) { + if (!other.getUri().equals(document.getUri()) && other.isParseSuccessful()) { + collectNames(other.getHostNames(), hostNamesSeen, items, "Host", CompletionItemKind.Reference); + } + } + } + break; + + case STATE_VARIABLE_REF: + Set stateKeys = document.getStateKeys(); + for (String key : stateKeys) { + CompletionItem item = new CompletionItem(key); + item.setKind(CompletionItemKind.Variable); + item.setDetail("State variable"); + items.add(item); + } + break; + + default: + break; + } + + return items; + } + + private void collectNames(Set names, Set seen, List items, String label, CompletionItemKind kind) { + for (String name : names) { + if (seen.add(name)) { + CompletionItem item = new CompletionItem(name); + item.setKind(kind); + item.setDetail(label + ": " + name); + items.add(item); + } + } + } + + private CompletionItem createKeyCompletion(String key, String detail, CompletionItemKind kind) { + CompletionItem item = new CompletionItem(key); + item.setKind(kind); + item.setDetail(detail); + item.setInsertText(key + ": "); + return item; + } + + private String getModifierDoc(String modifier) { + return switch (modifier) { + case "then" -> "Commands to run when the parent command succeeds"; + case "with" -> "Set state variables for this command and its children"; + case "watch" -> "Commands to run concurrently while this command executes"; + case "timer" -> "Commands to run after a specified timeout"; + case "on-signal" -> "Commands to run when a specific signal is received"; + case "silent" -> "Suppress command output in the log"; + case "prefix" -> "Override the state variable prefix pattern"; + case "suffix" -> "Override the state variable suffix pattern"; + case "separator" -> "Override the state variable separator"; + case "js-prefix" -> "Override the JavaScript evaluation prefix"; + case "idle-timer" -> "Set or disable the idle timer for this command"; + case "state-scan" -> "Enable or disable state variable scanning"; + default -> "Command modifier"; + }; + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CursorContextResolver.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CursorContextResolver.java new file mode 100644 index 00000000..34858896 --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/CursorContextResolver.java @@ -0,0 +1,329 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.yaml.snakeyaml.nodes.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; + +/** + * Determines the YamlContext for a given cursor position within a qDup YAML document. + * Uses the SnakeYAML Node tree when available, with a line-based fallback. + */ +public class CursorContextResolver { + + private static final Logger LOG = Logger.getLogger(CursorContextResolver.class.getName()); + + private final CommandRegistry registry; + + public CursorContextResolver(CommandRegistry registry) { + this.registry = registry; + } + + /** + * Resolves the YAML context at the given line and character position. + * + * @param document the parsed document + * @param line 0-based line number + * @param character 0-based character offset + * @return the resolved context + */ + public YamlContext resolve(QDupDocument document, int line, int character) { + if (document.isParseSuccessful() && document.getRootNode() != null) { + return resolveFromTree(document, line, character); + } + return resolveFromLines(document, line, character); + } + + /** + * Resolves context by finding the path of keys in the YAML Node tree. + */ + private YamlContext resolveFromTree(QDupDocument document, int line, int character) { + Node root = document.getRootNode(); + if (!(root instanceof MappingNode)) { + return YamlContext.UNKNOWN; + } + + List keyPath = new ArrayList<>(); + boolean isKeyPosition = determineKeyPath(root, line, character, keyPath); + + return contextFromKeyPath(keyPath, isKeyPosition, line, character, document); + } + + /** + * Walks the node tree to build the key path from root to the node containing the cursor. + * Returns true if the cursor is at a key position, false if at a value position. + */ + private boolean determineKeyPath(Node node, int line, int character, List keyPath) { + if (node instanceof MappingNode) { + MappingNode mapping = (MappingNode) node; + for (NodeTuple tuple : mapping.getValue()) { + Node keyNode = tuple.getKeyNode(); + Node valueNode = tuple.getValueNode(); + + // Check if cursor is on the key + if (containsPosition(keyNode, line, character)) { + return true; // cursor is on a key + } + + // Check if cursor is in the value + if (containsPosition(valueNode, line, character)) { + String keyName = QDupDocument.scalarValue(keyNode); + if (keyName != null) { + keyPath.add(keyName); + } + return determineKeyPath(valueNode, line, character, keyPath); + } + + // Check if cursor is between key and value on the same line + if (keyNode.getStartMark() != null && keyNode.getStartMark().getLine() == line) { + String keyName = QDupDocument.scalarValue(keyNode); + if (keyName != null) { + int keyEnd = keyNode.getEndMark() != null ? keyNode.getEndMark().getColumn() : 0; + if (character > keyEnd) { + keyPath.add(keyName); + return false; // cursor is on value side + } + } + } + } + // Cursor is inside the mapping but not on any specific tuple + // This usually means a new key position + return true; + } else if (node instanceof SequenceNode) { + SequenceNode sequence = (SequenceNode) node; + for (Node item : sequence.getValue()) { + if (containsPosition(item, line, character)) { + return determineKeyPath(item, line, character, keyPath); + } + } + // Inside sequence but not matching any item - treating as command key context + return true; + } + return false; + } + + private boolean containsPosition(Node node, int line, int character) { + if (node == null || node.getStartMark() == null || node.getEndMark() == null) { + return false; + } + int startLine = node.getStartMark().getLine(); + int startCol = node.getStartMark().getColumn(); + int endLine = node.getEndMark().getLine(); + int endCol = node.getEndMark().getColumn(); + + if (line < startLine || line > endLine) return false; + if (line == startLine && character < startCol) return false; + if (line == endLine && character > endCol) return false; + return true; + } + + /** + * Determines context from the key path built during tree traversal. + */ + private YamlContext contextFromKeyPath(List keyPath, boolean isKeyPosition, int line, int character, QDupDocument document) { + if (keyPath.isEmpty()) { + return isKeyPosition ? YamlContext.TOP_LEVEL_KEY : YamlContext.TOP_LEVEL_VALUE; + } + + String firstKey = keyPath.get(0); + + switch (firstKey) { + case "scripts": + if (keyPath.size() == 1) { + return isKeyPosition ? YamlContext.SCRIPT_NAME : YamlContext.SCRIPT_NAME; + } + if (keyPath.size() == 2) { + // Inside a specific script - this is the command list + return isKeyPosition ? YamlContext.SCRIPT_COMMAND_KEY : YamlContext.SCRIPT_COMMAND_VALUE; + } + if (keyPath.size() >= 3) { + String thirdKey = keyPath.get(2); + if (registry.isCommand(thirdKey)) { + if (keyPath.size() == 3 && !isKeyPosition) { + return YamlContext.SCRIPT_COMMAND_VALUE; + } + if (keyPath.size() >= 4 || isKeyPosition) { + return YamlContext.COMMAND_PARAM_KEY; + } + } + if (registry.getModifierKeys().contains(thirdKey) || "else".equals(thirdKey)) { + return isKeyPosition ? YamlContext.SCRIPT_COMMAND_KEY : YamlContext.SCRIPT_COMMAND_VALUE; + } + return isKeyPosition ? YamlContext.COMMAND_MODIFIER_KEY : YamlContext.SCRIPT_COMMAND_VALUE; + } + return YamlContext.SCRIPT_COMMAND_KEY; + + case "hosts": + if (keyPath.size() == 1) { + return YamlContext.HOST_NAME; + } + return isKeyPosition ? YamlContext.HOST_CONFIG_KEY : YamlContext.UNKNOWN; + + case "roles": + if (keyPath.size() == 1) { + return YamlContext.ROLE_NAME; + } + if (keyPath.size() == 2) { + return isKeyPosition ? YamlContext.ROLE_KEY : YamlContext.UNKNOWN; + } + if (keyPath.size() >= 3) { + String roleProp = keyPath.get(2); + if (roleProp.endsWith("-scripts")) { + return YamlContext.ROLE_SCRIPT_REF; + } + if ("hosts".equals(roleProp)) { + return YamlContext.ROLE_HOST_REF; + } + } + return YamlContext.ROLE_KEY; + + case "states": + case "globals": + return isKeyPosition ? YamlContext.STATE_VARIABLE_REF : YamlContext.UNKNOWN; + + case "name": + return YamlContext.TOP_LEVEL_VALUE; + + default: + return YamlContext.UNKNOWN; + } + } + + /** + * Fallback line-based context resolution for when YAML parsing fails. + */ + private YamlContext resolveFromLines(QDupDocument document, int line, int character) { + String currentLine = document.getLine(line).stripTrailing(); + int indent = getIndent(currentLine); + String trimmed = currentLine.trim(); + + // Find the nearest parent section by looking upward + String parentSection = findParentSection(document, line); + + if (indent == 0) { + // Top-level + if (trimmed.endsWith(":") || trimmed.isEmpty()) { + return YamlContext.TOP_LEVEL_KEY; + } + return YamlContext.TOP_LEVEL_KEY; + } + + if ("scripts".equals(parentSection)) { + if (indent == 2) { + return YamlContext.SCRIPT_NAME; + } + // Inside a script - check if it's a list item (command) + if (trimmed.startsWith("- ")) { + return YamlContext.SCRIPT_COMMAND_KEY; + } + // Check if it's a modifier + String key = trimmed.split(":")[0].trim().replace("- ", ""); + if (registry.getModifierKeys().contains(key) || "else".equals(key)) { + return YamlContext.COMMAND_MODIFIER_KEY; + } + if (registry.isCommand(key)) { + return YamlContext.SCRIPT_COMMAND_KEY; + } + return YamlContext.SCRIPT_COMMAND_KEY; + } + + if ("hosts".equals(parentSection)) { + if (indent == 2) { + return YamlContext.HOST_NAME; + } + return YamlContext.HOST_CONFIG_KEY; + } + + if ("roles".equals(parentSection)) { + if (indent == 2) { + return YamlContext.ROLE_NAME; + } + if (indent == 4) { + // At indent 4 we are on the role property key itself (e.g. "run-scripts:"), + // which should always be treated as ROLE_KEY. + return YamlContext.ROLE_KEY; + } + // Inside a role property — resolve based on nearest parent key + String roleKey = findNearestKey(document, line, 4); + if (roleKey != null) { + if (roleKey.endsWith("-scripts")) { + return YamlContext.ROLE_SCRIPT_REF; + } + if ("hosts".equals(roleKey)) { + return YamlContext.ROLE_HOST_REF; + } + } + return YamlContext.ROLE_KEY; + } + + if ("states".equals(parentSection) || "globals".equals(parentSection)) { + return YamlContext.STATE_VARIABLE_REF; + } + + return YamlContext.UNKNOWN; + } + + private String findParentSection(QDupDocument document, int line) { + for (int i = line; i >= 0; i--) { + String l = document.getLine(i); + int indent = getIndent(l); + if (indent == 0 && !l.trim().isEmpty()) { + String key = l.trim().split(":")[0].trim(); + return key; + } + } + return null; + } + + private String findNearestKey(QDupDocument document, int line, int targetIndent) { + for (int i = line; i >= 0; i--) { + String l = document.getLine(i); + int indent = getIndent(l); + if (indent == targetIndent && l.contains(":")) { + return l.trim().split(":")[0].trim(); + } + if (indent < targetIndent) { + break; + } + } + return null; + } + + /** + * Returns the command name at the given line, if any. + * Useful for determining which command's parameters to suggest. + */ + public String findCommandAtLine(QDupDocument document, int line) { + // Look at the current line and parent lines + for (int i = line; i >= 0; i--) { + String l = document.getLine(i).trim(); + if (l.startsWith("- ")) { + l = l.substring(2); + } + String key = l.split(":")[0].trim(); + if (registry.isCommand(key)) { + return key; + } + // If we've reached a lower indent level, stop looking + int currentIndent = getIndent(document.getLine(i)); + int searchIndent = getIndent(document.getLine(line)); + if (currentIndent < searchIndent) { + if (registry.isCommand(key)) { + return key; + } + break; + } + } + return null; + } + + private int getIndent(String line) { + int count = 0; + for (char c : line.toCharArray()) { + if (c == ' ') count++; + else break; + } + return count; + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DefinitionProvider.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DefinitionProvider.java new file mode 100644 index 00000000..7e1f7a90 --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DefinitionProvider.java @@ -0,0 +1,251 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.yaml.snakeyaml.nodes.Node; + +import java.util.Collection; + +/** + * Provides go-to-definition for qDup YAML documents. + * Supports jumping from script/host references to their definitions. + */ +public class DefinitionProvider { + + private final CursorContextResolver contextResolver; + + public DefinitionProvider(CursorContextResolver contextResolver) { + this.contextResolver = contextResolver; + } + + /** + * Returns the definition Location for the element at the given position, or null if none. + */ + public Location definition(QDupDocument doc, int line, int character) { + if (!doc.isParseSuccessful()) { + return null; + } + + String currentLine = doc.getLine(line); + + // Check for state variable pattern ${{...}} + String varName = extractVariableAt(currentLine, character); + if (varName != null) { + Node stateNode = doc.findStateNode(varName); + if (stateNode != null) { + return nodeToLocation(doc.getUri(), stateNode); + } + return null; + } + + String word = extractWordAt(currentLine, character); + if (word == null || word.isEmpty()) { + return null; + } + + YamlContext context = contextResolver.resolve(doc, line, character); + + Node targetNode = null; + + switch (context) { + case ROLE_SCRIPT_REF: + targetNode = doc.findScriptNode(word); + break; + + case SCRIPT_COMMAND_VALUE: + // Check if the command key on this line is "script" + String cmdName = contextResolver.findCommandAtLine(doc, line); + if ("script".equals(cmdName)) { + targetNode = doc.findScriptNode(word); + } + break; + + case ROLE_HOST_REF: + targetNode = doc.findHostNode(word); + break; + + default: + break; + } + + if (targetNode == null) { + return null; + } + + return nodeToLocation(doc.getUri(), targetNode); + } + + /** + * Returns the definition Location searching across all workspace documents. + * The current document is searched first; if not found, other documents are searched. + */ + public Location definition(QDupDocument doc, int line, int character, Collection allDocs) { + // Try current document first + Location local = definition(doc, line, character); + if (local != null) { + return local; + } + + if (allDocs == null || allDocs.isEmpty()) { + return null; + } + + if (!doc.isParseSuccessful()) { + return null; + } + + String currentLine = doc.getLine(line); + + // Check for state variable pattern ${{...}} across all docs + String varName = extractVariableAt(currentLine, character); + if (varName != null) { + for (QDupDocument other : allDocs) { + if (other.getUri().equals(doc.getUri()) || !other.isParseSuccessful()) { + continue; + } + Node stateNode = other.findStateNode(varName); + if (stateNode != null) { + return nodeToLocation(other.getUri(), stateNode); + } + } + return null; + } + + String word = extractWordAt(currentLine, character); + if (word == null || word.isEmpty()) { + return null; + } + + YamlContext context = contextResolver.resolve(doc, line, character); + + for (QDupDocument other : allDocs) { + if (other.getUri().equals(doc.getUri()) || !other.isParseSuccessful()) { + continue; + } + + Node targetNode = null; + + switch (context) { + case ROLE_SCRIPT_REF: + targetNode = other.findScriptNode(word); + break; + + case SCRIPT_COMMAND_VALUE: + String cmdName = contextResolver.findCommandAtLine(doc, line); + if ("script".equals(cmdName)) { + targetNode = other.findScriptNode(word); + } + break; + + case ROLE_HOST_REF: + targetNode = other.findHostNode(word); + break; + + default: + break; + } + + if (targetNode != null) { + return nodeToLocation(other.getUri(), targetNode); + } + } + + return null; + } + + private Location nodeToLocation(String uri, Node node) { + if (node.getStartMark() == null || node.getEndMark() == null) { + return null; + } + int startLine = node.getStartMark().getLine(); + int startCol = node.getStartMark().getColumn(); + int endLine = node.getEndMark().getLine(); + int endCol = node.getEndMark().getColumn(); + + Range range = new Range( + new Position(startLine, startCol), + new Position(endLine, endCol) + ); + return new Location(uri, range); + } + + /** + * Extracts the state variable name if the cursor is inside a ${{...}} pattern. + * Returns null if the cursor is not inside such a pattern. + * Strips any default-value separator (e.g., "FOO:default" returns "FOO"). + */ + static String extractVariableAt(String line, int character) { + if (line == null || character < 0 || character > line.length()) { + return null; + } + + // Search backward from cursor for "${{" + int openIndex = -1; + for (int i = Math.min(character, line.length()) - 1; i >= 2; i--) { + if (line.charAt(i) == '{' && line.charAt(i - 1) == '{' && line.charAt(i - 2) == '$') { + openIndex = i + 1; // position after "${{" + break; + } + // If we hit "}}" while searching backward, we're outside a pattern + if (i >= 1 && line.charAt(i) == '}' && line.charAt(i - 1) == '}') { + return null; + } + } + + if (openIndex < 0) { + return null; + } + + // Search forward from the open for "}}" (or end of line) + int closeIndex = line.length(); + for (int i = openIndex; i < line.length() - 1; i++) { + if (line.charAt(i) == '}' && line.charAt(i + 1) == '}') { + closeIndex = i; + break; + } + } + + String varExpr = line.substring(openIndex, closeIndex).trim(); + if (varExpr.isEmpty()) { + return null; + } + + // Strip default-value separator (e.g., "FOO:default" -> "FOO") + int colonIndex = varExpr.indexOf(':'); + if (colonIndex > 0) { + varExpr = varExpr.substring(0, colonIndex); + } + + return varExpr.trim(); + } + + /** + * Extracts the word at the given character position in a line. + */ + static String extractWordAt(String line, int character) { + if (line == null || character < 0 || character > line.length()) { + return null; + } + + int start = character; + int end = character; + + while (start > 0 && isWordChar(line.charAt(start - 1))) { + start--; + } + while (end < line.length() && isWordChar(line.charAt(end))) { + end++; + } + + if (start == end) { + return null; + } + + return line.substring(start, end); + } + + private static boolean isWordChar(char c) { + return Character.isLetterOrDigit(c) || c == '-' || c == '_' || c == '/' || c == '\\'; + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DiagnosticsProvider.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DiagnosticsProvider.java new file mode 100644 index 00000000..c962769b --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DiagnosticsProvider.java @@ -0,0 +1,351 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticSeverity; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.yaml.snakeyaml.nodes.*; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * Validates a qDup YAML document and produces LSP Diagnostics. + */ +public class DiagnosticsProvider { + + private final CommandRegistry registry; + + public DiagnosticsProvider(CommandRegistry registry) { + this.registry = registry; + } + + /** + * Validates the document and returns a list of diagnostics. + */ + public List diagnose(QDupDocument document) { + return diagnose(document, Collections.emptyList()); + } + + /** + * Validates the document and returns a list of diagnostics, + * considering definitions and references from all workspace documents. + */ + public List diagnose(QDupDocument document, Collection allDocs) { + List diagnostics = new ArrayList<>(); + + if (!document.isParseSuccessful()) { + diagnostics.add(createDiagnostic( + 0, 0, 0, 1, + "YAML syntax error: unable to parse document", + DiagnosticSeverity.Error + )); + return diagnostics; + } + + Node root = document.getRootNode(); + if (!(root instanceof MappingNode)) { + diagnostics.add(createDiagnostic( + 0, 0, 0, 1, + "qDup document must be a YAML mapping at the top level", + DiagnosticSeverity.Error + )); + return diagnostics; + } + + MappingNode rootMapping = (MappingNode) root; + validateTopLevelKeys(rootMapping, diagnostics); + validateScripts(rootMapping, diagnostics); + validateHosts(rootMapping, diagnostics); + validateRoles(rootMapping, document, allDocs, diagnostics); + checkUnusedScripts(document, allDocs, diagnostics); + checkUnusedHosts(document, allDocs, diagnostics); + + return diagnostics; + } + + private void validateTopLevelKeys(MappingNode root, List diagnostics) { + Set validKeys = registry.getTopLevelKeys(); + for (NodeTuple tuple : root.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if (key != null && !validKeys.contains(key)) { + diagnostics.add(createDiagnosticFromNode( + tuple.getKeyNode(), + "Unknown top-level key: '" + key + "'. Valid keys: " + validKeys, + DiagnosticSeverity.Error + )); + } + } + } + + private void validateScripts(MappingNode root, List diagnostics) { + for (NodeTuple tuple : root.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if ("scripts".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode scripts = (MappingNode) tuple.getValueNode(); + for (NodeTuple scriptTuple : scripts.getValue()) { + if (scriptTuple.getValueNode() instanceof SequenceNode) { + validateCommandSequence((SequenceNode) scriptTuple.getValueNode(), diagnostics); + } + } + } + } + } + + private void validateCommandSequence(SequenceNode sequence, List diagnostics) { + for (Node item : sequence.getValue()) { + if (item instanceof ScalarNode) { + // Could be a no-arg command like "ctrlC" or "done" + String value = ((ScalarNode) item).getValue(); + if (!registry.isNoArgCommand(value) && !registry.isCommand(value)) { + diagnostics.add(createDiagnosticFromNode( + item, + "Unknown command: '" + value + "'", + DiagnosticSeverity.Error + )); + } + } else if (item instanceof MappingNode) { + validateCommandMapping((MappingNode) item, diagnostics); + } + } + } + + private void validateCommandMapping(MappingNode commandNode, List diagnostics) { + boolean foundCommand = false; + for (NodeTuple tuple : commandNode.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if (key == null) continue; + + if (registry.isCommand(key)) { + foundCommand = true; + } else if (!registry.getModifierKeys().contains(key) && !"else".equals(key)) { + diagnostics.add(createDiagnosticFromNode( + tuple.getKeyNode(), + "Unknown command or modifier: '" + key + "'", + DiagnosticSeverity.Error + )); + } + + // Validate nested command sequences in modifiers + if ("then".equals(key) || "watch".equals(key) || "else".equals(key)) { + if (tuple.getValueNode() instanceof SequenceNode) { + validateCommandSequence((SequenceNode) tuple.getValueNode(), diagnostics); + } + } + if ("on-signal".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode signals = (MappingNode) tuple.getValueNode(); + for (NodeTuple signalTuple : signals.getValue()) { + if (signalTuple.getValueNode() instanceof SequenceNode) { + validateCommandSequence((SequenceNode) signalTuple.getValueNode(), diagnostics); + } + } + } + if ("timer".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode timers = (MappingNode) tuple.getValueNode(); + for (NodeTuple timerTuple : timers.getValue()) { + if (timerTuple.getValueNode() instanceof SequenceNode) { + validateCommandSequence((SequenceNode) timerTuple.getValueNode(), diagnostics); + } + } + } + } + + if (!foundCommand) { + // Check if it's just modifiers without a command + diagnostics.add(createDiagnosticFromNode( + commandNode, + "No recognized command found in mapping", + DiagnosticSeverity.Warning + )); + } + } + + private void validateHosts(MappingNode root, List diagnostics) { + for (NodeTuple tuple : root.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if ("hosts".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode hosts = (MappingNode) tuple.getValueNode(); + for (NodeTuple hostTuple : hosts.getValue()) { + if (hostTuple.getValueNode() instanceof MappingNode) { + validateHostConfig((MappingNode) hostTuple.getValueNode(), diagnostics); + } + // Scalar values like "user@host:port" are valid too + } + } + } + } + + private void validateHostConfig(MappingNode hostNode, List diagnostics) { + List validKeys = registry.getHostConfigKeys(); + for (NodeTuple tuple : hostNode.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if (key != null && !validKeys.contains(key)) { + diagnostics.add(createDiagnosticFromNode( + tuple.getKeyNode(), + "Unknown host configuration key: '" + key + "'", + DiagnosticSeverity.Error + )); + } + } + } + + private void validateRoles(MappingNode root, QDupDocument document, Collection allDocs, List diagnostics) { + Set definedScripts = new LinkedHashSet<>(document.getScriptNames()); + Set definedHosts = new LinkedHashSet<>(document.getHostNames()); + for (QDupDocument other : allDocs) { + if (!other.getUri().equals(document.getUri()) && other.isParseSuccessful()) { + definedScripts.addAll(other.getScriptNames()); + definedHosts.addAll(other.getHostNames()); + } + } + + for (NodeTuple tuple : root.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if ("roles".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode roles = (MappingNode) tuple.getValueNode(); + for (NodeTuple roleTuple : roles.getValue()) { + if (roleTuple.getValueNode() instanceof MappingNode) { + MappingNode role = (MappingNode) roleTuple.getValueNode(); + validateRoleKeys(role, diagnostics); + validateRoleReferences(role, definedScripts, definedHosts, diagnostics); + } + } + } + } + } + + private void validateRoleKeys(MappingNode role, List diagnostics) { + Set validKeys = registry.getRoleKeys(); + for (NodeTuple tuple : role.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if (key != null && !validKeys.contains(key)) { + diagnostics.add(createDiagnosticFromNode( + tuple.getKeyNode(), + "Unknown role key: '" + key + "'. Valid keys: " + validKeys, + DiagnosticSeverity.Error + )); + } + } + } + + private void validateRoleReferences(MappingNode role, Set definedScripts, Set definedHosts, List diagnostics) { + for (NodeTuple tuple : role.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if (key == null) continue; + + if (key.endsWith("-scripts")) { + validateReferences(tuple.getValueNode(), definedScripts, "script", diagnostics); + } else if ("hosts".equals(key)) { + validateReferences(tuple.getValueNode(), definedHosts, "host", diagnostics); + } + } + } + + private void validateReferences(Node valueNode, Set defined, String kind, List diagnostics) { + if (valueNode instanceof SequenceNode) { + for (Node item : ((SequenceNode) valueNode).getValue()) { + String name = QDupDocument.scalarValue(item); + if (name != null && !defined.contains(name)) { + diagnostics.add(createDiagnosticFromNode( + item, + "Undefined " + kind + " reference: '" + name + "'", + DiagnosticSeverity.Warning + )); + } + } + } else if (valueNode instanceof ScalarNode) { + String name = QDupDocument.scalarValue(valueNode); + if (name != null && !defined.contains(name)) { + diagnostics.add(createDiagnosticFromNode( + valueNode, + "Undefined " + kind + " reference: '" + name + "'", + DiagnosticSeverity.Warning + )); + } + } + } + + private void checkUnusedScripts(QDupDocument document, Collection allDocs, List diagnostics) { + Set defined = document.getScriptNames(); + Set referenced = new LinkedHashSet<>(document.getReferencedScripts()); + for (QDupDocument other : allDocs) { + if (!other.getUri().equals(document.getUri()) && other.isParseSuccessful()) { + referenced.addAll(other.getReferencedScripts()); + } + } + for (String script : defined) { + if (!referenced.contains(script)) { + // Find the node for this script name to get position + if (document.getRootNode() instanceof MappingNode) { + MappingNode root = (MappingNode) document.getRootNode(); + for (NodeTuple tuple : root.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if ("scripts".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode scripts = (MappingNode) tuple.getValueNode(); + for (NodeTuple scriptTuple : scripts.getValue()) { + String name = QDupDocument.scalarValue(scriptTuple.getKeyNode()); + if (script.equals(name)) { + diagnostics.add(createDiagnosticFromNode( + scriptTuple.getKeyNode(), + "Script '" + script + "' is defined but not referenced in any role", + DiagnosticSeverity.Information + )); + } + } + } + } + } + } + } + } + + private void checkUnusedHosts(QDupDocument document, Collection allDocs, List diagnostics) { + Set defined = document.getHostNames(); + Set referenced = new LinkedHashSet<>(document.getReferencedHosts()); + for (QDupDocument other : allDocs) { + if (!other.getUri().equals(document.getUri()) && other.isParseSuccessful()) { + referenced.addAll(other.getReferencedHosts()); + } + } + for (String host : defined) { + if (!referenced.contains(host)) { + if (document.getRootNode() instanceof MappingNode) { + MappingNode root = (MappingNode) document.getRootNode(); + for (NodeTuple tuple : root.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if ("hosts".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode hosts = (MappingNode) tuple.getValueNode(); + for (NodeTuple hostTuple : hosts.getValue()) { + String name = QDupDocument.scalarValue(hostTuple.getKeyNode()); + if (host.equals(name)) { + diagnostics.add(createDiagnosticFromNode( + hostTuple.getKeyNode(), + "Host '" + host + "' is defined but not referenced in any role", + DiagnosticSeverity.Information + )); + } + } + } + } + } + } + } + } + + private Diagnostic createDiagnostic(int startLine, int startChar, int endLine, int endChar, String message, DiagnosticSeverity severity) { + Diagnostic diag = new Diagnostic(); + diag.setRange(new Range(new Position(startLine, startChar), new Position(endLine, endChar))); + diag.setMessage(message); + diag.setSeverity(severity); + diag.setSource("qDup"); + return diag; + } + + private Diagnostic createDiagnosticFromNode(Node node, String message, DiagnosticSeverity severity) { + int startLine = node.getStartMark() != null ? node.getStartMark().getLine() : 0; + int startCol = node.getStartMark() != null ? node.getStartMark().getColumn() : 0; + int endLine = node.getEndMark() != null ? node.getEndMark().getLine() : startLine; + int endCol = node.getEndMark() != null ? node.getEndMark().getColumn() : startCol + 1; + return createDiagnostic(startLine, startCol, endLine, endCol, message, severity); + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DocumentSymbolProvider.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DocumentSymbolProvider.java new file mode 100644 index 00000000..2f3de420 --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/DocumentSymbolProvider.java @@ -0,0 +1,163 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.DocumentSymbol; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.SymbolKind; +import org.yaml.snakeyaml.nodes.*; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Provides document symbols (outline) for qDup YAML documents. + * Builds a hierarchical symbol tree with sections, scripts, hosts, roles, and states. + */ +public class DocumentSymbolProvider { + + /** + * Returns a hierarchical list of DocumentSymbols for the given document. + */ + public List documentSymbols(QDupDocument doc) { + if (!doc.isParseSuccessful() || !(doc.getRootNode() instanceof MappingNode)) { + return Collections.emptyList(); + } + + MappingNode root = (MappingNode) doc.getRootNode(); + List symbols = new ArrayList<>(); + + for (NodeTuple tuple : root.getValue()) { + String key = QDupDocument.scalarValue(tuple.getKeyNode()); + if (key == null) { + continue; + } + + Node keyNode = tuple.getKeyNode(); + Node valueNode = tuple.getValueNode(); + Range range = nodeRange(keyNode, valueNode); + Range selectionRange = nodeRange(keyNode); + + switch (key) { + case "scripts": + symbols.add(buildSectionSymbol(key, SymbolKind.Namespace, range, selectionRange, + valueNode, SymbolKind.Function)); + break; + + case "hosts": + symbols.add(buildSectionSymbol(key, SymbolKind.Namespace, range, selectionRange, + valueNode, SymbolKind.Property)); + break; + + case "roles": + symbols.add(buildRolesSymbol(key, range, selectionRange, valueNode)); + break; + + case "states": + case "globals": + symbols.add(buildSectionSymbol(key, SymbolKind.Namespace, range, selectionRange, + valueNode, SymbolKind.Variable)); + break; + + default: + // Top-level keys like "name" + DocumentSymbol sym = new DocumentSymbol(key, SymbolKind.Property, range, selectionRange); + symbols.add(sym); + break; + } + } + + return symbols; + } + + /** + * Builds a section symbol with children from a MappingNode. + */ + private DocumentSymbol buildSectionSymbol(String name, SymbolKind sectionKind, + Range range, Range selectionRange, + Node valueNode, SymbolKind childKind) { + DocumentSymbol section = new DocumentSymbol(name, sectionKind, range, selectionRange); + List children = new ArrayList<>(); + + if (valueNode instanceof MappingNode) { + MappingNode mapping = (MappingNode) valueNode; + for (NodeTuple entry : mapping.getValue()) { + String entryName = QDupDocument.scalarValue(entry.getKeyNode()); + if (entryName != null) { + Range entryRange = nodeRange(entry.getKeyNode(), entry.getValueNode()); + Range entrySelRange = nodeRange(entry.getKeyNode()); + children.add(new DocumentSymbol(entryName, childKind, entryRange, entrySelRange)); + } + } + } + + section.setChildren(children); + return section; + } + + /** + * Builds the roles section symbol with role entries and their properties as grandchildren. + */ + private DocumentSymbol buildRolesSymbol(String name, Range range, Range selectionRange, Node valueNode) { + DocumentSymbol section = new DocumentSymbol(name, SymbolKind.Namespace, range, selectionRange); + List roleChildren = new ArrayList<>(); + + if (valueNode instanceof MappingNode) { + MappingNode rolesMapping = (MappingNode) valueNode; + for (NodeTuple roleTuple : rolesMapping.getValue()) { + String roleName = QDupDocument.scalarValue(roleTuple.getKeyNode()); + if (roleName == null) { + continue; + } + + Range roleRange = nodeRange(roleTuple.getKeyNode(), roleTuple.getValueNode()); + Range roleSelRange = nodeRange(roleTuple.getKeyNode()); + DocumentSymbol roleSym = new DocumentSymbol(roleName, SymbolKind.Class, roleRange, roleSelRange); + + // Add role properties as grandchildren + List propChildren = new ArrayList<>(); + if (roleTuple.getValueNode() instanceof MappingNode) { + MappingNode roleMapping = (MappingNode) roleTuple.getValueNode(); + for (NodeTuple propTuple : roleMapping.getValue()) { + String propName = QDupDocument.scalarValue(propTuple.getKeyNode()); + if (propName != null) { + Range propRange = nodeRange(propTuple.getKeyNode(), propTuple.getValueNode()); + Range propSelRange = nodeRange(propTuple.getKeyNode()); + propChildren.add(new DocumentSymbol(propName, SymbolKind.Property, propRange, propSelRange)); + } + } + } + roleSym.setChildren(propChildren); + roleChildren.add(roleSym); + } + } + + section.setChildren(roleChildren); + return section; + } + + /** + * Creates a Range spanning from keyNode start to valueNode end. + */ + private Range nodeRange(Node keyNode, Node valueNode) { + Position start = markToPosition(keyNode.getStartMark()); + Position end = markToPosition(valueNode.getEndMark()); + return new Range(start, end); + } + + /** + * Creates a Range covering just the given node. + */ + private Range nodeRange(Node node) { + Position start = markToPosition(node.getStartMark()); + Position end = markToPosition(node.getEndMark()); + return new Range(start, end); + } + + private Position markToPosition(org.yaml.snakeyaml.error.Mark mark) { + if (mark == null) { + return new Position(0, 0); + } + return new Position(mark.getLine(), mark.getColumn()); + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/HoverProvider.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/HoverProvider.java new file mode 100644 index 00000000..8e1a93ec --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/HoverProvider.java @@ -0,0 +1,277 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.Hover; +import org.eclipse.lsp4j.MarkupContent; +import org.eclipse.lsp4j.MarkupKind; + +import java.util.Collection; +import java.util.Properties; + +/** + * Produces Hover content for qDup YAML elements. + */ +public class HoverProvider { + + private final CommandRegistry registry; + private final CursorContextResolver contextResolver; + private final Properties commandDocs; + + public HoverProvider(CommandRegistry registry, CursorContextResolver contextResolver, Properties commandDocs) { + this.registry = registry; + this.contextResolver = contextResolver; + this.commandDocs = commandDocs; + } + + /** + * Provides hover information for the given position, searching across all workspace documents. + */ + public Hover hover(QDupDocument document, int line, int character, Collection allDocs) { + String currentLine = document.getLine(line); + + // Check for state variable pattern ${{...}} + String varName = DefinitionProvider.extractVariableAt(currentLine, character); + if (varName != null) { + String content = getStateVariableHover(varName, document, allDocs); + if (content != null) { + MarkupContent markup = new MarkupContent(); + markup.setKind(MarkupKind.MARKDOWN); + markup.setValue(content); + return new Hover(markup); + } + } + + return hover(document, line, character); + } + + private String getStateVariableHover(String varName, QDupDocument currentDoc, Collection allDocs) { + // Search current document first + String value = currentDoc.getStateValue(varName); + if (value != null) { + return "**${{" + varName + "}}**\n\nValue: `" + value + "`\n\nSource: " + shortUri(currentDoc.getUri()); + } + if (currentDoc.findStateNode(varName) != null) { + return "**${{" + varName + "}}**\n\nState variable\n\nSource: " + shortUri(currentDoc.getUri()); + } + + // Search other workspace documents + if (allDocs != null) { + for (QDupDocument other : allDocs) { + if (other.getUri().equals(currentDoc.getUri()) || !other.isParseSuccessful()) { + continue; + } + value = other.getStateValue(varName); + if (value != null) { + return "**${{" + varName + "}}**\n\nValue: `" + value + "`\n\nSource: " + shortUri(other.getUri()); + } + if (other.findStateNode(varName) != null) { + return "**${{" + varName + "}}**\n\nState variable\n\nSource: " + shortUri(other.getUri()); + } + } + } + + return "**${{" + varName + "}}**\n\nState variable (undefined)"; + } + + private String shortUri(String uri) { + int lastSlash = uri.lastIndexOf('/'); + return lastSlash >= 0 ? uri.substring(lastSlash + 1) : uri; + } + + /** + * Provides hover information for the given position in the document. + */ + public Hover hover(QDupDocument document, int line, int character) { + String currentLine = document.getLine(line); + String word = extractWordAt(currentLine, character); + + if (word == null || word.isEmpty()) { + return null; + } + + YamlContext context = contextResolver.resolve(document, line, character); + + String content = null; + + switch (context) { + case SCRIPT_COMMAND_KEY: + case SCRIPT_COMMAND_VALUE: + if (registry.isCommand(word)) { + content = getCommandHover(word); + } + break; + + case COMMAND_MODIFIER_KEY: + content = getModifierHover(word); + break; + + case COMMAND_PARAM_KEY: + String cmdName = contextResolver.findCommandAtLine(document, line); + if (cmdName != null) { + content = getParamHover(cmdName, word); + } + if (content == null && registry.isCommand(word)) { + content = getCommandHover(word); + } + if (content == null) { + content = getModifierHover(word); + } + break; + + case HOST_CONFIG_KEY: + content = getHostKeyHover(word); + break; + + case TOP_LEVEL_KEY: + content = getTopLevelKeyHover(word); + break; + + case ROLE_KEY: + content = getRoleKeyHover(word); + break; + + default: + // Try command name hover as fallback + if (registry.isCommand(word)) { + content = getCommandHover(word); + } + break; + } + + if (content != null) { + MarkupContent markup = new MarkupContent(); + markup.setKind(MarkupKind.MARKDOWN); + markup.setValue(content); + return new Hover(markup); + } + + return null; + } + + private String getCommandHover(String command) { + String doc = commandDocs.getProperty(command); + if (doc != null) { + return "**" + command + "**\n\n" + doc; + } + if (registry.isNoArgCommand(command)) { + return "**" + command + "** (no arguments)\n\nqDup command"; + } + return "**" + command + "**\n\nqDup command"; + } + + private String getModifierHover(String modifier) { + return switch (modifier) { + case "then" -> "**then**\n\nCommands to run when the parent command succeeds. " + + "The commands are executed sequentially after the parent completes."; + case "else" -> "**else**\n\nCommands to run when the parent command does not match or fails. " + + "Used with commands like `regex` and `read-state`."; + case "with" -> "**with**\n\nSet state variables for this command and its children. " + + "Accepts a map of key-value pairs."; + case "watch" -> "**watch**\n\nCommands to run concurrently while this command executes. " + + "Watchers observe the command's output in real time."; + case "timer" -> "**timer**\n\nCommands to run after a specified timeout. " + + "Accepts a map of duration to command list."; + case "on-signal" -> "**on-signal**\n\nCommands to run when a specific signal is received. " + + "Accepts a map of signal name to command list."; + case "silent" -> "**silent**\n\nSuppress command output in the qDup log. " + + "Set to `true` to silence output."; + case "prefix" -> "**prefix**\n\nOverride the state variable prefix pattern (default `${{`)."; + case "suffix" -> "**suffix**\n\nOverride the state variable suffix pattern (default `}}`)."; + case "separator" -> "**separator**\n\nOverride the state variable default value separator (default `:`)."; + case "js-prefix" -> "**js-prefix**\n\nOverride the JavaScript evaluation prefix (default `=`)."; + case "idle-timer" -> "**idle-timer**\n\nSet or disable the idle timer for this command. " + + "Set to `false` to disable, or a duration string to customize."; + case "state-scan" -> "**state-scan**\n\nEnable or disable state variable scanning in command output. " + + "Set to `false` to disable automatic state variable detection."; + default -> null; + }; + } + + private String getParamHover(String command, String param) { + String key = command + "." + param; + String doc = commandDocs.getProperty(key); + if (doc != null) { + return "**" + param + "** (parameter of `" + command + "`)\n\n" + doc; + } + return null; + } + + private String getHostKeyHover(String key) { + return switch (key) { + case "username" -> "**username**\n\nSSH username for connecting to the host."; + case "hostname" -> "**hostname**\n\nHostname or IP address of the remote host."; + case "password" -> "**password**\n\nSSH password for authentication."; + case "port" -> "**port**\n\nSSH port number (default: 22)."; + case "prompt" -> "**prompt**\n\nExpected shell prompt pattern for the host."; + case "local" -> "**local**\n\nSet to `true` to use a local shell instead of SSH."; + case "platform" -> "**platform**\n\nContainer platform to use (e.g., `podman`, `docker`)."; + case "container" -> "**container**\n\nContainer name or image to use."; + case "identity" -> "**identity**\n\nPath to SSH identity (private key) file."; + case "upload" -> "**upload**\n\nCustom upload command template."; + case "download" -> "**download**\n\nCustom download command template."; + case "exec" -> "**exec**\n\nCustom exec command template."; + case "is-shell" -> "**is-shell**\n\nWhether the connection provides a shell (default: true)."; + case "connect-shell" -> "**connect-shell**\n\nCommand to establish a shell connection to the host."; + case "platform-login" -> "**platform-login**\n\nCommand to log in to the container platform."; + case "create-container" -> "**create-container**\n\nCommand to create a new container."; + case "create-connected-container" -> "**create-connected-container**\n\nCommand to create and connect to a container."; + case "restart-connected-container" -> "**restart-connected-container**\n\nCommand to restart and reconnect to a container."; + case "stop-container" -> "**stop-container**\n\nCommand to stop a running container."; + case "check-container-id" -> "**check-container-id**\n\nCommand to check the container ID."; + case "check-container-name" -> "**check-container-name**\n\nCommand to check the container name."; + default -> "**" + key + "**\n\nHost configuration key."; + }; + } + + private String getTopLevelKeyHover(String key) { + return switch (key) { + case "name" -> "**name**\n\nThe name of this qDup configuration."; + case "scripts" -> "**scripts**\n\nDefines named scripts containing sequences of commands."; + case "hosts" -> "**hosts**\n\nDefines named host configurations for SSH connections."; + case "roles" -> "**roles**\n\nAssigns scripts to hosts for execution. " + + "Each role specifies hosts and which scripts to run."; + case "states" -> "**states**\n\nDefines initial state variables available to scripts."; + case "globals" -> "**globals**\n\nDefines global state variables shared across all scripts."; + default -> null; + }; + } + + private String getRoleKeyHover(String key) { + return switch (key) { + case "hosts" -> "**hosts**\n\nList of host names (defined in `hosts:`) to run this role's scripts on."; + case "setup-scripts" -> "**setup-scripts**\n\nScripts to run during the setup phase (before run-scripts)."; + case "run-scripts" -> "**run-scripts**\n\nScripts to run during the main execution phase."; + case "cleanup-scripts" -> "**cleanup-scripts**\n\nScripts to run during the cleanup phase (after run-scripts)."; + default -> null; + }; + } + + /** + * Extracts the word at the given character position in a line. + */ + private String extractWordAt(String line, int character) { + if (line == null || character < 0 || character > line.length()) { + return null; + } + + // Find word boundaries + int start = character; + int end = character; + + while (start > 0 && isWordChar(line.charAt(start - 1))) { + start--; + } + while (end < line.length() && isWordChar(line.charAt(end))) { + end++; + } + + if (start == end) { + return null; + } + + return line.substring(start, end); + } + + private boolean isWordChar(char c) { + return Character.isLetterOrDigit(c) || c == '-' || c == '_' || c == '/' || c == '\\'; + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupDocument.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupDocument.java new file mode 100644 index 00000000..7278a6b2 --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupDocument.java @@ -0,0 +1,355 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.nodes.*; + +import java.util.*; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Represents a parsed qDup YAML document. + * Maintains both the raw text and the SnakeYAML Node tree for structural analysis. + */ +public class QDupDocument { + + private static final Logger LOG = Logger.getLogger(QDupDocument.class.getName()); + + private String uri; + private String text; + private String[] lines; + private Node rootNode; + private boolean parseSuccessful; + + public QDupDocument(String uri, String text) { + this.uri = uri; + setText(text); + } + + public void setText(String text) { + this.text = text; + this.lines = text.split("\n", -1); + parse(); + } + + private void parse() { + try { + LoaderOptions options = new LoaderOptions(); + options.setProcessComments(false); + Yaml yaml = new Yaml(options); + this.rootNode = yaml.compose(new java.io.StringReader(text)); + this.parseSuccessful = (rootNode != null); + } catch (Exception e) { + LOG.log(Level.FINE, "YAML parse failed for " + uri, e); + this.rootNode = null; + this.parseSuccessful = false; + } + } + + public String getUri() { + return uri; + } + + public String getText() { + return text; + } + + public String[] getLines() { + return lines; + } + + public String getLine(int lineIndex) { + if (lineIndex >= 0 && lineIndex < lines.length) { + return lines[lineIndex]; + } + return ""; + } + + public Node getRootNode() { + return rootNode; + } + + public boolean isParseSuccessful() { + return parseSuccessful; + } + + /** + * Extracts script names defined under the "scripts:" top-level key. + */ + public Set getScriptNames() { + Set names = new LinkedHashSet<>(); + if (rootNode instanceof MappingNode) { + MappingNode root = (MappingNode) rootNode; + for (NodeTuple tuple : root.getValue()) { + String key = scalarValue(tuple.getKeyNode()); + if ("scripts".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode scripts = (MappingNode) tuple.getValueNode(); + for (NodeTuple scriptTuple : scripts.getValue()) { + String scriptName = scalarValue(scriptTuple.getKeyNode()); + if (scriptName != null) { + names.add(scriptName); + } + } + } + } + } + return names; + } + + /** + * Extracts host names defined under the "hosts:" top-level key. + */ + public Set getHostNames() { + Set names = new LinkedHashSet<>(); + if (rootNode instanceof MappingNode) { + MappingNode root = (MappingNode) rootNode; + for (NodeTuple tuple : root.getValue()) { + String key = scalarValue(tuple.getKeyNode()); + if ("hosts".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode hosts = (MappingNode) tuple.getValueNode(); + for (NodeTuple hostTuple : hosts.getValue()) { + String hostName = scalarValue(hostTuple.getKeyNode()); + if (hostName != null) { + names.add(hostName); + } + } + } + } + } + return names; + } + + /** + * Extracts state keys defined under the "states:" top-level key. + */ + public Set getStateKeys() { + Set keys = new LinkedHashSet<>(); + if (rootNode instanceof MappingNode) { + MappingNode root = (MappingNode) rootNode; + for (NodeTuple tuple : root.getValue()) { + String key = scalarValue(tuple.getKeyNode()); + if ("states".equals(key) && tuple.getValueNode() instanceof MappingNode) { + collectKeys((MappingNode) tuple.getValueNode(), "", keys); + } + } + } + return keys; + } + + private void collectKeys(MappingNode node, String prefix, Set keys) { + for (NodeTuple tuple : node.getValue()) { + String key = scalarValue(tuple.getKeyNode()); + if (key != null) { + String fullKey = prefix.isEmpty() ? key : prefix + "." + key; + keys.add(fullKey); + if (tuple.getValueNode() instanceof MappingNode) { + collectKeys((MappingNode) tuple.getValueNode(), fullKey, keys); + } + } + } + } + + /** + * Extracts host references from roles. + */ + public Set getReferencedHosts() { + Set refs = new LinkedHashSet<>(); + if (rootNode instanceof MappingNode) { + MappingNode root = (MappingNode) rootNode; + for (NodeTuple tuple : root.getValue()) { + String key = scalarValue(tuple.getKeyNode()); + if ("roles".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode roles = (MappingNode) tuple.getValueNode(); + for (NodeTuple roleTuple : roles.getValue()) { + if (roleTuple.getValueNode() instanceof MappingNode) { + MappingNode role = (MappingNode) roleTuple.getValueNode(); + for (NodeTuple roleProp : role.getValue()) { + String propKey = scalarValue(roleProp.getKeyNode()); + if ("hosts".equals(propKey)) { + collectSequenceValues(roleProp.getValueNode(), refs); + } + } + } + } + } + } + } + return refs; + } + + /** + * Extracts script references from roles. + */ + public Set getReferencedScripts() { + Set refs = new LinkedHashSet<>(); + if (rootNode instanceof MappingNode) { + MappingNode root = (MappingNode) rootNode; + for (NodeTuple tuple : root.getValue()) { + String key = scalarValue(tuple.getKeyNode()); + if ("roles".equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode roles = (MappingNode) tuple.getValueNode(); + for (NodeTuple roleTuple : roles.getValue()) { + if (roleTuple.getValueNode() instanceof MappingNode) { + MappingNode role = (MappingNode) roleTuple.getValueNode(); + for (NodeTuple roleProp : role.getValue()) { + String propKey = scalarValue(roleProp.getKeyNode()); + if (propKey != null && propKey.endsWith("-scripts")) { + collectSequenceValues(roleProp.getValueNode(), refs); + } + } + } + } + } + } + } + return refs; + } + + private void collectSequenceValues(Node node, Set values) { + if (node instanceof SequenceNode) { + for (Node item : ((SequenceNode) node).getValue()) { + String val = scalarValue(item); + if (val != null) { + values.add(val); + } + } + } else if (node instanceof ScalarNode) { + String val = scalarValue(node); + if (val != null) { + values.add(val); + } + } + } + + /** + * Finds the key Node for the named script under the "scripts:" top-level key. + * Returns null if not found. + */ + public Node findScriptNode(String name) { + return findTopLevelEntryKeyNode("scripts", name); + } + + /** + * Finds the key Node for the named host under the "hosts:" top-level key. + * Returns null if not found. + */ + public Node findHostNode(String name) { + return findTopLevelEntryKeyNode("hosts", name); + } + + /** + * Finds the key Node for the named state variable under "states:" or "globals:". + * Searches flat keys first, then nested keys (e.g., "server.FOO"). + * Returns null if not found. + */ + public Node findStateNode(String name) { + if (name == null || rootNode == null || !(rootNode instanceof MappingNode)) { + return null; + } + MappingNode root = (MappingNode) rootNode; + for (NodeTuple tuple : root.getValue()) { + String key = scalarValue(tuple.getKeyNode()); + if (("states".equals(key) || "globals".equals(key)) && tuple.getValueNode() instanceof MappingNode) { + MappingNode section = (MappingNode) tuple.getValueNode(); + // Search flat keys first + for (NodeTuple entryTuple : section.getValue()) { + String entryName = scalarValue(entryTuple.getKeyNode()); + if (name.equals(entryName)) { + return entryTuple.getKeyNode(); + } + } + // Search nested keys (e.g., name "server.FOO" -> parent "server", child "FOO") + int dotIndex = name.indexOf('.'); + if (dotIndex > 0) { + String parent = name.substring(0, dotIndex); + String child = name.substring(dotIndex + 1); + for (NodeTuple entryTuple : section.getValue()) { + String entryName = scalarValue(entryTuple.getKeyNode()); + if (parent.equals(entryName) && entryTuple.getValueNode() instanceof MappingNode) { + MappingNode nested = (MappingNode) entryTuple.getValueNode(); + for (NodeTuple nestedTuple : nested.getValue()) { + String nestedName = scalarValue(nestedTuple.getKeyNode()); + if (child.equals(nestedName)) { + return nestedTuple.getKeyNode(); + } + } + } + } + } + } + } + return null; + } + + /** + * Returns the scalar value of a state variable, or null if not found or not scalar. + */ + public String getStateValue(String name) { + if (name == null || rootNode == null || !(rootNode instanceof MappingNode)) { + return null; + } + MappingNode root = (MappingNode) rootNode; + for (NodeTuple tuple : root.getValue()) { + String key = scalarValue(tuple.getKeyNode()); + if (("states".equals(key) || "globals".equals(key)) && tuple.getValueNode() instanceof MappingNode) { + MappingNode section = (MappingNode) tuple.getValueNode(); + for (NodeTuple entryTuple : section.getValue()) { + String entryName = scalarValue(entryTuple.getKeyNode()); + if (name.equals(entryName)) { + return scalarValue(entryTuple.getValueNode()); + } + } + // Search nested keys + int dotIndex = name.indexOf('.'); + if (dotIndex > 0) { + String parent = name.substring(0, dotIndex); + String child = name.substring(dotIndex + 1); + for (NodeTuple entryTuple : section.getValue()) { + String entryName = scalarValue(entryTuple.getKeyNode()); + if (parent.equals(entryName) && entryTuple.getValueNode() instanceof MappingNode) { + MappingNode nested = (MappingNode) entryTuple.getValueNode(); + for (NodeTuple nestedTuple : nested.getValue()) { + String nestedName = scalarValue(nestedTuple.getKeyNode()); + if (child.equals(nestedName)) { + return scalarValue(nestedTuple.getValueNode()); + } + } + } + } + } + } + } + return null; + } + + /** + * Finds the key Node for a named entry under a given top-level section. + */ + private Node findTopLevelEntryKeyNode(String section, String name) { + if (name == null || rootNode == null || !(rootNode instanceof MappingNode)) { + return null; + } + MappingNode root = (MappingNode) rootNode; + for (NodeTuple tuple : root.getValue()) { + String key = scalarValue(tuple.getKeyNode()); + if (section.equals(key) && tuple.getValueNode() instanceof MappingNode) { + MappingNode sectionNode = (MappingNode) tuple.getValueNode(); + for (NodeTuple entryTuple : sectionNode.getValue()) { + String entryName = scalarValue(entryTuple.getKeyNode()); + if (name.equals(entryName)) { + return entryTuple.getKeyNode(); + } + } + } + } + return null; + } + + static String scalarValue(Node node) { + if (node instanceof ScalarNode) { + return ((ScalarNode) node).getValue(); + } + return null; + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupLanguageServer.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupLanguageServer.java new file mode 100644 index 00000000..bb0c7f16 --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupLanguageServer.java @@ -0,0 +1,131 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.*; +import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.services.LanguageClientAware; +import org.eclipse.lsp4j.services.LanguageServer; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.eclipse.lsp4j.services.WorkspaceService; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * qDup Language Server implementation. + * Provides completion, hover, and diagnostics for qDup YAML files. + */ +public class QDupLanguageServer implements LanguageServer, LanguageClientAware { + + private static final Logger LOG = Logger.getLogger(QDupLanguageServer.class.getName()); + + private final QDupTextDocumentService textDocumentService; + private final QDupWorkspaceService workspaceService; + private LanguageClient client; + private int errorCode = 1; + + public QDupLanguageServer() { + CommandRegistry registry = new CommandRegistry(); + Properties commandDocs = loadCommandDocs(); + this.textDocumentService = new QDupTextDocumentService(registry, commandDocs); + this.workspaceService = new QDupWorkspaceService(); + this.workspaceService.setFileChangeHandler(textDocumentService::handleFileChange); + } + + private Properties loadCommandDocs() { + Properties props = new Properties(); + try (InputStream is = getClass().getClassLoader().getResourceAsStream("command-docs.properties")) { + if (is != null) { + props.load(is); + } else { + LOG.warning("command-docs.properties not found in classpath"); + } + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to load command-docs.properties", e); + } + return props; + } + + @Override + public CompletableFuture initialize(InitializeParams params) { + ServerCapabilities capabilities = new ServerCapabilities(); + + // Text document sync - full document sync + capabilities.setTextDocumentSync(TextDocumentSyncKind.Full); + + // Completion support + CompletionOptions completionOptions = new CompletionOptions(); + completionOptions.setTriggerCharacters(java.util.List.of(":", "-", " ")); + completionOptions.setResolveProvider(false); + capabilities.setCompletionProvider(completionOptions); + + // Hover support + capabilities.setHoverProvider(true); + + // Go-to-definition support + capabilities.setDefinitionProvider(true); + + // Document symbols (outline) support + capabilities.setDocumentSymbolProvider(true); + + InitializeResult result = new InitializeResult(capabilities); + ServerInfo serverInfo = new ServerInfo("qDup Language Server", getVersion()); + result.setServerInfo(serverInfo); + + // Scan workspace for cross-file go-to-definition + String rootUri = params.getRootUri(); + if (rootUri == null && params.getWorkspaceFolders() != null && !params.getWorkspaceFolders().isEmpty()) { + rootUri = params.getWorkspaceFolders().get(0).getUri(); + } + if (rootUri != null) { + textDocumentService.scanWorkspace(rootUri); + } + + LOG.info("qDup Language Server initialized"); + return CompletableFuture.completedFuture(result); + } + + private String getVersion() { + try (InputStream is = getClass().getClassLoader().getResourceAsStream("lsp-version.properties")) { + if (is != null) { + Properties versionProps = new Properties(); + versionProps.load(is); + return versionProps.getProperty("lsp.version", "unknown"); + } + } catch (IOException e) { + LOG.log(Level.FINE, "Failed to read lsp-version.properties", e); + } + return "unknown"; + } + + @Override + public CompletableFuture shutdown() { + errorCode = 0; + return CompletableFuture.completedFuture(null); + } + + @Override + public void exit() { + System.exit(errorCode); + } + + @Override + public TextDocumentService getTextDocumentService() { + return textDocumentService; + } + + @Override + public WorkspaceService getWorkspaceService() { + return workspaceService; + } + + @Override + public void connect(LanguageClient client) { + this.client = client; + this.textDocumentService.setClient(client); + LOG.info("Language client connected"); + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupLspLauncher.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupLspLauncher.java new file mode 100644 index 00000000..09cb5b24 --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupLspLauncher.java @@ -0,0 +1,46 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.jsonrpc.Launcher; +import org.eclipse.lsp4j.launch.LSPLauncher; +import org.eclipse.lsp4j.services.LanguageClient; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.Future; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Entry point for the qDup Language Server. + * Launches the LSP server using stdin/stdout JSON-RPC communication. + */ +public class QDupLspLauncher { + + private static final Logger LOG = Logger.getLogger(QDupLspLauncher.class.getName()); + + public static void main(String[] args) { + LOG.info("Starting qDup Language Server..."); + + InputStream in = System.in; + OutputStream out = System.out; + + // Redirect stderr for logging so it doesn't interfere with JSON-RPC on stdout + System.setOut(System.err); + + QDupLanguageServer server = new QDupLanguageServer(); + + Launcher launcher = LSPLauncher.createServerLauncher(server, in, out); + LanguageClient client = launcher.getRemoteProxy(); + server.connect(client); + + Future startListening = launcher.startListening(); + + LOG.info("qDup Language Server is listening"); + + try { + startListening.get(); + } catch (Exception e) { + LOG.log(Level.SEVERE, "qDup Language Server error", e); + } + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupTextDocumentService.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupTextDocumentService.java new file mode 100644 index 00000000..ececffaf --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupTextDocumentService.java @@ -0,0 +1,248 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.*; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.services.TextDocumentService; + +import java.io.IOException; +import java.net.URI; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Text document service for qDup LSP. + * Handles document lifecycle events and provides completion, hover, and diagnostics. + */ +public class QDupTextDocumentService implements TextDocumentService { + + private static final Logger LOG = Logger.getLogger(QDupTextDocumentService.class.getName()); + + private final Map documents = new ConcurrentHashMap<>(); + private final Map workspaceDocuments = new ConcurrentHashMap<>(); + private final CompletionProvider completionProvider; + private final DiagnosticsProvider diagnosticsProvider; + private final HoverProvider hoverProvider; + private final DefinitionProvider definitionProvider; + private final DocumentSymbolProvider documentSymbolProvider; + private LanguageClient client; + + public QDupTextDocumentService(CommandRegistry registry, Properties commandDocs) { + CursorContextResolver contextResolver = new CursorContextResolver(registry); + this.completionProvider = new CompletionProvider(registry, contextResolver, commandDocs); + this.diagnosticsProvider = new DiagnosticsProvider(registry); + this.hoverProvider = new HoverProvider(registry, contextResolver, commandDocs); + this.definitionProvider = new DefinitionProvider(contextResolver); + this.documentSymbolProvider = new DocumentSymbolProvider(); + } + + public void setClient(LanguageClient client) { + this.client = client; + } + + private static final Set SKIP_DIRS = Set.of( + ".git", ".svn", ".hg", "node_modules", "target", "build", ".idea", ".vscode", ".settings" + ); + + /** + * Scans the workspace directory for qDup YAML files and parses them. + * Uses walkFileTree to skip hidden directories and common non-source directories + * without descending into them. + * Only considers files matching *.qdup.yaml or *.qdup.yml. + */ + public void scanWorkspace(String rootUri) { + if (rootUri == null) { + return; + } + try { + Path rootPath = Paths.get(URI.create(rootUri)); + Files.walkFileTree(rootPath, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) { + Path fileName = dir.getFileName(); + if (fileName == null) { + return FileVisitResult.CONTINUE; + } + String name = fileName.toString(); + if (name.startsWith(".") || SKIP_DIRS.contains(name)) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + Path fileName = file.getFileName(); + if (fileName == null) { + return FileVisitResult.CONTINUE; + } + String name = fileName.toString(); + if (name.endsWith(".qdup.yaml") || name.endsWith(".qdup.yml")) { + loadWorkspaceFile(file); + } + return FileVisitResult.CONTINUE; + } + }); + LOG.info("Scanned workspace: " + workspaceDocuments.size() + " qDup YAML files found"); + } catch (Exception e) { + LOG.log(Level.WARNING, "Failed to scan workspace: " + rootUri, e); + } + } + + private void loadWorkspaceFile(Path file) { + try { + String content = Files.readString(file); + String fileUri = file.toUri().toString(); + QDupDocument doc = new QDupDocument(fileUri, content); + if (doc.isParseSuccessful()) { + workspaceDocuments.put(fileUri, doc); + } + } catch (IOException e) { + LOG.log(Level.FINE, "Failed to read workspace file: " + file, e); + } + } + + /** + * Handles workspace file change events to keep workspace documents in sync. + */ + public void handleFileChange(DidChangeWatchedFilesParams params) { + for (FileEvent event : params.getChanges()) { + String uri = event.getUri(); + if (event.getType() == FileChangeType.Deleted) { + workspaceDocuments.remove(uri); + } else { + // Created or Changed — re-read the file + try { + Path filePath = Paths.get(URI.create(uri)); + loadWorkspaceFile(filePath); + } catch (Exception e) { + LOG.log(Level.FINE, "Failed to handle file change: " + uri, e); + } + } + } + } + + /** + * Returns all known documents, with open documents taking priority over workspace-scanned ones. + */ + public Collection getAllDocuments() { + Map merged = new HashMap<>(workspaceDocuments); + merged.putAll(documents); // open documents override workspace docs + return merged.values(); + } + + @Override + public void didOpen(DidOpenTextDocumentParams params) { + String uri = params.getTextDocument().getUri(); + String text = params.getTextDocument().getText(); + QDupDocument doc = new QDupDocument(uri, text); + documents.put(uri, doc); + publishDiagnostics(uri, doc); + } + + @Override + public void didChange(DidChangeTextDocumentParams params) { + String uri = params.getTextDocument().getUri(); + List changes = params.getContentChanges(); + if (!changes.isEmpty()) { + // We use full document sync, so take the last change + String text = changes.get(changes.size() - 1).getText(); + QDupDocument doc = new QDupDocument(uri, text); + documents.put(uri, doc); + publishDiagnostics(uri, doc); + } + } + + @Override + public void didClose(DidCloseTextDocumentParams params) { + String uri = params.getTextDocument().getUri(); + documents.remove(uri); + // Clear diagnostics for closed document + if (client != null) { + client.publishDiagnostics(new PublishDiagnosticsParams(uri, Collections.emptyList())); + } + } + + @Override + public void didSave(DidSaveTextDocumentParams params) { + // Re-validate on save + String uri = params.getTextDocument().getUri(); + QDupDocument doc = documents.get(uri); + if (doc != null) { + publishDiagnostics(uri, doc); + } + } + + @Override + public CompletableFuture, CompletionList>> completion(CompletionParams params) { + return CompletableFuture.supplyAsync(() -> { + String uri = params.getTextDocument().getUri(); + QDupDocument doc = documents.get(uri); + if (doc == null) { + return Either.forLeft(Collections.emptyList()); + } + Position pos = params.getPosition(); + List items = completionProvider.complete(doc, pos.getLine(), pos.getCharacter(), getAllDocuments()); + return Either.forLeft(items); + }); + } + + @Override + public CompletableFuture hover(HoverParams params) { + return CompletableFuture.supplyAsync(() -> { + String uri = params.getTextDocument().getUri(); + QDupDocument doc = documents.get(uri); + if (doc == null) { + return null; + } + Position pos = params.getPosition(); + return hoverProvider.hover(doc, pos.getLine(), pos.getCharacter(), getAllDocuments()); + }); + } + + @Override + public CompletableFuture, List>> definition(DefinitionParams params) { + return CompletableFuture.supplyAsync(() -> { + String uri = params.getTextDocument().getUri(); + QDupDocument doc = documents.get(uri); + if (doc == null) { + return Either.forLeft(Collections.emptyList()); + } + Position pos = params.getPosition(); + Location location = definitionProvider.definition(doc, pos.getLine(), pos.getCharacter(), getAllDocuments()); + if (location != null) { + return Either.forLeft(List.of(location)); + } + return Either.forLeft(Collections.emptyList()); + }); + } + + @Override + public CompletableFuture>> documentSymbol(DocumentSymbolParams params) { + return CompletableFuture.supplyAsync(() -> { + String uri = params.getTextDocument().getUri(); + QDupDocument doc = documents.get(uri); + if (doc == null) { + return Collections.emptyList(); + } + List symbols = documentSymbolProvider.documentSymbols(doc); + List> result = new ArrayList<>(); + for (DocumentSymbol sym : symbols) { + result.add(Either.forRight(sym)); + } + return result; + }); + } + + private void publishDiagnostics(String uri, QDupDocument doc) { + if (client != null) { + List diagnostics = diagnosticsProvider.diagnose(doc, getAllDocuments()); + client.publishDiagnostics(new PublishDiagnosticsParams(uri, diagnostics)); + } + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupWorkspaceService.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupWorkspaceService.java new file mode 100644 index 00000000..f956879d --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/QDupWorkspaceService.java @@ -0,0 +1,32 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.DidChangeConfigurationParams; +import org.eclipse.lsp4j.DidChangeWatchedFilesParams; +import org.eclipse.lsp4j.services.WorkspaceService; + +import java.util.function.Consumer; + +/** + * Workspace service for the qDup LSP server. + * Handles watched file changes to keep workspace documents in sync. + */ +public class QDupWorkspaceService implements WorkspaceService { + + private Consumer fileChangeHandler; + + public void setFileChangeHandler(Consumer handler) { + this.fileChangeHandler = handler; + } + + @Override + public void didChangeConfiguration(DidChangeConfigurationParams params) { + // No configuration changes to handle yet + } + + @Override + public void didChangeWatchedFiles(DidChangeWatchedFilesParams params) { + if (fileChangeHandler != null) { + fileChangeHandler.accept(params); + } + } +} diff --git a/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/YamlContext.java b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/YamlContext.java new file mode 100644 index 00000000..9df8987e --- /dev/null +++ b/qDup-lsp/src/main/java/io/hyperfoil/tools/qdup/lsp/YamlContext.java @@ -0,0 +1,52 @@ +package io.hyperfoil.tools.qdup.lsp; + +/** + * Represents where the cursor is positioned within the qDup YAML document structure. + * Used to determine what completions, diagnostics, and hover info to provide. + */ +public enum YamlContext { + /** Cursor is at a top-level key position (e.g., name, scripts, hosts, roles, states, globals) */ + TOP_LEVEL_KEY, + + /** Cursor is at a top-level value position */ + TOP_LEVEL_VALUE, + + /** Cursor is at a command key position within a script (e.g., sh, regex, set-state) */ + SCRIPT_COMMAND_KEY, + + /** Cursor is at a command value position */ + SCRIPT_COMMAND_VALUE, + + /** Cursor is at a command modifier key position (e.g., then, else, watch, with, timer) */ + COMMAND_MODIFIER_KEY, + + /** Cursor is at a command-specific parameter key (e.g., command, prompt for sh) */ + COMMAND_PARAM_KEY, + + /** Cursor is at a host configuration key position */ + HOST_CONFIG_KEY, + + /** Cursor is at a role key position (e.g., hosts, setup-scripts, run-scripts, cleanup-scripts) */ + ROLE_KEY, + + /** Cursor is at a role script reference value */ + ROLE_SCRIPT_REF, + + /** Cursor is at a role host reference value */ + ROLE_HOST_REF, + + /** Cursor is at a state variable reference */ + STATE_VARIABLE_REF, + + /** Cursor is at a script name definition (under scripts:) */ + SCRIPT_NAME, + + /** Cursor is at a host name definition (under hosts:) */ + HOST_NAME, + + /** Cursor is at a role name definition (under roles:) */ + ROLE_NAME, + + /** Context could not be determined */ + UNKNOWN +} diff --git a/qDup-lsp/src/main/resources/command-docs.properties b/qDup-lsp/src/main/resources/command-docs.properties new file mode 100644 index 00000000..3fe86413 --- /dev/null +++ b/qDup-lsp/src/main/resources/command-docs.properties @@ -0,0 +1,76 @@ +# qDup command documentation for LSP hover support +# Generated from docs/reference/command/*.adoc + +abort = End the current run with an optional error message. Aborted runs will still download queued files and run cleanup scripts. +add-prompt = Add a custom shell prompt so qDup can detect when a non-standard terminal session (e.g. psql, jboss-cli) is ready for the next command. +countdown = Decrease a named counter starting from an initial value. Child 'then' commands are invoked each time after the counter reaches zero. +ctrlC = Send a ctrl+C interrupt to the SSH terminal, typically used to stop a long-running command such as 'tail -f'. +ctrl/ = Send a ctrl+/ signal to the SSH terminal. +ctrl\\ = Send a ctrl+\\ signal to the SSH terminal. +ctrlU = Send a ctrl+U signal to the SSH terminal to clear the current input line. +ctrlZ = Send a ctrl+Z signal to the SSH terminal to suspend the current foreground process. +done = Tell qDup that the current phase is done and cancel any other scripts still running. Deprecated; do not use unless absolutely necessary. +download = Copy a file from the remote host to the local qDup run directory immediately. Accepts a remote path, optional local destination, and optional max-size. +echo = Write the input from the previous command to the qDup console log, primarily used for debugging scripts. +exec = Run a shell command as an SSH exec channel with the default environment and home folder, without affecting the current terminal session. +for-each = Iterate over the input (or an explicit list) and run the 'then' commands for each entry, setting a named state variable to the current item. +js = Execute arbitrary JavaScript code with access to the input from the previous command and the current state. Runs 'then' on truthy return, 'else' otherwise. +json = Evaluate a JSONPath expression against the input (assumed to be JSON). Runs 'then' commands with matched values, or 'else' if no match. +log = Write a message string to the qDup run.log file, supporting state variable substitution. +parse = Use the Hyperfoil parse library to parse the input from the previous command. +queue-download = Queue a file on the remote host for download after the run or cleanup stage completes, rather than downloading immediately. +read-signal = Check whether a named signal has been fully signalled. Runs 'then' commands if reached, or 'else' commands if not yet reached. +read-state = Evaluate a state expression and run the 'then' commands if the result is non-null and non-empty, otherwise run the 'else' commands. +regex = Match a Java regex pattern against the input from the previous command. Named capture groups are added to script state. Runs 'then' on match, 'else' otherwise. +repeat-until = Repeat the 'then' commands in a loop until the named signal is fully signalled. Always include a 'sleep' inside to avoid a tight loop. +script = Invoke another named script using the same SSH terminal. Supports 'async' option to run in a separate terminal with independent state. +send-text = Send text to the SSH terminal without waiting for a prompt response, designed for use inside 'watch', 'timer', or 'on-signal' blocks. +set-signal = Initialize or reset the expected signal count for a named coordination signal, used when qDup cannot automatically calculate the count. +set-state = Set a state variable to a given value, or to the input from the previous command if no value is specified. Supports RUN. and HOST. prefixes for scope. +sh = Execute a shell command on the remote SSH terminal. Supports prompt-response mapping, exit code checking, and 'ignore-exit-code' option. +signal = Signal a named coordination point to notify other scripts that a specific execution milestone has been reached. +sleep = Pause the current script for a specified duration. Accepts milliseconds or human-readable durations (e.g. '1h 10m 20.5s'). +upload = Copy a file from the local machine running qDup to the remote host. Accepts a local path and optional remote destination. +wait-for = Pause the current script until the named coordination point has been fully signalled by other scripts. +xml = Read or modify XML documents using XPath expressions. Supports set, add child, delete, set attribute, and read-to-state operations. + +# Command parameter documentation +sh.command = The shell command to execute on the remote terminal. +sh.prompt = Map of expected prompts to responses, used for interactive commands. +sh.ignore-exit-code = Set to true to skip exit code checking for this command. +sh.silent = Suppress command output in the log. +set-state.key = The state variable name to set. Use RUN. or HOST. prefix for scope. +set-state.value = The value to assign. If omitted, uses input from the previous command. +set-state.separator = Character to separate state key from default value (default ':'). +set-state.autoConvert = Set to false to disable automatic type conversion of text. +regex.pattern = The Java regex pattern to match against input. +regex.miss = Set to true to invert the match (run 'then' when pattern does NOT match). +regex.autoConvert = Set to false to disable automatic type conversion of captured groups. +download.path = Remote file path to download. +download.destination = Local destination path (default: run directory). +download.max-size = Maximum file size to download. +upload.path = Local file path to upload. +upload.destination = Remote destination path (default: home folder). +for-each.name = State variable name for the current iteration item. +for-each.input = Explicit input to iterate over (default: previous command output). +exec.command = The command to execute via SSH exec channel. +exec.async = Set to true to run asynchronously without waiting for completion. +exec.silent = Suppress command output in the log. +abort.message = Error message to include when aborting the run. +abort.skip-cleanup = Set to true to skip cleanup scripts when aborting. +countdown.name = Name of the countdown counter. +countdown.initial = Initial counter value. +set-signal.name = Name of the coordination signal. +set-signal.count = Expected number of signal calls. +set-signal.reset = Set to true to reset an existing signal counter. +script.name = Name of the script to invoke. +script.async = Set to true to run in a separate terminal. +wait-for.name = Name of the signal to wait for. +wait-for.initial = Initial signal count (overrides automatic calculation). +xml.path = Path to the XML file. +xml.operations = List of XPath operations to perform. +queue-download.path = Remote file path to queue for download. +queue-download.destination = Local destination path. +queue-download.max-size = Maximum file size to download. +add-prompt.prompt = The prompt pattern to recognize. +add-prompt.is-shell = Whether this prompt is a shell prompt (default: false). diff --git a/qDup-lsp/src/main/resources/lsp-version.properties b/qDup-lsp/src/main/resources/lsp-version.properties new file mode 100644 index 00000000..1fed73bf --- /dev/null +++ b/qDup-lsp/src/main/resources/lsp-version.properties @@ -0,0 +1 @@ +lsp.version=${project.version} diff --git a/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CommandRegistryTest.java b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CommandRegistryTest.java new file mode 100644 index 00000000..05a0c01b --- /dev/null +++ b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CommandRegistryTest.java @@ -0,0 +1,126 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.junit.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.*; + +public class CommandRegistryTest { + + @Test + public void testCommandNamesLoaded() { + CommandRegistry registry = new CommandRegistry(); + Set commands = registry.getCommandNames(); + + assertFalse("Should have loaded commands", commands.isEmpty()); + assertTrue("Should contain 'sh'", commands.contains("sh")); + assertTrue("Should contain 'regex'", commands.contains("regex")); + assertTrue("Should contain 'set-state'", commands.contains("set-state")); + assertTrue("Should contain 'download'", commands.contains("download")); + assertTrue("Should contain 'upload'", commands.contains("upload")); + assertTrue("Should contain 'for-each'", commands.contains("for-each")); + assertTrue("Should contain 'signal'", commands.contains("signal")); + assertTrue("Should contain 'wait-for'", commands.contains("wait-for")); + assertTrue("Should contain 'sleep'", commands.contains("sleep")); + assertTrue("Should contain 'log'", commands.contains("log")); + assertTrue("Should contain 'abort'", commands.contains("abort")); + assertTrue("Should contain 'js'", commands.contains("js")); + assertTrue("Should contain 'xml'", commands.contains("xml")); + } + + @Test + public void testNoArgCommands() { + CommandRegistry registry = new CommandRegistry(); + Set noArgs = registry.getNoArgCommands(); + + assertTrue("ctrlC should be no-arg", noArgs.contains("ctrlC")); + assertTrue("done should be no-arg", noArgs.contains("done")); + assertTrue("echo should be no-arg", noArgs.contains("echo")); + assertFalse("sh should NOT be no-arg", noArgs.contains("sh")); + assertFalse("regex should NOT be no-arg", noArgs.contains("regex")); + } + + @Test + public void testModifierKeys() { + CommandRegistry registry = new CommandRegistry(); + Set modifiers = registry.getModifierKeys(); + + assertTrue("Should contain 'then'", modifiers.contains("then")); + assertTrue("Should contain 'with'", modifiers.contains("with")); + assertTrue("Should contain 'watch'", modifiers.contains("watch")); + assertTrue("Should contain 'timer'", modifiers.contains("timer")); + assertTrue("Should contain 'on-signal'", modifiers.contains("on-signal")); + assertTrue("Should contain 'silent'", modifiers.contains("silent")); + assertTrue("Should contain 'prefix'", modifiers.contains("prefix")); + assertTrue("Should contain 'suffix'", modifiers.contains("suffix")); + assertEquals("Should have 12 modifiers", 12, modifiers.size()); + } + + @Test + public void testHostConfigKeys() { + CommandRegistry registry = new CommandRegistry(); + List hostKeys = registry.getHostConfigKeys(); + + assertTrue("Should contain 'hostname'", hostKeys.contains("hostname")); + assertTrue("Should contain 'username'", hostKeys.contains("username")); + assertTrue("Should contain 'password'", hostKeys.contains("password")); + assertTrue("Should contain 'port'", hostKeys.contains("port")); + assertTrue("Should contain 'identity'", hostKeys.contains("identity")); + assertTrue("Should contain 'local'", hostKeys.contains("local")); + assertTrue("Should contain 'platform'", hostKeys.contains("platform")); + assertEquals("Should have 21 host keys", 21, hostKeys.size()); + } + + @Test + public void testExpectedKeys() { + CommandRegistry registry = new CommandRegistry(); + + List shKeys = registry.getExpectedKeys("sh"); + assertTrue("sh should have 'command' key", shKeys.contains("command")); + assertTrue("sh should have 'prompt' key", shKeys.contains("prompt")); + + List setStateKeys = registry.getExpectedKeys("set-state"); + assertTrue("set-state should have 'key'", setStateKeys.contains("key")); + assertTrue("set-state should have 'value'", setStateKeys.contains("value")); + + List regexKeys = registry.getExpectedKeys("regex"); + assertTrue("regex should have 'pattern'", regexKeys.contains("pattern")); + } + + @Test + public void testIsCommand() { + CommandRegistry registry = new CommandRegistry(); + + assertTrue(registry.isCommand("sh")); + assertTrue(registry.isCommand("regex")); + assertTrue(registry.isCommand("ctrlC")); + assertFalse(registry.isCommand("nonexistent")); + assertFalse(registry.isCommand("then")); // modifier, not command + } + + @Test + public void testTopLevelKeys() { + CommandRegistry registry = new CommandRegistry(); + Set topLevel = registry.getTopLevelKeys(); + + assertTrue(topLevel.contains("name")); + assertTrue(topLevel.contains("scripts")); + assertTrue(topLevel.contains("hosts")); + assertTrue(topLevel.contains("roles")); + assertTrue(topLevel.contains("states")); + assertTrue(topLevel.contains("globals")); + } + + @Test + public void testRoleKeys() { + CommandRegistry registry = new CommandRegistry(); + Set roleKeys = registry.getRoleKeys(); + + assertTrue(roleKeys.contains("hosts")); + assertTrue(roleKeys.contains("setup-scripts")); + assertTrue(roleKeys.contains("run-scripts")); + assertTrue(roleKeys.contains("cleanup-scripts")); + } +} diff --git a/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CompletionProviderTest.java b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CompletionProviderTest.java new file mode 100644 index 00000000..35dc1a5c --- /dev/null +++ b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CompletionProviderTest.java @@ -0,0 +1,181 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.CompletionItem; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +public class CompletionProviderTest { + + private CommandRegistry registry; + private CursorContextResolver contextResolver; + private CompletionProvider provider; + private Properties commandDocs; + + @Before + public void setUp() { + registry = new CommandRegistry(); + contextResolver = new CursorContextResolver(registry); + commandDocs = new Properties(); + try (InputStream is = getClass().getClassLoader().getResourceAsStream("command-docs.properties")) { + if (is != null) { + commandDocs.load(is); + } + } catch (IOException e) { + // Use empty docs + } + provider = new CompletionProvider(registry, contextResolver, commandDocs); + } + + @Test + public void testTopLevelKeyCompletions() { + QDupDocument doc = new QDupDocument("test.yaml", ""); + List items = provider.completeForContext(YamlContext.TOP_LEVEL_KEY, doc, 0, null); + + Set labels = items.stream().map(CompletionItem::getLabel).collect(Collectors.toSet()); + assertTrue("Should include 'scripts'", labels.contains("scripts")); + assertTrue("Should include 'hosts'", labels.contains("hosts")); + assertTrue("Should include 'roles'", labels.contains("roles")); + assertTrue("Should include 'name'", labels.contains("name")); + assertTrue("Should include 'states'", labels.contains("states")); + assertTrue("Should include 'globals'", labels.contains("globals")); + } + + @Test + public void testCommandKeyCompletions() { + QDupDocument doc = new QDupDocument("test.yaml", "scripts:\n myScript:\n - "); + List items = provider.completeForContext(YamlContext.SCRIPT_COMMAND_KEY, doc, 2, null); + + Set labels = items.stream().map(CompletionItem::getLabel).collect(Collectors.toSet()); + assertTrue("Should include 'sh'", labels.contains("sh")); + assertTrue("Should include 'regex'", labels.contains("regex")); + assertTrue("Should include 'set-state'", labels.contains("set-state")); + assertTrue("Should include 'ctrlC'", labels.contains("ctrlC")); + assertTrue("Should include 'log'", labels.contains("log")); + } + + @Test + public void testModifierKeyCompletions() { + QDupDocument doc = new QDupDocument("test.yaml", ""); + List items = provider.completeForContext(YamlContext.COMMAND_MODIFIER_KEY, doc, 0, null); + + Set labels = items.stream().map(CompletionItem::getLabel).collect(Collectors.toSet()); + assertTrue("Should include 'then'", labels.contains("then")); + assertTrue("Should include 'with'", labels.contains("with")); + assertTrue("Should include 'watch'", labels.contains("watch")); + assertTrue("Should include 'timer'", labels.contains("timer")); + assertTrue("Should include 'else'", labels.contains("else")); + } + + @Test + public void testHostConfigKeyCompletions() { + QDupDocument doc = new QDupDocument("test.yaml", ""); + List items = provider.completeForContext(YamlContext.HOST_CONFIG_KEY, doc, 0, null); + + Set labels = items.stream().map(CompletionItem::getLabel).collect(Collectors.toSet()); + assertTrue("Should include 'hostname'", labels.contains("hostname")); + assertTrue("Should include 'username'", labels.contains("username")); + assertTrue("Should include 'port'", labels.contains("port")); + assertTrue("Should include 'identity'", labels.contains("identity")); + } + + @Test + public void testRoleKeyCompletions() { + QDupDocument doc = new QDupDocument("test.yaml", ""); + List items = provider.completeForContext(YamlContext.ROLE_KEY, doc, 0, null); + + Set labels = items.stream().map(CompletionItem::getLabel).collect(Collectors.toSet()); + assertTrue("Should include 'hosts'", labels.contains("hosts")); + assertTrue("Should include 'run-scripts'", labels.contains("run-scripts")); + assertTrue("Should include 'setup-scripts'", labels.contains("setup-scripts")); + assertTrue("Should include 'cleanup-scripts'", labels.contains("cleanup-scripts")); + } + + @Test + public void testRoleScriptRefCompletions() { + String yaml = "scripts:\n buildApp:\n - sh: mvn package\n deployApp:\n - sh: deploy.sh\nroles:\n builder:\n run-scripts:\n - "; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List items = provider.completeForContext(YamlContext.ROLE_SCRIPT_REF, doc, 8, null); + + Set labels = items.stream().map(CompletionItem::getLabel).collect(Collectors.toSet()); + assertTrue("Should include 'buildApp'", labels.contains("buildApp")); + assertTrue("Should include 'deployApp'", labels.contains("deployApp")); + } + + @Test + public void testCompletionInsideVariablePattern() { + String yaml = String.join("\n", + "name: test", + "states:", + " WF_HOME: /opt/wildfly", + " USER: admin", + "scripts:", + " deploy:", + " - sh: cd ${{", + "" + ); + QDupDocument doc = new QDupDocument("test.yaml", yaml); + // Line 6: " - sh: cd ${{", cursor at end (column 19) + List items = provider.complete(doc, 6, 19, java.util.List.of(doc)); + + Set labels = items.stream().map(CompletionItem::getLabel).collect(Collectors.toSet()); + assertTrue("Should include 'WF_HOME'", labels.contains("WF_HOME")); + assertTrue("Should include 'USER'", labels.contains("USER")); + // Verify they are Variable kind + for (CompletionItem item : items) { + assertEquals(org.eclipse.lsp4j.CompletionItemKind.Variable, item.getKind()); + } + } + + @Test + public void testCompletionInsideVariablePatternCrossFile() { + String scriptYaml = String.join("\n", + "scripts:", + " run:", + " - sh: echo ${{", + "" + ); + String stateYaml = String.join("\n", + "states:", + " DB_PORT: 5432", + "" + ); + QDupDocument scriptDoc = new QDupDocument("file:///script.yaml", scriptYaml); + QDupDocument stateDoc = new QDupDocument("file:///state.yaml", stateYaml); + + List items = provider.complete(scriptDoc, 2, 22, java.util.List.of(scriptDoc, stateDoc)); + + Set labels = items.stream().map(CompletionItem::getLabel).collect(Collectors.toSet()); + assertTrue("Should include cross-file state variable 'DB_PORT'", labels.contains("DB_PORT")); + } + + @Test + public void testNoArgCommandInsertText() { + QDupDocument doc = new QDupDocument("test.yaml", ""); + List items = provider.completeForContext(YamlContext.SCRIPT_COMMAND_KEY, doc, 0, null); + + // Find ctrlC completion + CompletionItem ctrlC = items.stream() + .filter(i -> "ctrlC".equals(i.getLabel())) + .findFirst() + .orElse(null); + assertNotNull("Should have ctrlC completion", ctrlC); + assertEquals("No-arg command should not have ': ' suffix", "ctrlC", ctrlC.getInsertText()); + + // Find sh completion + CompletionItem sh = items.stream() + .filter(i -> "sh".equals(i.getLabel())) + .findFirst() + .orElse(null); + assertNotNull("Should have sh completion", sh); + assertEquals("Regular command should have ': ' suffix", "sh: ", sh.getInsertText()); + } +} diff --git a/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CursorContextResolverTest.java b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CursorContextResolverTest.java new file mode 100644 index 00000000..a2ff0032 --- /dev/null +++ b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/CursorContextResolverTest.java @@ -0,0 +1,93 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.*; + +public class CursorContextResolverTest { + + private CommandRegistry registry; + private CursorContextResolver resolver; + + @Before + public void setUp() { + registry = new CommandRegistry(); + resolver = new CursorContextResolver(registry); + } + + @Test + public void testTopLevelKey() { + String yaml = "name: test\nscripts:\n myScript:\n - sh: echo hello\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + + // Cursor on "name" at line 0 + YamlContext ctx = resolver.resolve(doc, 0, 0); + // Should be at top-level key or value + assertTrue("Should be top-level context", + ctx == YamlContext.TOP_LEVEL_KEY || ctx == YamlContext.TOP_LEVEL_VALUE); + } + + @Test + public void testScriptsSection() { + String yaml = "scripts:\n myScript:\n - sh: echo hello\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + + assertTrue("Document should parse", doc.isParseSuccessful()); + } + + @Test + public void testHostsSection() { + String yaml = "hosts:\n myHost: user@host:22\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + + assertTrue("Document should parse", doc.isParseSuccessful()); + } + + @Test + public void testFallbackContextResolution() { + // Use malformed YAML to trigger line-based fallback + String yaml = "scripts:\n myScript:\n - sh: echo hello\n - regex: pattern\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + + // Even with tree-based resolution, these should work + if (doc.isParseSuccessful()) { + assertNotNull("Root node should exist", doc.getRootNode()); + } + } + + @Test + public void testLineBasedHostContext() { + // Deliberately broken YAML to trigger fallback + String yaml = "hosts:\n myHost:\n hostname: example.com\n port: 22\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + + if (!doc.isParseSuccessful()) { + // Test line-based fallback + YamlContext ctx = resolver.resolve(doc, 2, 4); + assertEquals(YamlContext.HOST_CONFIG_KEY, ctx); + } + } + + @Test + public void testLineBasedScriptsContext() { + // Test line-based fallback for scripts + String yaml = "scripts:\n myScript:\n - \n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + + // If parse fails, fallback should identify script context + if (!doc.isParseSuccessful()) { + YamlContext ctx = resolver.resolve(doc, 2, 4); + assertEquals(YamlContext.SCRIPT_COMMAND_KEY, ctx); + } + } + + @Test + public void testFindCommandAtLine() { + String yaml = "scripts:\n myScript:\n - sh:\n command: ls\n prompt:\n 'Password:': pass\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + + String cmd = resolver.findCommandAtLine(doc, 3); + assertEquals("Should find 'sh' command", "sh", cmd); + } +} diff --git a/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DefinitionProviderTest.java b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DefinitionProviderTest.java new file mode 100644 index 00000000..90656d6a --- /dev/null +++ b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DefinitionProviderTest.java @@ -0,0 +1,268 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.Location; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.*; + +public class DefinitionProviderTest { + + private CommandRegistry registry; + private CursorContextResolver contextResolver; + private DefinitionProvider provider; + + @Before + public void setUp() { + registry = new CommandRegistry(); + contextResolver = new CursorContextResolver(registry); + provider = new DefinitionProvider(contextResolver); + } + + private static final String FULL_YAML = String.join("\n", + "name: test", + "scripts:", + " start-db:", + " - sh: echo starting db", + " start-app:", + " - sh: echo starting app", + " - script: start-db", + "hosts:", + " local: me@localhost", + " remote: user@server:22", + "roles:", + " test-role:", + " hosts:", + " - local", + " run-scripts:", + " - start-app", + "" + ); + + @Test + public void testDefinitionForScriptRefInRole() { + // " - start-app" is at line 15, cursor on "start-app" + QDupDocument doc = new QDupDocument("test.yaml", FULL_YAML); + Location loc = provider.definition(doc, 15, 10); + + assertNotNull("Should find definition for script ref 'start-app'", loc); + assertEquals("test.yaml", loc.getUri()); + // "start-app:" is the key at line 4 + assertEquals(4, loc.getRange().getStart().getLine()); + } + + @Test + public void testDefinitionForHostRefInRole() { + // " - local" is at line 13, cursor on "local" + QDupDocument doc = new QDupDocument("test.yaml", FULL_YAML); + Location loc = provider.definition(doc, 13, 10); + + assertNotNull("Should find definition for host ref 'local'", loc); + assertEquals("test.yaml", loc.getUri()); + // "local:" is the key at line 8 + assertEquals(8, loc.getRange().getStart().getLine()); + } + + @Test + public void testDefinitionForScriptCommand() { + // " - script: start-db" is at line 6, cursor on "start-db" + QDupDocument doc = new QDupDocument("test.yaml", FULL_YAML); + Location loc = provider.definition(doc, 6, 18); + + assertNotNull("Should find definition for 'script: start-db'", loc); + assertEquals("test.yaml", loc.getUri()); + // "start-db:" is the key at line 2 + assertEquals(2, loc.getRange().getStart().getLine()); + } + + @Test + public void testDefinitionReturnsNullForNonReference() { + // "name: test" is at line 0, cursor on "test" - no definition + QDupDocument doc = new QDupDocument("test.yaml", FULL_YAML); + Location loc = provider.definition(doc, 0, 7); + + assertNull("Should return null for non-reference context", loc); + } + + @Test + public void testDefinitionReturnsNullForUndefinedScript() { + String yaml = String.join("\n", + "scripts:", + " myScript:", + " - sh: echo hello", + "roles:", + " myRole:", + " run-scripts:", + " - nonExistent", + "" + ); + QDupDocument doc = new QDupDocument("test.yaml", yaml); + Location loc = provider.definition(doc, 6, 10); + + assertNull("Should return null for undefined script reference", loc); + } + + @Test + public void testDefinitionReturnsNullForBrokenYaml() { + String yaml = "scripts:\n myScript:\n - sh: {\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + Location loc = provider.definition(doc, 1, 3); + + assertNull("Should return null when YAML parse failed", loc); + } + + @Test + public void testExtractWordAt() { + assertEquals("start-db", DefinitionProvider.extractWordAt(" - script: start-db", 18)); + assertEquals("local", DefinitionProvider.extractWordAt(" - local", 10)); + assertEquals("script", DefinitionProvider.extractWordAt(" - script: start-db", 8)); + assertNull(DefinitionProvider.extractWordAt("", 0)); + assertNull(DefinitionProvider.extractWordAt(" ", 1)); + } + + // --- Cross-file definition tests --- + + private static final String MAIN_YAML = String.join("\n", + "name: main", + "roles:", + " test-role:", + " hosts:", + " - remote-server", + " run-scripts:", + " - start-heroes-db", + "" + ); + + private static final String SCRIPTS_YAML = String.join("\n", + "name: scripts", + "scripts:", + " start-heroes-db:", + " - sh: echo starting db", + " stop-heroes-db:", + " - sh: echo stopping db", + "hosts:", + " remote-server: user@server:22", + "" + ); + + @Test + public void testCrossFileScriptDefinition() { + QDupDocument mainDoc = new QDupDocument("file:///main.qdup.yaml", MAIN_YAML); + QDupDocument scriptsDoc = new QDupDocument("file:///scripts.qdup.yaml", SCRIPTS_YAML); + + // " - start-heroes-db" is at line 6 in mainDoc, cursor on "start-heroes-db" + Location loc = provider.definition(mainDoc, 6, 10, List.of(mainDoc, scriptsDoc)); + + assertNotNull("Should find cross-file definition for script 'start-heroes-db'", loc); + assertEquals("file:///scripts.qdup.yaml", loc.getUri()); + // "start-heroes-db:" is the key at line 2 in scriptsDoc + assertEquals(2, loc.getRange().getStart().getLine()); + } + + @Test + public void testCrossFileHostDefinition() { + QDupDocument mainDoc = new QDupDocument("file:///main.qdup.yaml", MAIN_YAML); + QDupDocument scriptsDoc = new QDupDocument("file:///scripts.qdup.yaml", SCRIPTS_YAML); + + // " - remote-server" is at line 4 in mainDoc, cursor on "remote-server" + Location loc = provider.definition(mainDoc, 4, 10, List.of(mainDoc, scriptsDoc)); + + assertNotNull("Should find cross-file definition for host 'remote-server'", loc); + assertEquals("file:///scripts.qdup.yaml", loc.getUri()); + // "remote-server:" is the key at line 7 in scriptsDoc + assertEquals(7, loc.getRange().getStart().getLine()); + } + + // --- State variable definition tests --- + + private static final String STATE_YAML = String.join("\n", + "name: state-test", + "states:", + " FOO: bar", + " GREETING: Hello qDup!", + "scripts:", + " myScript:", + " - sh: echo ${{FOO}}", + " - sh: echo ${{GREETING}}", + "" + ); + + @Test + public void testDefinitionForStateVariable() { + QDupDocument doc = new QDupDocument("test.yaml", STATE_YAML); + // Line 6: " - sh: echo ${{FOO}}", cursor on "FOO" inside ${{FOO}} + // "${{FOO}}" starts at column 18, "FOO" is at column 21 + Location loc = provider.definition(doc, 6, 21); + + assertNotNull("Should find definition for state variable 'FOO'", loc); + assertEquals("test.yaml", loc.getUri()); + // "FOO:" is the key at line 2 + assertEquals(2, loc.getRange().getStart().getLine()); + } + + @Test + public void testCrossFileStateVariableDefinition() { + String scriptYaml = String.join("\n", + "name: script-file", + "scripts:", + " run:", + " - sh: echo ${{DB_HOST}}", + "" + ); + String stateYaml = String.join("\n", + "name: state-file", + "states:", + " DB_HOST: localhost", + "" + ); + QDupDocument scriptDoc = new QDupDocument("file:///script.yaml", scriptYaml); + QDupDocument stateDoc = new QDupDocument("file:///state.yaml", stateYaml); + + // Line 3: " - sh: echo ${{DB_HOST}}", cursor on "DB_HOST" + Location loc = provider.definition(scriptDoc, 3, 22, List.of(scriptDoc, stateDoc)); + + assertNotNull("Should find cross-file state variable definition", loc); + assertEquals("file:///state.yaml", loc.getUri()); + assertEquals(2, loc.getRange().getStart().getLine()); + } + + @Test + public void testExtractVariableAt() { + assertEquals("FOO", DefinitionProvider.extractVariableAt("echo ${{FOO}}", 9)); + assertEquals("GREETING", DefinitionProvider.extractVariableAt("echo ${{GREETING}}", 12)); + assertEquals("FOO", DefinitionProvider.extractVariableAt("${{FOO:default}}", 5)); + assertNull(DefinitionProvider.extractVariableAt("echo hello", 5)); + assertNull(DefinitionProvider.extractVariableAt("echo ${{FOO}} ${{BAR}}", 14)); + assertEquals("BAR", DefinitionProvider.extractVariableAt("echo ${{FOO}} ${{BAR}}", 19)); + assertNull(DefinitionProvider.extractVariableAt("", 0)); + assertNull(DefinitionProvider.extractVariableAt(null, 0)); + } + + @Test + public void testCurrentDocTakesPriority() { + // Both docs define 'start-heroes-db', current doc should win + String mainWithScript = String.join("\n", + "name: main", + "scripts:", + " start-heroes-db:", + " - sh: echo local version", + "roles:", + " test-role:", + " run-scripts:", + " - start-heroes-db", + "" + ); + QDupDocument mainDoc = new QDupDocument("file:///main.qdup.yaml", mainWithScript); + QDupDocument scriptsDoc = new QDupDocument("file:///scripts.qdup.yaml", SCRIPTS_YAML); + + // " - start-heroes-db" is at line 7 in mainDoc + Location loc = provider.definition(mainDoc, 7, 10, List.of(mainDoc, scriptsDoc)); + + assertNotNull("Should find definition in current doc", loc); + assertEquals("file:///main.qdup.yaml", loc.getUri()); + // "start-heroes-db:" is the key at line 2 in mainDoc + assertEquals(2, loc.getRange().getStart().getLine()); + } +} diff --git a/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DiagnosticsProviderTest.java b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DiagnosticsProviderTest.java new file mode 100644 index 00000000..a9c3d23f --- /dev/null +++ b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DiagnosticsProviderTest.java @@ -0,0 +1,250 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticSeverity; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.*; + +public class DiagnosticsProviderTest { + + private CommandRegistry registry; + private DiagnosticsProvider provider; + + @Before + public void setUp() { + registry = new CommandRegistry(); + provider = new DiagnosticsProvider(registry); + } + + @Test + public void testValidDocument() { + String yaml = String.join("\n", + "name: test", + "scripts:", + " myScript:", + " - sh: echo hello", + " - log: done", + "hosts:", + " myHost: user@host:22", + "roles:", + " myRole:", + " hosts:", + " - myHost", + " run-scripts:", + " - myScript", + "" + ); + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + // A valid document should have no errors + List errors = diags.stream() + .filter(d -> d.getSeverity() == DiagnosticSeverity.Error) + .collect(Collectors.toList()); + assertTrue("Valid document should have no errors, but got: " + + errors.stream().map(Diagnostic::getMessage).collect(Collectors.joining(", ")), + errors.isEmpty()); + } + + @Test + public void testUnknownTopLevelKey() { + String yaml = "name: test\ninvalidKey: something\nscripts:\n myScript:\n - sh: echo\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + boolean hasUnknownKey = diags.stream() + .anyMatch(d -> d.getMessage().contains("Unknown top-level key") && d.getMessage().contains("invalidKey")); + assertTrue("Should detect unknown top-level key 'invalidKey'", hasUnknownKey); + } + + @Test + public void testUnknownCommand() { + String yaml = "scripts:\n myScript:\n - invalidCmd: something\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + boolean hasUnknownCmd = diags.stream() + .anyMatch(d -> d.getMessage().contains("Unknown command") && d.getMessage().contains("invalidCmd")); + assertTrue("Should detect unknown command 'invalidCmd'", hasUnknownCmd); + } + + @Test + public void testUnknownHostKey() { + String yaml = "hosts:\n myHost:\n hostname: example.com\n invalidKey: something\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + boolean hasUnknownHostKey = diags.stream() + .anyMatch(d -> d.getMessage().contains("Unknown host configuration key") && d.getMessage().contains("invalidKey")); + assertTrue("Should detect unknown host config key", hasUnknownHostKey); + } + + @Test + public void testUnknownRoleKey() { + String yaml = "roles:\n myRole:\n invalidKey: something\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + boolean hasUnknownRoleKey = diags.stream() + .anyMatch(d -> d.getMessage().contains("Unknown role key") && d.getMessage().contains("invalidKey")); + assertTrue("Should detect unknown role key", hasUnknownRoleKey); + } + + @Test + public void testUndefinedScriptReference() { + String yaml = String.join("\n", + "scripts:", + " myScript:", + " - sh: echo hello", + "roles:", + " myRole:", + " hosts:", + " - someHost", + " run-scripts:", + " - nonExistentScript", + "" + ); + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + boolean hasUndefinedScript = diags.stream() + .anyMatch(d -> d.getMessage().contains("Undefined script reference") && d.getMessage().contains("nonExistentScript")); + assertTrue("Should detect undefined script reference", hasUndefinedScript); + } + + @Test + public void testUndefinedHostReference() { + String yaml = String.join("\n", + "scripts:", + " myScript:", + " - sh: echo hello", + "hosts:", + " realHost: user@host:22", + "roles:", + " myRole:", + " hosts:", + " - fakeHost", + " run-scripts:", + " - myScript", + "" + ); + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + boolean hasUndefinedHost = diags.stream() + .anyMatch(d -> d.getMessage().contains("Undefined host reference") && d.getMessage().contains("fakeHost")); + assertTrue("Should detect undefined host reference", hasUndefinedHost); + } + + @Test + public void testUnusedScript() { + String yaml = String.join("\n", + "scripts:", + " usedScript:", + " - sh: echo used", + " unusedScript:", + " - sh: echo unused", + "hosts:", + " myHost: user@host:22", + "roles:", + " myRole:", + " hosts:", + " - myHost", + " run-scripts:", + " - usedScript", + "" + ); + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + boolean hasUnused = diags.stream() + .anyMatch(d -> d.getSeverity() == DiagnosticSeverity.Information + && d.getMessage().contains("unusedScript") + && d.getMessage().contains("not referenced")); + assertTrue("Should detect unused script", hasUnused); + } + + @Test + public void testUnusedHost() { + String yaml = String.join("\n", + "scripts:", + " myScript:", + " - sh: echo hello", + "hosts:", + " usedHost: user@host:22", + " unusedHost: user@other:22", + "roles:", + " myRole:", + " hosts:", + " - usedHost", + " run-scripts:", + " - myScript", + "" + ); + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + boolean hasUnused = diags.stream() + .anyMatch(d -> d.getSeverity() == DiagnosticSeverity.Information + && d.getMessage().contains("unusedHost") + && d.getMessage().contains("not referenced")); + assertTrue("Should detect unused host", hasUnused); + } + + @Test + public void testBrokenYamlDiagnostic() { + String yaml = "scripts:\n myScript:\n - sh: {\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + if (!doc.isParseSuccessful()) { + boolean hasSyntaxError = diags.stream() + .anyMatch(d -> d.getSeverity() == DiagnosticSeverity.Error + && d.getMessage().contains("YAML syntax error")); + assertTrue("Should report YAML syntax error", hasSyntaxError); + } + } + + @Test + public void testValidCommandsNotFlagged() { + String yaml = String.join("\n", + "scripts:", + " myScript:", + " - sh: echo hello", + " - regex: pattern", + " then:", + " - log: matched", + " - set-state: myVar", + " - sleep: 1s", + "" + ); + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + List errors = diags.stream() + .filter(d -> d.getSeverity() == DiagnosticSeverity.Error) + .collect(Collectors.toList()); + assertTrue("Valid commands should not produce errors, but got: " + + errors.stream().map(Diagnostic::getMessage).collect(Collectors.joining(", ")), + errors.isEmpty()); + } + + @Test + public void testNoArgCommandAsScalar() { + String yaml = "scripts:\n myScript:\n - ctrlC\n - done\n - echo\n"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + List diags = provider.diagnose(doc); + + List errors = diags.stream() + .filter(d -> d.getSeverity() == DiagnosticSeverity.Error) + .collect(Collectors.toList()); + assertTrue("No-arg commands as scalars should not produce errors, got: " + + errors.stream().map(Diagnostic::getMessage).collect(Collectors.joining(", ")), + errors.isEmpty()); + } +} diff --git a/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DocumentSymbolProviderTest.java b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DocumentSymbolProviderTest.java new file mode 100644 index 00000000..483bf173 --- /dev/null +++ b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/DocumentSymbolProviderTest.java @@ -0,0 +1,174 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.DocumentSymbol; +import org.eclipse.lsp4j.SymbolKind; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; +import java.util.Optional; + +import static org.junit.Assert.*; + +public class DocumentSymbolProviderTest { + + private DocumentSymbolProvider provider; + + @Before + public void setUp() { + provider = new DocumentSymbolProvider(); + } + + private static final String FULL_YAML = String.join("\n", + "name: test", + "scripts:", + " start-db:", + " - sh: echo starting db", + " start-app:", + " - sh: echo starting app", + "hosts:", + " local: me@localhost", + " remote: user@server:22", + "roles:", + " test-role:", + " hosts:", + " - local", + " run-scripts:", + " - start-app", + "states:", + " DB_PORT: 5432", + " APP_NAME: myapp", + "" + ); + + @Test + public void testTopLevelSections() { + QDupDocument doc = new QDupDocument("test.yaml", FULL_YAML); + List symbols = provider.documentSymbols(doc); + + assertFalse("Should have symbols", symbols.isEmpty()); + + // Should have name, scripts, hosts, roles, states + assertTrue("Should have 'name' symbol", findSymbol(symbols, "name").isPresent()); + assertTrue("Should have 'scripts' symbol", findSymbol(symbols, "scripts").isPresent()); + assertTrue("Should have 'hosts' symbol", findSymbol(symbols, "hosts").isPresent()); + assertTrue("Should have 'roles' symbol", findSymbol(symbols, "roles").isPresent()); + assertTrue("Should have 'states' symbol", findSymbol(symbols, "states").isPresent()); + } + + @Test + public void testScriptsSection() { + QDupDocument doc = new QDupDocument("test.yaml", FULL_YAML); + List symbols = provider.documentSymbols(doc); + + DocumentSymbol scripts = findSymbol(symbols, "scripts").orElse(null); + assertNotNull(scripts); + assertEquals(SymbolKind.Namespace, scripts.getKind()); + + List children = scripts.getChildren(); + assertNotNull(children); + assertEquals(2, children.size()); + + assertTrue("Should have 'start-db' script", findSymbol(children, "start-db").isPresent()); + assertTrue("Should have 'start-app' script", findSymbol(children, "start-app").isPresent()); + + // Script children should be Function kind + assertEquals(SymbolKind.Function, findSymbol(children, "start-db").get().getKind()); + } + + @Test + public void testHostsSection() { + QDupDocument doc = new QDupDocument("test.yaml", FULL_YAML); + List symbols = provider.documentSymbols(doc); + + DocumentSymbol hosts = findSymbol(symbols, "hosts").orElse(null); + assertNotNull(hosts); + assertEquals(SymbolKind.Namespace, hosts.getKind()); + + List children = hosts.getChildren(); + assertNotNull(children); + assertEquals(2, children.size()); + + assertTrue("Should have 'local' host", findSymbol(children, "local").isPresent()); + assertTrue("Should have 'remote' host", findSymbol(children, "remote").isPresent()); + + assertEquals(SymbolKind.Property, findSymbol(children, "local").get().getKind()); + } + + @Test + public void testRolesSection() { + QDupDocument doc = new QDupDocument("test.yaml", FULL_YAML); + List symbols = provider.documentSymbols(doc); + + DocumentSymbol roles = findSymbol(symbols, "roles").orElse(null); + assertNotNull(roles); + assertEquals(SymbolKind.Namespace, roles.getKind()); + + List roleChildren = roles.getChildren(); + assertNotNull(roleChildren); + assertEquals(1, roleChildren.size()); + + DocumentSymbol testRole = roleChildren.get(0); + assertEquals("test-role", testRole.getName()); + assertEquals(SymbolKind.Class, testRole.getKind()); + + // Role should have hosts and run-scripts as grandchildren + List roleProps = testRole.getChildren(); + assertNotNull(roleProps); + assertEquals(2, roleProps.size()); + + assertTrue("Should have 'hosts' property", findSymbol(roleProps, "hosts").isPresent()); + assertTrue("Should have 'run-scripts' property", findSymbol(roleProps, "run-scripts").isPresent()); + assertEquals(SymbolKind.Property, findSymbol(roleProps, "hosts").get().getKind()); + } + + @Test + public void testStatesSection() { + QDupDocument doc = new QDupDocument("test.yaml", FULL_YAML); + List symbols = provider.documentSymbols(doc); + + DocumentSymbol states = findSymbol(symbols, "states").orElse(null); + assertNotNull(states); + assertEquals(SymbolKind.Namespace, states.getKind()); + + List children = states.getChildren(); + assertNotNull(children); + assertEquals(2, children.size()); + + assertTrue("Should have 'DB_PORT' state", findSymbol(children, "DB_PORT").isPresent()); + assertTrue("Should have 'APP_NAME' state", findSymbol(children, "APP_NAME").isPresent()); + + assertEquals(SymbolKind.Variable, findSymbol(children, "DB_PORT").get().getKind()); + } + + @Test + public void testSymbolRangesAreValid() { + QDupDocument doc = new QDupDocument("test.yaml", FULL_YAML); + List symbols = provider.documentSymbols(doc); + + for (DocumentSymbol sym : symbols) { + assertNotNull("Range should not be null for " + sym.getName(), sym.getRange()); + assertNotNull("Selection range should not be null for " + sym.getName(), sym.getSelectionRange()); + assertTrue("Start line should be >= 0 for " + sym.getName(), + sym.getRange().getStart().getLine() >= 0); + } + } + + @Test + public void testEmptyDocument() { + QDupDocument doc = new QDupDocument("test.yaml", ""); + List symbols = provider.documentSymbols(doc); + assertTrue("Empty document should have no symbols", symbols.isEmpty()); + } + + @Test + public void testBrokenYaml() { + QDupDocument doc = new QDupDocument("test.yaml", "scripts:\n - sh: {\n"); + List symbols = provider.documentSymbols(doc); + assertTrue("Broken YAML should return empty symbols", symbols.isEmpty()); + } + + private Optional findSymbol(List symbols, String name) { + return symbols.stream().filter(s -> name.equals(s.getName())).findFirst(); + } +} diff --git a/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/HoverProviderTest.java b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/HoverProviderTest.java new file mode 100644 index 00000000..21f246fb --- /dev/null +++ b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/HoverProviderTest.java @@ -0,0 +1,147 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.eclipse.lsp4j.Hover; +import org.junit.Before; +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.Properties; + +import static org.junit.Assert.*; + +public class HoverProviderTest { + + private CommandRegistry registry; + private CursorContextResolver contextResolver; + private HoverProvider provider; + private Properties commandDocs; + + @Before + public void setUp() { + registry = new CommandRegistry(); + contextResolver = new CursorContextResolver(registry); + commandDocs = new Properties(); + try (InputStream is = getClass().getClassLoader().getResourceAsStream("command-docs.properties")) { + if (is != null) { + commandDocs.load(is); + } + } catch (IOException e) { + // Use empty docs + } + provider = new HoverProvider(registry, contextResolver, commandDocs); + } + + @Test + public void testHoverOnCommand() { + QDupDocument doc = new QDupDocument("test.yaml", + "scripts:\n myScript:\n - sh: echo hello"); + // hover over "sh" at line 2, character 4 + Hover hover = provider.hover(doc, 2, 4); + assertNotNull("Should return hover for 'sh' command", hover); + String content = hover.getContents().getRight().getValue(); + assertTrue("Hover should mention 'sh'", content.contains("**sh**")); + } + + @Test + public void testHoverOnNoArgCommand() { + QDupDocument doc = new QDupDocument("test.yaml", + "scripts:\n myScript:\n - ctrlC"); + Hover hover = provider.hover(doc, 2, 6); + assertNotNull("Should return hover for 'ctrlC'", hover); + String content = hover.getContents().getRight().getValue(); + assertTrue("Hover should mention 'ctrlC'", content.contains("**ctrlC**")); + } + + @Test + public void testHoverOnCommandParamFallsBackToModifier() { + // When "silent" appears as a parameter under a command, COMMAND_PARAM_KEY context + // falls back to modifier hover when no param doc is found + String yaml = "scripts:\n myScript:\n - sh:\n command: echo hello\n silent: true"; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + Hover hover = provider.hover(doc, 4, 10); + assertNotNull("Should return hover for 'silent' (modifier fallback)", hover); + String content = hover.getContents().getRight().getValue(); + assertTrue("Hover should mention 'silent'", content.contains("**silent**")); + } + + @Test + public void testHoverOnElseModifier() { + // "else" under a regex command at the same indent level + String yaml = "scripts:\n myScript:\n - regex: \".*foo.*\""; + QDupDocument doc = new QDupDocument("test.yaml", yaml); + Hover hover = provider.hover(doc, 2, 8); + assertNotNull("Should return hover for 'regex' command", hover); + String content = hover.getContents().getRight().getValue(); + assertTrue("Hover should mention 'regex'", content.contains("**regex**")); + } + + @Test + public void testHoverOnTopLevelKey() { + QDupDocument doc = new QDupDocument("test.yaml", "scripts:"); + Hover hover = provider.hover(doc, 0, 3); + assertNotNull("Should return hover for 'scripts' top-level key", hover); + String content = hover.getContents().getRight().getValue(); + assertTrue("Hover should mention 'scripts'", content.contains("**scripts**")); + } + + @Test + public void testHoverOnHostConfigKey() { + QDupDocument doc = new QDupDocument("test.yaml", + "hosts:\n myHost:\n username: root"); + Hover hover = provider.hover(doc, 2, 6); + assertNotNull("Should return hover for 'username'", hover); + String content = hover.getContents().getRight().getValue(); + assertTrue("Hover should mention 'username'", content.contains("**username**")); + } + + @Test + public void testHoverOnRoleKey() { + QDupDocument doc = new QDupDocument("test.yaml", + "roles:\n myRole:\n hosts:\n - myHost\n run-scripts:\n - myScript"); + Hover hover = provider.hover(doc, 4, 6); + assertNotNull("Should return hover for 'run-scripts'", hover); + String content = hover.getContents().getRight().getValue(); + assertTrue("Hover should mention 'run-scripts'", content.contains("**run-scripts**")); + } + + @Test + public void testHoverOnStateVariable() { + QDupDocument doc = new QDupDocument("test.yaml", + "scripts:\n myScript:\n - sh: echo ${{myVar}}\nstates:\n myVar: hello"); + Hover hover = provider.hover(doc, 2, 18, Collections.singleton(doc)); + assertNotNull("Should return hover for state variable", hover); + String content = hover.getContents().getRight().getValue(); + assertTrue("Hover should contain variable name", content.contains("myVar")); + assertTrue("Hover should contain value", content.contains("hello")); + } + + @Test + public void testHoverOnUndefinedStateVariable() { + QDupDocument doc = new QDupDocument("test.yaml", + "scripts:\n myScript:\n - sh: echo ${{unknown}}"); + Hover hover = provider.hover(doc, 2, 19, Collections.singleton(doc)); + assertNotNull("Should return hover for undefined variable", hover); + String content = hover.getContents().getRight().getValue(); + assertTrue("Hover should indicate undefined", content.contains("undefined")); + } + + @Test + public void testHoverOnNonWord() { + QDupDocument doc = new QDupDocument("test.yaml", " "); + Hover hover = provider.hover(doc, 0, 1); + assertNull("Should return null for whitespace", hover); + } + + @Test + public void testHoverOnCommandParameter() { + QDupDocument doc = new QDupDocument("test.yaml", + "scripts:\n myScript:\n - sh:\n command: echo hello"); + // hover over "command" at line 3 + Hover hover = provider.hover(doc, 3, 8); + assertNotNull("Should return hover for 'command' parameter", hover); + String content = hover.getContents().getRight().getValue(); + assertTrue("Hover should mention 'command'", content.contains("**command**")); + } +} diff --git a/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/QDupDocumentTest.java b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/QDupDocumentTest.java new file mode 100644 index 00000000..52c44156 --- /dev/null +++ b/qDup-lsp/src/test/java/io/hyperfoil/tools/qdup/lsp/QDupDocumentTest.java @@ -0,0 +1,191 @@ +package io.hyperfoil.tools.qdup.lsp; + +import org.junit.Test; +import org.yaml.snakeyaml.nodes.Node; + +import java.util.Set; + +import static org.junit.Assert.*; + +public class QDupDocumentTest { + + @Test + public void testParseValidDocument() { + QDupDocument doc = new QDupDocument("test.yaml", + "scripts:\n myScript:\n - sh: echo hello"); + assertTrue("Valid YAML should parse successfully", doc.isParseSuccessful()); + assertNotNull("Root node should not be null", doc.getRootNode()); + } + + @Test + public void testParseInvalidYaml() { + QDupDocument doc = new QDupDocument("test.yaml", + "scripts:\n - :\n invalid: ["); + assertFalse("Invalid YAML should not parse successfully", doc.isParseSuccessful()); + } + + @Test + public void testParseEmptyDocument() { + QDupDocument doc = new QDupDocument("test.yaml", ""); + assertFalse("Empty document should not parse successfully", doc.isParseSuccessful()); + } + + @Test + public void testGetLine() { + QDupDocument doc = new QDupDocument("test.yaml", "line0\nline1\nline2"); + assertEquals("line0", doc.getLine(0)); + assertEquals("line1", doc.getLine(1)); + assertEquals("line2", doc.getLine(2)); + } + + @Test + public void testGetLineOutOfBounds() { + QDupDocument doc = new QDupDocument("test.yaml", "only line"); + assertEquals("", doc.getLine(-1)); + assertEquals("", doc.getLine(5)); + } + + @Test + public void testGetScriptNames() { + QDupDocument doc = new QDupDocument("test.yaml", + "scripts:\n setup:\n - sh: echo setup\n cleanup:\n - sh: echo cleanup"); + Set names = doc.getScriptNames(); + assertTrue("Should contain 'setup'", names.contains("setup")); + assertTrue("Should contain 'cleanup'", names.contains("cleanup")); + assertEquals(2, names.size()); + } + + @Test + public void testGetScriptNamesNoScriptsSection() { + QDupDocument doc = new QDupDocument("test.yaml", "hosts:\n myHost: user@host"); + Set names = doc.getScriptNames(); + assertTrue("Should be empty when no scripts section", names.isEmpty()); + } + + @Test + public void testGetHostNames() { + QDupDocument doc = new QDupDocument("test.yaml", + "hosts:\n server1:\n hostname: host1\n server2:\n hostname: host2"); + Set names = doc.getHostNames(); + assertTrue("Should contain 'server1'", names.contains("server1")); + assertTrue("Should contain 'server2'", names.contains("server2")); + assertEquals(2, names.size()); + } + + @Test + public void testGetStateKeys() { + QDupDocument doc = new QDupDocument("test.yaml", + "states:\n foo: bar\n count: 5"); + Set keys = doc.getStateKeys(); + assertTrue("Should contain 'foo'", keys.contains("foo")); + assertTrue("Should contain 'count'", keys.contains("count")); + } + + @Test + public void testGetStateValue() { + QDupDocument doc = new QDupDocument("test.yaml", + "states:\n myKey: myValue"); + assertEquals("myValue", doc.getStateValue("myKey")); + assertNull(doc.getStateValue("nonexistent")); + } + + @Test + public void testGetNestedStateValue() { + QDupDocument doc = new QDupDocument("test.yaml", + "states:\n server:\n host: example.com\n port: 8080"); + assertEquals("example.com", doc.getStateValue("server.host")); + assertEquals("8080", doc.getStateValue("server.port")); + } + + @Test + public void testFindScriptNode() { + QDupDocument doc = new QDupDocument("test.yaml", + "scripts:\n myScript:\n - sh: echo hello"); + Node node = doc.findScriptNode("myScript"); + assertNotNull("Should find 'myScript' node", node); + assertNull("Should not find nonexistent script", doc.findScriptNode("missing")); + } + + @Test + public void testFindHostNode() { + QDupDocument doc = new QDupDocument("test.yaml", + "hosts:\n myHost:\n hostname: example.com"); + Node node = doc.findHostNode("myHost"); + assertNotNull("Should find 'myHost' node", node); + assertNull("Should not find nonexistent host", doc.findHostNode("missing")); + } + + @Test + public void testFindStateNode() { + QDupDocument doc = new QDupDocument("test.yaml", + "states:\n myVar: hello"); + assertNotNull("Should find state node", doc.findStateNode("myVar")); + assertNull("Should not find missing state", doc.findStateNode("missing")); + } + + @Test + public void testFindNestedStateNode() { + QDupDocument doc = new QDupDocument("test.yaml", + "states:\n server:\n host: example.com"); + assertNotNull("Should find nested state node", doc.findStateNode("server.host")); + } + + @Test + public void testFindStateNodeInGlobals() { + QDupDocument doc = new QDupDocument("test.yaml", + "globals:\n globalVar: value"); + assertNotNull("Should find state in globals section", doc.findStateNode("globalVar")); + } + + @Test + public void testGetReferencedScripts() { + QDupDocument doc = new QDupDocument("test.yaml", + "roles:\n myRole:\n hosts:\n - h1\n run-scripts:\n - scriptA\n - scriptB"); + Set refs = doc.getReferencedScripts(); + assertTrue("Should reference 'scriptA'", refs.contains("scriptA")); + assertTrue("Should reference 'scriptB'", refs.contains("scriptB")); + } + + @Test + public void testGetReferencedHosts() { + QDupDocument doc = new QDupDocument("test.yaml", + "roles:\n myRole:\n hosts:\n - hostA\n - hostB\n run-scripts:\n - s1"); + Set refs = doc.getReferencedHosts(); + assertTrue("Should reference 'hostA'", refs.contains("hostA")); + assertTrue("Should reference 'hostB'", refs.contains("hostB")); + } + + @Test + public void testSetText() { + QDupDocument doc = new QDupDocument("test.yaml", "scripts:"); + assertTrue(doc.isParseSuccessful()); + assertEquals("scripts:", doc.getLine(0)); + + doc.setText("hosts:\n h1: user@host"); + assertTrue(doc.isParseSuccessful()); + assertEquals("hosts:", doc.getLine(0)); + } + + @Test + public void testFindStateNodeNullInput() { + QDupDocument doc = new QDupDocument("test.yaml", "states:\n foo: bar"); + assertNull(doc.findStateNode(null)); + } + + @Test + public void testGetStateValueNullInput() { + QDupDocument doc = new QDupDocument("test.yaml", "states:\n foo: bar"); + assertNull(doc.getStateValue(null)); + } + + @Test + public void testGetStateKeysNested() { + QDupDocument doc = new QDupDocument("test.yaml", + "states:\n server:\n host: example.com\n port: 8080\n flat: value"); + Set keys = doc.getStateKeys(); + assertTrue("Should contain top-level 'server'", keys.contains("server")); + assertTrue("Should contain top-level 'flat'", keys.contains("flat")); + assertTrue("Should contain nested 'server.host'", keys.contains("server.host")); + assertTrue("Should contain nested 'server.port'", keys.contains("server.port")); + } +} diff --git a/vscode/.gitignore b/vscode/.gitignore new file mode 100644 index 00000000..d3e15b1e --- /dev/null +++ b/vscode/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +out/ +*.vsix diff --git a/vscode/.vscodeignore b/vscode/.vscodeignore new file mode 100644 index 00000000..41fcf0a8 --- /dev/null +++ b/vscode/.vscodeignore @@ -0,0 +1,5 @@ +src/ +node_modules/ +tsconfig.json +.gitignore +*.map diff --git a/vscode/README.md b/vscode/README.md new file mode 100644 index 00000000..6becbb86 --- /dev/null +++ b/vscode/README.md @@ -0,0 +1,88 @@ +# qDup VS Code Extension + +Language support for [qDup](https://github.com/Hyperfoil/qDup) YAML scripts, powered by the qDup Language Server. + +## Features + +- **Completion** — context-aware suggestions for top-level keys, commands, modifiers, command parameters, host config, role properties, and script references +- **Diagnostics** — errors for unknown keys/commands, warnings for undefined references, info for unused scripts/hosts +- **Hover documentation** — inline docs for commands, parameters, modifiers, host config keys, and more + +## Requirements + +- **Java 17+** — required to run the LSP server +- One of the following to provide the server: + - The qDup LSP fat JAR (`qDup-lsp-.jar`), or + - [JBang](https://www.jbang.dev/) installed on your PATH + +## Installation + +### From source + +```bash +cd vscode +npm install +npm run compile +``` + +Then press `F5` in VS Code with the `vscode/` folder open to launch an Extension Development Host. + +### Package as .vsix + +```bash +cd vscode +npm install +npm run compile +npx @vscode/vsce package +``` + +Install the resulting `.vsix` file via **Extensions > ... > Install from VSIX**. + +## File Association + +The extension activates automatically for files with these extensions: + +- `*.qdup.yaml` +- `*.qdup.yml` + +## Server Discovery + +The extension locates the qDup LSP server using the following order: + +1. **`qdup.lsp.jarPath` setting** — if set to a valid file path, uses `java -jar ` +2. **Bundled JAR** — looks for `server/qDup-lsp.jar` inside the extension directory +3. **JBang** — if JBang is on your PATH, runs the bundled `server/qdup-lsp.java` script +4. **Error** — displays a message with a link to open settings + +To bundle the JAR with the extension, build it and copy it in: + +```bash +mvn -pl qDup-lsp package -DskipTests +cp qDup-lsp/target/qDup-lsp-*.jar vscode/server/qDup-lsp.jar +``` + +## Settings + +| Setting | Default | Description | +|---|---|---| +| `qdup.lsp.jarPath` | *(empty)* | Absolute path to the qDup LSP server JAR file. Leave empty for auto-detection. | +| `qdup.lsp.jbangPath` | `jbang` | Path to the JBang executable. | +| `qdup.lsp.javaHome` | *(empty)* | Path to a Java 17+ installation. Leave empty to use `JAVA_HOME` or `java` on PATH. | + +## Commands + +| Command | Description | +|---|---| +| `qDup: Restart Language Server` | Restarts the LSP server without reloading the window. | + +Access commands via the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`). + +## Development + +```bash +cd vscode +npm install +npm run watch # recompile on changes +``` + +Open the `vscode/` folder in VS Code and press `F5` to launch a development instance with the extension loaded. diff --git a/vscode/language-configuration.json b/vscode/language-configuration.json new file mode 100644 index 00000000..a71705e8 --- /dev/null +++ b/vscode/language-configuration.json @@ -0,0 +1,15 @@ +{ + "comments": { + "lineComment": "#" + }, + "brackets": [ + ["{", "}"], + ["[", "]"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "\"", "close": "\"" }, + { "open": "'", "close": "'" } + ] +} diff --git a/vscode/package-lock.json b/vscode/package-lock.json new file mode 100644 index 00000000..80f10daf --- /dev/null +++ b/vscode/package-lock.json @@ -0,0 +1,2541 @@ +{ + "name": "qdup", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "qdup", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^18.0.0", + "@types/vscode": "^1.75.0", + "@vscode/vsce": "^2.22.0", + "typescript": "^5.3.0" + }, + "engines": { + "vscode": "^1.75.0" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz", + "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", + "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", + "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.22.2", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz", + "integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz", + "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz", + "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", + "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^4.2.0", + "@azure/msal-node": "^3.5.0", + "open": "^10.1.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz", + "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.28.2.tgz", + "integrity": "sha512-6vYUMvs6kJxJgxaCmHn/F8VxjLHNh7i9wzfwPGf8kyBJ8Gg2yvBXx175Uev8LdrD1F5C4o7qHa2CC4IrhGE1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.14.2" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.14.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.14.2.tgz", + "integrity": "sha512-n8RBJEUmd5QotoqbZfd+eGBkzuFI1KX6jw2b3WcpSyGjwmzoeI/Jb99opIBPHpb8y312NB+B6+FGi2ZVSR8yfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.7.tgz", + "integrity": "sha512-a+Xnrae+uwLnlw68bplS1X4kuJ9F/7K6afuMFyRkNIskhjgDezl5Fhrx+1pmAlDmC0VaaAxjRQMp1OmcqVwkIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.14.2", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/vscode": { + "version": "1.109.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", + "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.3.tgz", + "integrity": "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@vscode/vsce": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.32.0.tgz", + "integrity": "sha512-3EFJfsgrSftIqt3EtdRcAygy/OJ3hstyI1cDmIgkU9CFZW5C+3djr6mfosndCUqcVYuyjmxOK1xmFp/Bq7+NIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@azure/identity": "^4.1.0", + "@vscode/vsce-sign": "^2.0.0", + "azure-devops-node-api": "^12.5.0", + "chalk": "^2.4.2", + "cheerio": "^1.0.0-rc.9", + "cockatiel": "^3.1.2", + "commander": "^6.2.1", + "form-data": "^4.0.0", + "glob": "^7.0.6", + "hosted-git-info": "^4.0.2", + "jsonc-parser": "^3.2.0", + "leven": "^3.1.0", + "markdown-it": "^12.3.2", + "mime": "^1.3.4", + "minimatch": "^3.0.3", + "parse-semver": "^1.1.1", + "read": "^1.0.7", + "semver": "^7.5.2", + "tmp": "^0.2.1", + "typed-rest-client": "^1.8.4", + "url-join": "^4.0.1", + "xml2js": "^0.5.0", + "yauzl": "^2.3.1", + "yazl": "^2.2.2" + }, + "bin": { + "vsce": "vsce" + }, + "engines": { + "node": ">= 16" + }, + "optionalDependencies": { + "keytar": "^7.7.0" + } + }, + "node_modules/@vscode/vsce-sign": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", + "dev": true, + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optionalDependencies": { + "@vscode/vsce-sign-alpine-arm64": "2.0.6", + "@vscode/vsce-sign-alpine-x64": "2.0.6", + "@vscode/vsce-sign-darwin-arm64": "2.0.6", + "@vscode/vsce-sign-darwin-x64": "2.0.6", + "@vscode/vsce-sign-linux-arm": "2.0.6", + "@vscode/vsce-sign-linux-arm64": "2.0.6", + "@vscode/vsce-sign-linux-x64": "2.0.6", + "@vscode/vsce-sign-win32-arm64": "2.0.6", + "@vscode/vsce-sign-win32-x64": "2.0.6" + } + }, + "node_modules/@vscode/vsce-sign-alpine-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz", + "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-alpine-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz", + "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "alpine" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz", + "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-darwin-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz", + "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz", + "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz", + "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-linux-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz", + "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/vsce-sign-win32-arm64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz", + "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/vsce-sign-win32-x64": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz", + "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/azure-devops-node-api": { + "version": "12.5.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", + "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==", + "dev": true, + "license": "MIT", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/cockatiel": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", + "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/linkify-it": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", + "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/markdown-it": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", + "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "~2.1.0", + "linkify-it": "^3.0.1", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-semver": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz", + "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.1.0" + } + }, + "node_modules/parse-semver/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/read": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", + "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "mute-stream": "~0.0.4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", + "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/underscore": { + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", + "license": "MIT", + "dependencies": { + "minimatch": "^5.1.0", + "semver": "^7.3.7", + "vscode-languageserver-protocol": "3.17.5" + }, + "engines": { + "vscode": "^1.82.0" + } + }, + "node_modules/vscode-languageclient/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/vscode-languageclient/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yazl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", + "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3" + } + } + } +} diff --git a/vscode/package.json b/vscode/package.json new file mode 100644 index 00000000..f9ed64e6 --- /dev/null +++ b/vscode/package.json @@ -0,0 +1,78 @@ +{ + "name": "qdup", + "displayName": "qDup", + "description": "Language support for qDup YAML scripts", + "version": "0.1.0", + "publisher": "hyperfoil", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/Hyperfoil/qDup" + }, + "engines": { + "vscode": "^1.75.0" + }, + "categories": [ + "Programming Languages" + ], + "activationEvents": [ + "onLanguage:qdup-yaml" + ], + "main": "./out/extension.js", + "contributes": { + "languages": [ + { + "id": "qdup-yaml", + "aliases": [ + "qDup YAML", + "qdup" + ], + "extensions": [ + ".qdup.yaml", + ".qdup.yml" + ], + "configuration": "./language-configuration.json" + } + ], + "configuration": { + "title": "qDup", + "properties": { + "qdup.lsp.jarPath": { + "type": "string", + "default": "", + "description": "Absolute path to the qDup LSP server JAR file. Leave empty for auto-detection." + }, + "qdup.lsp.jbangPath": { + "type": "string", + "default": "jbang", + "description": "Path to the JBang executable." + }, + "qdup.lsp.javaHome": { + "type": "string", + "default": "", + "description": "Path to a Java 17+ installation. Leave empty to use JAVA_HOME or java on PATH." + } + } + }, + "commands": [ + { + "command": "qdup.restartServer", + "title": "qDup: Restart Language Server" + } + ] + }, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "package": "vsce package" + }, + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@types/vscode": "^1.75.0", + "@types/node": "^18.0.0", + "typescript": "^5.3.0", + "@vscode/vsce": "^2.22.0" + } +} diff --git a/vscode/server/qdup-lsp.java b/vscode/server/qdup-lsp.java new file mode 100755 index 00000000..8925dc75 --- /dev/null +++ b/vscode/server/qdup-lsp.java @@ -0,0 +1,13 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 17+ +//DEPS io.hyperfoil.tools:qDup-lsp:0.11.1-SNAPSHOT + +import io.hyperfoil.tools.qdup.lsp.QDupLspLauncher; + +class qduplsp { + + public static void main(String... args) { + QDupLspLauncher.main(args); + } + +} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts new file mode 100644 index 00000000..49468e9d --- /dev/null +++ b/vscode/src/extension.ts @@ -0,0 +1,135 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import { workspace, ExtensionContext, window, commands } from 'vscode'; +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, +} from 'vscode-languageclient/node'; +import { execFileSync } from 'child_process'; + +let client: LanguageClient | undefined; + +export async function activate(context: ExtensionContext): Promise { + const serverOptions = await resolveServerOptions(context); + if (!serverOptions) { + return; + } + + const clientOptions: LanguageClientOptions = { + documentSelector: [{ language: 'qdup-yaml' }], + synchronize: { + fileEvents: workspace.createFileSystemWatcher('**/*.qdup.{yaml,yml}'), + }, + }; + + client = new LanguageClient( + 'qdup-lsp', + 'qDup Language Server', + serverOptions, + clientOptions + ); + + client.start(); + + const restartCmd = commands.registerCommand('qdup.restartServer', async () => { + if (client) { + await client.restart(); + } + }); + + context.subscriptions.push(client, restartCmd); +} + +export async function deactivate(): Promise { + if (client) { + await client.stop(); + client = undefined; + } +} + +async function resolveServerOptions(context: ExtensionContext): Promise { + const config = workspace.getConfiguration('qdup.lsp'); + + // 1. User-configured JAR path + const jarPath = config.get('jarPath', ''); + if (jarPath) { + if (fs.existsSync(jarPath)) { + const java = findJava(); + return { command: java, args: ['-jar', jarPath] }; + } + window.showWarningMessage( + `Configured qDup LSP JAR not found: ${jarPath}. Falling back to other discovery methods.`, + 'Open Settings' + ).then(selection => { + if (selection === 'Open Settings') { + commands.executeCommand('workbench.action.openSettings', 'qdup.lsp.jarPath'); + } + }); + } + + // 2. Bundled JAR in extension's server/ directory + const bundledJar = path.join(context.extensionPath, 'server', 'qDup-lsp.jar'); + if (fs.existsSync(bundledJar)) { + const java = findJava(); + return { command: java, args: ['-jar', bundledJar] }; + } + + // 3. JBang with bundled script + const jbangScript = path.join(context.extensionPath, 'server', 'qdup-lsp.java'); + if (fs.existsSync(jbangScript)) { + const jbangPath = config.get('jbangPath', 'jbang'); + if (isExecutableOnPath(jbangPath)) { + return { command: jbangPath, args: [jbangScript] }; + } + } + + // Nothing found — show error + window.showErrorMessage( + 'qDup LSP server not found. Set "qdup.lsp.jarPath" in settings or install JBang.', + 'Open Settings' + ).then(selection => { + if (selection === 'Open Settings') { + commands.executeCommand('workbench.action.openSettings', 'qdup.lsp'); + } + }); + + return undefined; +} + +function findJava(): string { + const config = workspace.getConfiguration('qdup.lsp'); + + const javaExe = process.platform === 'win32' ? 'java.exe' : 'java'; + + // 1. User-configured JAVA_HOME + const configJavaHome = config.get('javaHome', ''); + if (configJavaHome) { + const javaBin = path.join(configJavaHome, 'bin', javaExe); + if (fs.existsSync(javaBin)) { + return javaBin; + } + } + + // 2. JAVA_HOME environment variable + const envJavaHome = process.env['JAVA_HOME']; + if (envJavaHome) { + const javaBin = path.join(envJavaHome, 'bin', javaExe); + if (fs.existsSync(javaBin)) { + return javaBin; + } + } + + // 3. Fall back to java on PATH + return 'java'; +} + +function isExecutableOnPath(name: string): boolean { + try { + const cmd = process.platform === 'win32' ? 'where' : 'which'; + execFileSync(cmd, [name], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} diff --git a/vscode/tsconfig.json b/vscode/tsconfig.json new file mode 100644 index 00000000..60198d58 --- /dev/null +++ b/vscode/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "lib": ["ES2020"], + "outDir": "./out", + "rootDir": "./src", + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "out"] +}