@blokjs/lsp-server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hover.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { type Hover, MarkupKind, Position, Range } from "vscode-languageserver";
2
+ import { FIELD_DOCS, type HoverDoc, STEP_FIELD_DOCS, TRIGGER_DOCS } from "./constants";
3
+
4
+ /**
5
+ * Provides hover documentation for Blok workflow JSON files via LSP.
6
+ *
7
+ * Shows contextual documentation when hovering over:
8
+ * - Trigger type keys (http, grpc, cron, queue, etc.)
9
+ * - Workflow fields (name, version, steps, nodes, etc.)
10
+ * - Node configuration fields (inputs, conditions, set_var)
11
+ * - Step fields (node, type, runtime)
12
+ * - HTTP method values, runtime type values, node packages
13
+ */
14
+ export function getHover(text: string, line: number, character: number): Hover | null {
15
+ const lines = text.split("\n");
16
+ if (line >= lines.length) return null;
17
+
18
+ const lineText = lines[line];
19
+
20
+ // Find the quoted word at the cursor position
21
+ const wordInfo = findQuotedWordAt(lineText, character);
22
+ if (!wordInfo) return null;
23
+
24
+ const { word, startChar, endChar } = wordInfo;
25
+ const range = Range.create(Position.create(line, startChar), Position.create(line, endChar));
26
+
27
+ // Check if it's a key (followed by colon)
28
+ const afterWord = lineText.substring(endChar).trimStart();
29
+ const isKey = afterWord.startsWith(":");
30
+
31
+ if (isKey) {
32
+ // Trigger type documentation
33
+ if (TRIGGER_DOCS[word]) {
34
+ return createDocHover(TRIGGER_DOCS[word], range);
35
+ }
36
+
37
+ // Field documentation
38
+ if (FIELD_DOCS[word]) {
39
+ return createDocHover(FIELD_DOCS[word], range);
40
+ }
41
+
42
+ // Step field documentation
43
+ if (STEP_FIELD_DOCS[word]) {
44
+ return createDocHover(STEP_FIELD_DOCS[word], range);
45
+ }
46
+ }
47
+
48
+ // Value-based hover
49
+ if (!isKey) {
50
+ // HTTP methods
51
+ if (["GET", "POST", "PUT", "DELETE", "PATCH", "ANY"].includes(word)) {
52
+ return {
53
+ contents: {
54
+ kind: MarkupKind.Markdown,
55
+ value: `**HTTP Method: ${word}**\n\nHTTP request method that will trigger this workflow.`,
56
+ },
57
+ range,
58
+ };
59
+ }
60
+
61
+ // Runtime type values (runtime.go, runtime.python3, etc.)
62
+ if (word.startsWith("runtime.")) {
63
+ const lang = word.replace("runtime.", "");
64
+ return {
65
+ contents: {
66
+ kind: MarkupKind.Markdown,
67
+ value: `**Runtime Type: ${lang}**\n\nExecutes this node using the ${lang} runtime adapter. The node code must be written in ${lang} and served via the Blok runtime protocol (HTTP/gRPC).`,
68
+ },
69
+ range,
70
+ };
71
+ }
72
+
73
+ // Common node packages
74
+ if (word === "@blokjs/api-call") {
75
+ return {
76
+ contents: {
77
+ kind: MarkupKind.Markdown,
78
+ value:
79
+ "**@blokjs/api-call**\n\nMakes HTTP API calls to external services.\n\n**Inputs:** `url`, `method`, `headers`, `body`, `responseType`",
80
+ },
81
+ range,
82
+ };
83
+ }
84
+ if (word === "@blokjs/if-else") {
85
+ return {
86
+ contents: {
87
+ kind: MarkupKind.Markdown,
88
+ value:
89
+ "**@blokjs/if-else**\n\nConditional branching node. Evaluates JavaScript conditions against the workflow context.\n\nConfigure conditions in the `nodes` section using the `conditions` array.",
90
+ },
91
+ range,
92
+ };
93
+ }
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ function findQuotedWordAt(
100
+ line: string,
101
+ character: number,
102
+ ): { word: string; startChar: number; endChar: number } | null {
103
+ // Find all quoted strings in the line and check if cursor is inside one
104
+ const regex = /"([^"]*)"/g;
105
+
106
+ for (const match of line.matchAll(regex)) {
107
+ const start = match.index; // position of opening quote
108
+ const end = match.index + match[0].length; // position after closing quote
109
+
110
+ if (character >= start && character <= end) {
111
+ return {
112
+ word: match[1],
113
+ startChar: start,
114
+ endChar: end,
115
+ };
116
+ }
117
+ }
118
+
119
+ return null;
120
+ }
121
+
122
+ function createDocHover(doc: HoverDoc, range: Range): Hover {
123
+ let value = `**${doc.title}**\n\n${doc.description}\n\n`;
124
+ if (doc.example) {
125
+ value += "```json\n" + doc.example + "\n```";
126
+ }
127
+
128
+ return {
129
+ contents: {
130
+ kind: MarkupKind.Markdown,
131
+ value,
132
+ },
133
+ range,
134
+ };
135
+ }
package/src/server.ts ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ import { TextDocument } from "vscode-languageserver-textdocument";
3
+ import {
4
+ type CompletionParams,
5
+ DidChangeConfigurationNotification,
6
+ type HoverParams,
7
+ type InitializeParams,
8
+ type InitializeResult,
9
+ ProposedFeatures,
10
+ TextDocumentSyncKind,
11
+ TextDocuments,
12
+ createConnection,
13
+ } from "vscode-languageserver/node";
14
+ import { getCompletions } from "./completion";
15
+ import { validateWorkflow } from "./diagnostics";
16
+ import { getHover } from "./hover";
17
+
18
+ /**
19
+ * Blok Workflow Language Server
20
+ *
21
+ * Provides workflow intelligence for any LSP-compatible editor:
22
+ * - Diagnostics: Real-time validation of workflow JSON files
23
+ * - Completion: Contextual auto-completion for triggers, steps, runtimes, etc.
24
+ * - Hover: Rich documentation on hover for workflow fields and values
25
+ *
26
+ * Communication: stdio (default) or TCP
27
+ * File types: JSON files matching `**​/workflows/**​/*.json` or `blok.workflow.json`
28
+ */
29
+
30
+ // Create connection and document manager
31
+ const connection = createConnection(ProposedFeatures.all);
32
+ const documents = new TextDocuments(TextDocument);
33
+
34
+ let hasConfigurationCapability = false;
35
+ let hasWorkspaceFolderCapability = false;
36
+
37
+ // Server settings
38
+ interface BlokLspSettings {
39
+ workflowGlob: string;
40
+ maxDiagnostics: number;
41
+ }
42
+
43
+ const defaultSettings: BlokLspSettings = {
44
+ workflowGlob: "**/workflows/**/*.json",
45
+ maxDiagnostics: 100,
46
+ };
47
+
48
+ let globalSettings: BlokLspSettings = defaultSettings;
49
+ const documentSettings = new Map<string, BlokLspSettings>();
50
+
51
+ connection.onInitialize((params: InitializeParams): InitializeResult => {
52
+ const capabilities = params.capabilities;
53
+
54
+ hasConfigurationCapability = !!(capabilities.workspace && capabilities.workspace.configuration);
55
+ hasWorkspaceFolderCapability = !!(capabilities.workspace && capabilities.workspace.workspaceFolders);
56
+
57
+ const result: InitializeResult = {
58
+ capabilities: {
59
+ textDocumentSync: TextDocumentSyncKind.Incremental,
60
+ completionProvider: {
61
+ resolveProvider: false,
62
+ triggerCharacters: ['"', ":"],
63
+ },
64
+ hoverProvider: true,
65
+ },
66
+ };
67
+
68
+ if (hasWorkspaceFolderCapability) {
69
+ result.capabilities.workspace = {
70
+ workspaceFolders: {
71
+ supported: true,
72
+ },
73
+ };
74
+ }
75
+
76
+ return result;
77
+ });
78
+
79
+ connection.onInitialized(() => {
80
+ if (hasConfigurationCapability) {
81
+ connection.client.register(DidChangeConfigurationNotification.type, undefined);
82
+ }
83
+ });
84
+
85
+ // Configuration handling
86
+ connection.onDidChangeConfiguration((change) => {
87
+ if (hasConfigurationCapability) {
88
+ documentSettings.clear();
89
+ } else {
90
+ globalSettings = change.settings?.blok || defaultSettings;
91
+ }
92
+
93
+ // Revalidate all open documents
94
+ for (const doc of documents.all()) {
95
+ validateDocument(doc);
96
+ }
97
+ });
98
+
99
+ function getDocumentSettings(resource: string): BlokLspSettings {
100
+ if (!hasConfigurationCapability) {
101
+ return globalSettings;
102
+ }
103
+ let result = documentSettings.get(resource);
104
+ if (!result) {
105
+ result = globalSettings;
106
+ documentSettings.set(resource, result);
107
+ }
108
+ return result;
109
+ }
110
+
111
+ // Document validation
112
+ function isWorkflowFile(uri: string): boolean {
113
+ // Match workflow files by path pattern
114
+ return /workflows?[/\\].*\.json$/i.test(uri) || /\.workflow\.json$/i.test(uri) || /blok\.json$/i.test(uri);
115
+ }
116
+
117
+ function validateDocument(document: TextDocument): void {
118
+ if (!isWorkflowFile(document.uri)) return;
119
+
120
+ const text = document.getText();
121
+ const diagnostics = validateWorkflow(text);
122
+ const settings = getDocumentSettings(document.uri);
123
+
124
+ // Limit diagnostics if configured
125
+ const limited = diagnostics.slice(0, settings.maxDiagnostics);
126
+ connection.sendDiagnostics({ uri: document.uri, diagnostics: limited });
127
+ }
128
+
129
+ // Validate on open and change
130
+ documents.onDidChangeContent((change) => {
131
+ validateDocument(change.document);
132
+ });
133
+
134
+ documents.onDidClose((event) => {
135
+ documentSettings.delete(event.document.uri);
136
+ connection.sendDiagnostics({ uri: event.document.uri, diagnostics: [] });
137
+ });
138
+
139
+ // Completion
140
+ connection.onCompletion((params: CompletionParams) => {
141
+ const document = documents.get(params.textDocument.uri);
142
+ if (!document) return [];
143
+ if (!isWorkflowFile(document.uri)) return [];
144
+
145
+ const text = document.getText();
146
+ const offset = document.offsetAt(params.position);
147
+ return getCompletions(text, offset);
148
+ });
149
+
150
+ // Hover
151
+ connection.onHover((params: HoverParams) => {
152
+ const document = documents.get(params.textDocument.uri);
153
+ if (!document) return null;
154
+ if (!isWorkflowFile(document.uri)) return null;
155
+
156
+ const text = document.getText();
157
+ return getHover(text, params.position.line, params.position.character);
158
+ });
159
+
160
+ // Start listening
161
+ documents.listen(connection);
162
+ connection.listen();
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "commonjs",
4
+ "target": "ES2022",
5
+ "outDir": "dist",
6
+ "rootDir": "src",
7
+ "lib": ["ES2022"],
8
+ "sourceMap": true,
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "moduleResolution": "node"
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
18
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: false,
6
+ environment: "node",
7
+ include: ["src/__tests__/**/*.test.ts"],
8
+ },
9
+ });