@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/CHANGELOG.md +15 -0
- package/dist/completion.d.ts +11 -0
- package/dist/completion.js +269 -0
- package/dist/completion.js.map +1 -0
- package/dist/constants.d.ts +37 -0
- package/dist/constants.js +161 -0
- package/dist/constants.js.map +1 -0
- package/dist/diagnostics.d.ts +17 -0
- package/dist/diagnostics.js +466 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/hover.d.ts +12 -0
- package/dist/hover.js +118 -0
- package/dist/hover.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +130 -0
- package/dist/server.js.map +1 -0
- package/editors/emacs-lsp.el +21 -0
- package/editors/helix-languages.toml +12 -0
- package/editors/neovim.lua +59 -0
- package/editors/sublime-lsp.json +16 -0
- package/package.json +40 -0
- package/src/__tests__/completion.test.ts +184 -0
- package/src/__tests__/constants.test.ts +142 -0
- package/src/__tests__/diagnostics.test.ts +513 -0
- package/src/__tests__/hover.test.ts +227 -0
- package/src/completion.ts +308 -0
- package/src/constants.ts +194 -0
- package/src/diagnostics.ts +493 -0
- package/src/hover.ts +135 -0
- package/src/server.ts +162 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +9 -0
package/src/constants.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants for Blok workflow validation, completion, and documentation.
|
|
3
|
+
* These are IDE-agnostic and used by both the LSP server and VS Code extension.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const VALID_TRIGGERS = [
|
|
7
|
+
"http",
|
|
8
|
+
"grpc",
|
|
9
|
+
"manual",
|
|
10
|
+
"cron",
|
|
11
|
+
"queue",
|
|
12
|
+
"pubsub",
|
|
13
|
+
"worker",
|
|
14
|
+
"webhook",
|
|
15
|
+
"websocket",
|
|
16
|
+
"sse",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export const VALID_HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "ANY"] as const;
|
|
20
|
+
|
|
21
|
+
export const VALID_STEP_TYPES = [
|
|
22
|
+
"local",
|
|
23
|
+
"module",
|
|
24
|
+
"runtime.nodejs",
|
|
25
|
+
"runtime.python3",
|
|
26
|
+
"runtime.go",
|
|
27
|
+
"runtime.java",
|
|
28
|
+
"runtime.rust",
|
|
29
|
+
"runtime.php",
|
|
30
|
+
"runtime.csharp",
|
|
31
|
+
"runtime.ruby",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export const VALID_RUNTIMES = [
|
|
35
|
+
"nodejs",
|
|
36
|
+
"bun",
|
|
37
|
+
"python3",
|
|
38
|
+
"go",
|
|
39
|
+
"java",
|
|
40
|
+
"rust",
|
|
41
|
+
"php",
|
|
42
|
+
"csharp",
|
|
43
|
+
"ruby",
|
|
44
|
+
"docker",
|
|
45
|
+
"wasm",
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
export const QUEUE_PROVIDERS = ["kafka", "rabbitmq", "sqs", "redis", "beanstalk"] as const;
|
|
49
|
+
|
|
50
|
+
export const PUBSUB_PROVIDERS = ["gcp", "aws", "azure", "redis", "nats"] as const;
|
|
51
|
+
|
|
52
|
+
export const WEBHOOK_SOURCES = ["github", "stripe", "shopify", "custom"] as const;
|
|
53
|
+
|
|
54
|
+
export const NODE_PACKAGES = [
|
|
55
|
+
{ name: "@blokjs/api-call", description: "HTTP API call node - makes requests to external services" },
|
|
56
|
+
{ name: "@blokjs/if-else", description: "Conditional branching node - evaluates conditions for routing" },
|
|
57
|
+
{ name: "@blokjs/react", description: "React SSR node - server-side rendering" },
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
export interface WorkflowJson {
|
|
61
|
+
name?: unknown;
|
|
62
|
+
version?: unknown;
|
|
63
|
+
description?: unknown;
|
|
64
|
+
trigger?: Record<string, unknown>;
|
|
65
|
+
steps?: unknown[];
|
|
66
|
+
nodes?: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface HoverDoc {
|
|
70
|
+
title: string;
|
|
71
|
+
description: string;
|
|
72
|
+
example?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const TRIGGER_DOCS: Record<string, HoverDoc> = {
|
|
76
|
+
http: {
|
|
77
|
+
title: "HTTP Trigger",
|
|
78
|
+
description: "Triggers workflow on HTTP requests. Supports GET, POST, PUT, DELETE, PATCH, and ANY methods.",
|
|
79
|
+
example: `"http": {\n "method": "POST",\n "path": "/api/users",\n "accept": "application/json"\n}`,
|
|
80
|
+
},
|
|
81
|
+
grpc: {
|
|
82
|
+
title: "gRPC Trigger",
|
|
83
|
+
description: "Triggers workflow on gRPC method calls using Connect RPC protocol.",
|
|
84
|
+
example: `"grpc": {\n "service": "UserService",\n "method": "GetUser"\n}`,
|
|
85
|
+
},
|
|
86
|
+
manual: {
|
|
87
|
+
title: "Manual Trigger",
|
|
88
|
+
description: "No automatic triggering. Workflow must be invoked programmatically.",
|
|
89
|
+
example: `"manual": {}`,
|
|
90
|
+
},
|
|
91
|
+
cron: {
|
|
92
|
+
title: "Cron Trigger",
|
|
93
|
+
description: "Triggers workflow on a schedule using cron expressions. Supports timezone and overlap control.",
|
|
94
|
+
example: `"cron": {\n "schedule": "*/5 * * * *",\n "timezone": "America/New_York",\n "overlap": false\n}`,
|
|
95
|
+
},
|
|
96
|
+
queue: {
|
|
97
|
+
title: "Queue Trigger",
|
|
98
|
+
description:
|
|
99
|
+
"Triggers workflow when messages arrive on a queue. Supports Kafka, RabbitMQ, SQS, Redis, and Beanstalk.",
|
|
100
|
+
example: `"queue": {\n "provider": "kafka",\n "topic": "user-events",\n "consumerGroup": "blok-workers"\n}`,
|
|
101
|
+
},
|
|
102
|
+
pubsub: {
|
|
103
|
+
title: "Pub/Sub Trigger",
|
|
104
|
+
description:
|
|
105
|
+
"Triggers workflow on pub/sub messages. Supports GCP Pub/Sub, AWS SNS, Azure Service Bus, Redis, and NATS.",
|
|
106
|
+
example: `"pubsub": {\n "provider": "gcp",\n "topic": "notifications",\n "subscription": "blok-sub"\n}`,
|
|
107
|
+
},
|
|
108
|
+
worker: {
|
|
109
|
+
title: "Worker Trigger",
|
|
110
|
+
description: "Background job processing with configurable concurrency, timeouts, and retries.",
|
|
111
|
+
example: `"worker": {\n "queue": "email-jobs",\n "concurrency": 5,\n "timeout": 30000,\n "retries": 3\n}`,
|
|
112
|
+
},
|
|
113
|
+
webhook: {
|
|
114
|
+
title: "Webhook Trigger",
|
|
115
|
+
description: "Triggers workflow when external services send webhook events. Supports HMAC signature verification.",
|
|
116
|
+
example: `"webhook": {\n "source": "github",\n "events": ["push", "pull_request"],\n "secret": "WEBHOOK_SECRET"\n}`,
|
|
117
|
+
},
|
|
118
|
+
websocket: {
|
|
119
|
+
title: "WebSocket Trigger",
|
|
120
|
+
description: "Real-time bidirectional communication. Supports rooms, authentication, and message rate limiting.",
|
|
121
|
+
example: `"websocket": {\n "path": "/ws",\n "events": ["message", "join", "leave"],\n "maxConnections": 1000\n}`,
|
|
122
|
+
},
|
|
123
|
+
sse: {
|
|
124
|
+
title: "SSE Trigger",
|
|
125
|
+
description: "Server-Sent Events for real-time unidirectional streaming. Supports channels and replay.",
|
|
126
|
+
example: `"sse": {\n "path": "/events",\n "channels": ["updates", "alerts"],\n "retryInterval": 3000\n}`,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const FIELD_DOCS: Record<string, HoverDoc> = {
|
|
131
|
+
name: {
|
|
132
|
+
title: "Workflow Name",
|
|
133
|
+
description: "Human-readable name identifying this workflow. Used for logging, monitoring, and display purposes.",
|
|
134
|
+
},
|
|
135
|
+
version: {
|
|
136
|
+
title: "Workflow Version",
|
|
137
|
+
description: "Semantic version of this workflow (e.g., 1.0.0). Follows SemVer format: MAJOR.MINOR.PATCH.",
|
|
138
|
+
},
|
|
139
|
+
description: {
|
|
140
|
+
title: "Workflow Description",
|
|
141
|
+
description: "Detailed description of what this workflow does. Shown in documentation and monitoring dashboards.",
|
|
142
|
+
},
|
|
143
|
+
trigger: {
|
|
144
|
+
title: "Workflow Trigger",
|
|
145
|
+
description:
|
|
146
|
+
"Defines how this workflow is invoked. Each workflow has exactly one trigger type: http, grpc, manual, cron, queue, pubsub, worker, webhook, websocket, or sse.",
|
|
147
|
+
},
|
|
148
|
+
steps: {
|
|
149
|
+
title: "Workflow Steps",
|
|
150
|
+
description:
|
|
151
|
+
"Ordered array of steps to execute. Each step references a node and defines its type (local or module). Steps execute sequentially; use conditional nodes for branching.",
|
|
152
|
+
},
|
|
153
|
+
nodes: {
|
|
154
|
+
title: "Node Configurations",
|
|
155
|
+
description:
|
|
156
|
+
'Maps step names to their configurations. Each key matches a step\'s "name" field and contains inputs, conditions, or nested steps.',
|
|
157
|
+
},
|
|
158
|
+
inputs: {
|
|
159
|
+
title: "Node Inputs",
|
|
160
|
+
description:
|
|
161
|
+
"Input values for the node. Supports static values, template variables (${ctx.request.body.field}), JavaScript evaluation (js/ prefix), and context variable references.",
|
|
162
|
+
example: `"inputs": {\n "url": "https://api.example.com/users",\n "userId": "\${ctx.request.params.id}",\n "computed": "js/ctx.response.data.items.length"\n}`,
|
|
163
|
+
},
|
|
164
|
+
conditions: {
|
|
165
|
+
title: "Conditional Branches",
|
|
166
|
+
description:
|
|
167
|
+
"Array of if/else conditions for branching logic. Use with @blokjs/if-else node. Each condition has a JavaScript expression and nested steps.",
|
|
168
|
+
example: `"conditions": [\n {\n "type": "if",\n "condition": "ctx.request.query.type === 'admin'",\n "steps": [...]\n },\n {\n "type": "else",\n "steps": [...]\n }\n]`,
|
|
169
|
+
},
|
|
170
|
+
set_var: {
|
|
171
|
+
title: "Set Context Variable",
|
|
172
|
+
description:
|
|
173
|
+
"When true, stores the step's output in ctx.vars['step-name']. This makes the result accessible to downstream steps via ctx.vars.",
|
|
174
|
+
example: `"my-step": {\n "set_var": true,\n "inputs": { ... }\n}\n// Later: ctx.vars['my-step'] contains the result`,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const STEP_FIELD_DOCS: Record<string, HoverDoc> = {
|
|
179
|
+
node: {
|
|
180
|
+
title: "Step Node Reference",
|
|
181
|
+
description:
|
|
182
|
+
"The node package or local path to execute. For modules: @blokjs/api-call. For local nodes: ./nodes/my-node.",
|
|
183
|
+
},
|
|
184
|
+
type: {
|
|
185
|
+
title: "Step Type",
|
|
186
|
+
description:
|
|
187
|
+
"How the node should be resolved.\n- **local**: Node defined in the project\n- **module**: npm package node\n- **runtime.X**: Language-specific runtime (nodejs, python3, go, java, rust, php, csharp, ruby)",
|
|
188
|
+
},
|
|
189
|
+
runtime: {
|
|
190
|
+
title: "Step Runtime",
|
|
191
|
+
description:
|
|
192
|
+
"Override the default runtime for this step. Available: nodejs, bun, python3, go, java, rust, php, csharp, ruby, docker, wasm.",
|
|
193
|
+
},
|
|
194
|
+
};
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { type Diagnostic, DiagnosticSeverity, Position, Range } from "vscode-languageserver";
|
|
2
|
+
import { VALID_HTTP_METHODS, VALID_RUNTIMES, VALID_STEP_TYPES, VALID_TRIGGERS, type WorkflowJson } from "./constants";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Provides workflow validation diagnostics for the LSP server.
|
|
6
|
+
*
|
|
7
|
+
* Validates:
|
|
8
|
+
* - Required fields (name, version, trigger, steps, nodes)
|
|
9
|
+
* - Version format (semver)
|
|
10
|
+
* - Trigger configuration (type-specific validation)
|
|
11
|
+
* - Step structure (name, node, type)
|
|
12
|
+
* - Node configuration references
|
|
13
|
+
* - Unused nodes
|
|
14
|
+
* - Runtime field validation
|
|
15
|
+
*/
|
|
16
|
+
export function validateWorkflow(text: string): Diagnostic[] {
|
|
17
|
+
const diagnostics: Diagnostic[] = [];
|
|
18
|
+
|
|
19
|
+
let workflow: WorkflowJson;
|
|
20
|
+
try {
|
|
21
|
+
workflow = JSON.parse(text);
|
|
22
|
+
} catch {
|
|
23
|
+
diagnostics.push({
|
|
24
|
+
severity: DiagnosticSeverity.Error,
|
|
25
|
+
range: createRange(0, 0, 0, 1),
|
|
26
|
+
message: "Invalid JSON: failed to parse workflow file",
|
|
27
|
+
source: "blok",
|
|
28
|
+
});
|
|
29
|
+
return diagnostics;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof workflow !== "object" || workflow === null || Array.isArray(workflow)) {
|
|
33
|
+
diagnostics.push({
|
|
34
|
+
severity: DiagnosticSeverity.Error,
|
|
35
|
+
range: createRange(0, 0, 0, 1),
|
|
36
|
+
message: "Workflow must be a JSON object",
|
|
37
|
+
source: "blok",
|
|
38
|
+
});
|
|
39
|
+
return diagnostics;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
validateRequiredFields(text, workflow, diagnostics);
|
|
43
|
+
validateVersion(text, workflow, diagnostics);
|
|
44
|
+
validateTrigger(text, workflow, diagnostics);
|
|
45
|
+
validateSteps(text, workflow, diagnostics);
|
|
46
|
+
validateNodeReferences(text, workflow, diagnostics);
|
|
47
|
+
|
|
48
|
+
return diagnostics;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateRequiredFields(text: string, workflow: WorkflowJson, diagnostics: Diagnostic[]): void {
|
|
52
|
+
const required: Array<{ key: keyof WorkflowJson; label: string }> = [
|
|
53
|
+
{ key: "name", label: "name" },
|
|
54
|
+
{ key: "version", label: "version" },
|
|
55
|
+
{ key: "trigger", label: "trigger" },
|
|
56
|
+
{ key: "steps", label: "steps" },
|
|
57
|
+
{ key: "nodes", label: "nodes" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
for (const { key, label } of required) {
|
|
61
|
+
if (workflow[key] === undefined) {
|
|
62
|
+
diagnostics.push({
|
|
63
|
+
severity: DiagnosticSeverity.Error,
|
|
64
|
+
range: createRange(0, 0, 0, 1),
|
|
65
|
+
message: `Missing required field: "${label}"`,
|
|
66
|
+
source: "blok",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof workflow.name === "string" && workflow.name.length === 0) {
|
|
72
|
+
const range = findKeyRange(text, "name");
|
|
73
|
+
diagnostics.push({
|
|
74
|
+
severity: DiagnosticSeverity.Error,
|
|
75
|
+
range,
|
|
76
|
+
message: "Workflow name cannot be empty",
|
|
77
|
+
source: "blok",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateVersion(text: string, workflow: WorkflowJson, diagnostics: Diagnostic[]): void {
|
|
83
|
+
if (typeof workflow.version !== "string") return;
|
|
84
|
+
|
|
85
|
+
const semverRegex = /^\d+\.\d+\.\d+$/;
|
|
86
|
+
if (!semverRegex.test(workflow.version)) {
|
|
87
|
+
const range = findKeyRange(text, "version");
|
|
88
|
+
diagnostics.push({
|
|
89
|
+
severity: DiagnosticSeverity.Warning,
|
|
90
|
+
range,
|
|
91
|
+
message: `Invalid version format "${workflow.version}". Expected semver (e.g., 1.0.0)`,
|
|
92
|
+
source: "blok",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validateTrigger(text: string, workflow: WorkflowJson, diagnostics: Diagnostic[]): void {
|
|
98
|
+
if (!workflow.trigger || typeof workflow.trigger !== "object") return;
|
|
99
|
+
|
|
100
|
+
const triggerKeys = Object.keys(workflow.trigger);
|
|
101
|
+
|
|
102
|
+
if (triggerKeys.length === 0) {
|
|
103
|
+
const range = findKeyRange(text, "trigger");
|
|
104
|
+
diagnostics.push({
|
|
105
|
+
severity: DiagnosticSeverity.Error,
|
|
106
|
+
range,
|
|
107
|
+
message: "Trigger must have at least one type defined",
|
|
108
|
+
source: "blok",
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (triggerKeys.length > 1) {
|
|
114
|
+
const range = findKeyRange(text, "trigger");
|
|
115
|
+
diagnostics.push({
|
|
116
|
+
severity: DiagnosticSeverity.Error,
|
|
117
|
+
range,
|
|
118
|
+
message: `Only one trigger type allowed per workflow. Found: ${triggerKeys.join(", ")}`,
|
|
119
|
+
source: "blok",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const triggerType = triggerKeys[0];
|
|
124
|
+
if (!VALID_TRIGGERS.includes(triggerType as (typeof VALID_TRIGGERS)[number])) {
|
|
125
|
+
const range = findKeyRange(text, triggerType);
|
|
126
|
+
diagnostics.push({
|
|
127
|
+
severity: DiagnosticSeverity.Error,
|
|
128
|
+
range,
|
|
129
|
+
message: `Unknown trigger type "${triggerType}". Valid types: ${VALID_TRIGGERS.join(", ")}`,
|
|
130
|
+
source: "blok",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// HTTP trigger validation
|
|
135
|
+
if (triggerType === "http") {
|
|
136
|
+
const httpConfig = workflow.trigger.http as Record<string, unknown> | undefined;
|
|
137
|
+
if (httpConfig && typeof httpConfig === "object") {
|
|
138
|
+
if (!httpConfig.method) {
|
|
139
|
+
const range = findKeyRange(text, "http");
|
|
140
|
+
diagnostics.push({
|
|
141
|
+
severity: DiagnosticSeverity.Error,
|
|
142
|
+
range,
|
|
143
|
+
message: 'HTTP trigger requires "method" field',
|
|
144
|
+
source: "blok",
|
|
145
|
+
});
|
|
146
|
+
} else if (
|
|
147
|
+
typeof httpConfig.method === "string" &&
|
|
148
|
+
!VALID_HTTP_METHODS.includes(httpConfig.method as (typeof VALID_HTTP_METHODS)[number])
|
|
149
|
+
) {
|
|
150
|
+
const range = findValueRange(text, "method", httpConfig.method as string);
|
|
151
|
+
diagnostics.push({
|
|
152
|
+
severity: DiagnosticSeverity.Error,
|
|
153
|
+
range,
|
|
154
|
+
message: `Invalid HTTP method "${httpConfig.method}". Valid: ${VALID_HTTP_METHODS.join(", ")}`,
|
|
155
|
+
source: "blok",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (!httpConfig.path) {
|
|
159
|
+
const range = findKeyRange(text, "http");
|
|
160
|
+
diagnostics.push({
|
|
161
|
+
severity: DiagnosticSeverity.Error,
|
|
162
|
+
range,
|
|
163
|
+
message: 'HTTP trigger requires "path" field',
|
|
164
|
+
source: "blok",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Cron trigger validation
|
|
171
|
+
if (triggerType === "cron") {
|
|
172
|
+
const cronConfig = workflow.trigger.cron as Record<string, unknown> | undefined;
|
|
173
|
+
if (cronConfig && typeof cronConfig === "object") {
|
|
174
|
+
if (!cronConfig.schedule) {
|
|
175
|
+
const range = findKeyRange(text, "cron");
|
|
176
|
+
diagnostics.push({
|
|
177
|
+
severity: DiagnosticSeverity.Error,
|
|
178
|
+
range,
|
|
179
|
+
message: 'Cron trigger requires "schedule" field',
|
|
180
|
+
source: "blok",
|
|
181
|
+
});
|
|
182
|
+
} else if (typeof cronConfig.schedule === "string") {
|
|
183
|
+
const parts = cronConfig.schedule.trim().split(/\s+/);
|
|
184
|
+
if (parts.length < 5 || parts.length > 6) {
|
|
185
|
+
const range = findValueRange(text, "schedule", cronConfig.schedule);
|
|
186
|
+
diagnostics.push({
|
|
187
|
+
severity: DiagnosticSeverity.Warning,
|
|
188
|
+
range,
|
|
189
|
+
message: "Invalid cron expression. Expected 5-6 fields (minute hour day month weekday [second])",
|
|
190
|
+
source: "blok",
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Queue trigger validation
|
|
198
|
+
if (triggerType === "queue") {
|
|
199
|
+
const queueConfig = workflow.trigger.queue as Record<string, unknown> | undefined;
|
|
200
|
+
if (queueConfig && typeof queueConfig === "object") {
|
|
201
|
+
if (!queueConfig.provider) {
|
|
202
|
+
const range = findKeyRange(text, "queue");
|
|
203
|
+
diagnostics.push({
|
|
204
|
+
severity: DiagnosticSeverity.Error,
|
|
205
|
+
range,
|
|
206
|
+
message: 'Queue trigger requires "provider" field',
|
|
207
|
+
source: "blok",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
if (!queueConfig.topic) {
|
|
211
|
+
const range = findKeyRange(text, "queue");
|
|
212
|
+
diagnostics.push({
|
|
213
|
+
severity: DiagnosticSeverity.Error,
|
|
214
|
+
range,
|
|
215
|
+
message: 'Queue trigger requires "topic" field',
|
|
216
|
+
source: "blok",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Webhook trigger validation
|
|
223
|
+
if (triggerType === "webhook") {
|
|
224
|
+
const webhookConfig = workflow.trigger.webhook as Record<string, unknown> | undefined;
|
|
225
|
+
if (webhookConfig && typeof webhookConfig === "object") {
|
|
226
|
+
if (!webhookConfig.source) {
|
|
227
|
+
const range = findKeyRange(text, "webhook");
|
|
228
|
+
diagnostics.push({
|
|
229
|
+
severity: DiagnosticSeverity.Error,
|
|
230
|
+
range,
|
|
231
|
+
message: 'Webhook trigger requires "source" field',
|
|
232
|
+
source: "blok",
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (!webhookConfig.events || !Array.isArray(webhookConfig.events)) {
|
|
236
|
+
const range = findKeyRange(text, "webhook");
|
|
237
|
+
diagnostics.push({
|
|
238
|
+
severity: DiagnosticSeverity.Error,
|
|
239
|
+
range,
|
|
240
|
+
message: 'Webhook trigger requires "events" array',
|
|
241
|
+
source: "blok",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Pubsub trigger validation
|
|
248
|
+
if (triggerType === "pubsub") {
|
|
249
|
+
const pubsubConfig = workflow.trigger.pubsub as Record<string, unknown> | undefined;
|
|
250
|
+
if (pubsubConfig && typeof pubsubConfig === "object") {
|
|
251
|
+
if (!pubsubConfig.provider) {
|
|
252
|
+
const range = findKeyRange(text, "pubsub");
|
|
253
|
+
diagnostics.push({
|
|
254
|
+
severity: DiagnosticSeverity.Error,
|
|
255
|
+
range,
|
|
256
|
+
message: 'Pub/Sub trigger requires "provider" field',
|
|
257
|
+
source: "blok",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (!pubsubConfig.topic && !pubsubConfig.channel) {
|
|
261
|
+
const range = findKeyRange(text, "pubsub");
|
|
262
|
+
diagnostics.push({
|
|
263
|
+
severity: DiagnosticSeverity.Error,
|
|
264
|
+
range,
|
|
265
|
+
message: 'Pub/Sub trigger requires "topic" or "channel" field',
|
|
266
|
+
source: "blok",
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Worker trigger validation
|
|
273
|
+
if (triggerType === "worker") {
|
|
274
|
+
const workerConfig = workflow.trigger.worker as Record<string, unknown> | undefined;
|
|
275
|
+
if (workerConfig && typeof workerConfig === "object") {
|
|
276
|
+
if (!workerConfig.queue) {
|
|
277
|
+
const range = findKeyRange(text, "worker");
|
|
278
|
+
diagnostics.push({
|
|
279
|
+
severity: DiagnosticSeverity.Error,
|
|
280
|
+
range,
|
|
281
|
+
message: 'Worker trigger requires "queue" field',
|
|
282
|
+
source: "blok",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function validateSteps(text: string, workflow: WorkflowJson, diagnostics: Diagnostic[]): void {
|
|
290
|
+
if (!Array.isArray(workflow.steps)) return;
|
|
291
|
+
|
|
292
|
+
const stepNames = new Set<string>();
|
|
293
|
+
|
|
294
|
+
for (let i = 0; i < workflow.steps.length; i++) {
|
|
295
|
+
const step = workflow.steps[i] as Record<string, unknown>;
|
|
296
|
+
if (!step || typeof step !== "object") {
|
|
297
|
+
diagnostics.push({
|
|
298
|
+
severity: DiagnosticSeverity.Error,
|
|
299
|
+
range: findArrayItemRange(text, "steps", i),
|
|
300
|
+
message: `Step ${i} must be an object`,
|
|
301
|
+
source: "blok",
|
|
302
|
+
});
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!step.name || typeof step.name !== "string") {
|
|
307
|
+
diagnostics.push({
|
|
308
|
+
severity: DiagnosticSeverity.Error,
|
|
309
|
+
range: findArrayItemRange(text, "steps", i),
|
|
310
|
+
message: `Step ${i} is missing required "name" field`,
|
|
311
|
+
source: "blok",
|
|
312
|
+
});
|
|
313
|
+
} else {
|
|
314
|
+
if (stepNames.has(step.name)) {
|
|
315
|
+
diagnostics.push({
|
|
316
|
+
severity: DiagnosticSeverity.Warning,
|
|
317
|
+
range: findValueRange(text, "name", step.name),
|
|
318
|
+
message: `Duplicate step name "${step.name}"`,
|
|
319
|
+
source: "blok",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
stepNames.add(step.name);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!step.node || typeof step.node !== "string") {
|
|
326
|
+
diagnostics.push({
|
|
327
|
+
severity: DiagnosticSeverity.Error,
|
|
328
|
+
range: findArrayItemRange(text, "steps", i),
|
|
329
|
+
message: `Step "${step.name || i}" is missing required "node" field`,
|
|
330
|
+
source: "blok",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!step.type || typeof step.type !== "string") {
|
|
335
|
+
diagnostics.push({
|
|
336
|
+
severity: DiagnosticSeverity.Error,
|
|
337
|
+
range: findArrayItemRange(text, "steps", i),
|
|
338
|
+
message: `Step "${step.name || i}" is missing required "type" field`,
|
|
339
|
+
source: "blok",
|
|
340
|
+
});
|
|
341
|
+
} else if (!VALID_STEP_TYPES.includes(step.type as (typeof VALID_STEP_TYPES)[number])) {
|
|
342
|
+
diagnostics.push({
|
|
343
|
+
severity: DiagnosticSeverity.Error,
|
|
344
|
+
range: findValueRange(text, "type", step.type),
|
|
345
|
+
message: `Invalid step type "${step.type}". Valid: ${VALID_STEP_TYPES.join(", ")}`,
|
|
346
|
+
source: "blok",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (
|
|
351
|
+
step.runtime &&
|
|
352
|
+
typeof step.runtime === "string" &&
|
|
353
|
+
!VALID_RUNTIMES.includes(step.runtime as (typeof VALID_RUNTIMES)[number])
|
|
354
|
+
) {
|
|
355
|
+
diagnostics.push({
|
|
356
|
+
severity: DiagnosticSeverity.Error,
|
|
357
|
+
range: findValueRange(text, "runtime", step.runtime),
|
|
358
|
+
message: `Invalid runtime "${step.runtime}". Valid: ${VALID_RUNTIMES.join(", ")}`,
|
|
359
|
+
source: "blok",
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function validateNodeReferences(text: string, workflow: WorkflowJson, diagnostics: Diagnostic[]): void {
|
|
366
|
+
if (!Array.isArray(workflow.steps) || typeof workflow.nodes !== "object" || !workflow.nodes) return;
|
|
367
|
+
|
|
368
|
+
const referencedNodes = collectStepNames(workflow.steps);
|
|
369
|
+
const definedNodes = new Set(Object.keys(workflow.nodes));
|
|
370
|
+
|
|
371
|
+
// Check for steps referencing undefined nodes
|
|
372
|
+
for (const stepName of referencedNodes) {
|
|
373
|
+
if (!definedNodes.has(stepName)) {
|
|
374
|
+
const range = findValueRange(text, "name", stepName);
|
|
375
|
+
diagnostics.push({
|
|
376
|
+
severity: DiagnosticSeverity.Warning,
|
|
377
|
+
range,
|
|
378
|
+
message: `Step "${stepName}" references a node that is not defined in "nodes"`,
|
|
379
|
+
source: "blok",
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Check for nodes with conditions and collect nested step names
|
|
385
|
+
const allReferenced = new Set(referencedNodes);
|
|
386
|
+
for (const [, nodeConfig] of Object.entries(workflow.nodes)) {
|
|
387
|
+
if (nodeConfig && typeof nodeConfig === "object") {
|
|
388
|
+
const cfg = nodeConfig as Record<string, unknown>;
|
|
389
|
+
if (Array.isArray(cfg.conditions)) {
|
|
390
|
+
for (const cond of cfg.conditions) {
|
|
391
|
+
if (cond && typeof cond === "object" && Array.isArray((cond as Record<string, unknown>).steps)) {
|
|
392
|
+
const nestedNames = collectStepNames((cond as Record<string, unknown>).steps as unknown[]);
|
|
393
|
+
for (const n of nestedNames) allReferenced.add(n);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Check for unused nodes
|
|
401
|
+
for (const nodeName of definedNodes) {
|
|
402
|
+
if (!allReferenced.has(nodeName)) {
|
|
403
|
+
const range = findKeyRange(text, nodeName);
|
|
404
|
+
diagnostics.push({
|
|
405
|
+
severity: DiagnosticSeverity.Information,
|
|
406
|
+
range,
|
|
407
|
+
message: `Node "${nodeName}" is defined but not referenced by any step`,
|
|
408
|
+
source: "blok",
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function collectStepNames(steps: unknown[]): Set<string> {
|
|
415
|
+
const names = new Set<string>();
|
|
416
|
+
for (const step of steps) {
|
|
417
|
+
if (step && typeof step === "object") {
|
|
418
|
+
const s = step as Record<string, unknown>;
|
|
419
|
+
if (typeof s.name === "string") {
|
|
420
|
+
names.add(s.name);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return names;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- Range helpers ---
|
|
428
|
+
|
|
429
|
+
function createRange(startLine: number, startChar: number, endLine: number, endChar: number): Range {
|
|
430
|
+
return Range.create(Position.create(startLine, startChar), Position.create(endLine, endChar));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function findKeyRange(text: string, key: string): Range {
|
|
434
|
+
const pattern = new RegExp(`"${escapeRegex(key)}"\\s*:`);
|
|
435
|
+
const match = pattern.exec(text);
|
|
436
|
+
if (match) {
|
|
437
|
+
const pos = offsetToPosition(text, match.index);
|
|
438
|
+
return createRange(pos.line, pos.character, pos.line, pos.character + match[0].length);
|
|
439
|
+
}
|
|
440
|
+
return createRange(0, 0, 0, 1);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function findValueRange(text: string, key: string, value: string): Range {
|
|
444
|
+
const pattern = new RegExp(`"${escapeRegex(key)}"\\s*:\\s*"${escapeRegex(value)}"`);
|
|
445
|
+
const match = pattern.exec(text);
|
|
446
|
+
if (match) {
|
|
447
|
+
const pos = offsetToPosition(text, match.index);
|
|
448
|
+
return createRange(pos.line, pos.character, pos.line, pos.character + match[0].length);
|
|
449
|
+
}
|
|
450
|
+
return findKeyRange(text, key);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function findArrayItemRange(text: string, arrayKey: string, index: number): Range {
|
|
454
|
+
const keyMatch = new RegExp(`"${escapeRegex(arrayKey)}"\\s*:\\s*\\[`).exec(text);
|
|
455
|
+
if (!keyMatch) return createRange(0, 0, 0, 1);
|
|
456
|
+
|
|
457
|
+
let depth = 0;
|
|
458
|
+
let itemCount = 0;
|
|
459
|
+
const start = keyMatch.index + keyMatch[0].length;
|
|
460
|
+
|
|
461
|
+
for (let i = start; i < text.length; i++) {
|
|
462
|
+
if (text[i] === "{" || text[i] === "[") {
|
|
463
|
+
if (depth === 0 && itemCount === index) {
|
|
464
|
+
const pos = offsetToPosition(text, i);
|
|
465
|
+
return createRange(pos.line, pos.character, pos.line, pos.character + 1);
|
|
466
|
+
}
|
|
467
|
+
depth++;
|
|
468
|
+
} else if (text[i] === "}" || text[i] === "]") {
|
|
469
|
+
depth--;
|
|
470
|
+
if (depth === 0) itemCount++;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return createRange(0, 0, 0, 1);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function offsetToPosition(text: string, offset: number): Position {
|
|
478
|
+
let line = 0;
|
|
479
|
+
let col = 0;
|
|
480
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
481
|
+
if (text[i] === "\n") {
|
|
482
|
+
line++;
|
|
483
|
+
col = 0;
|
|
484
|
+
} else {
|
|
485
|
+
col++;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return Position.create(line, col);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function escapeRegex(str: string): string {
|
|
492
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
493
|
+
}
|