cluaupp 1.4.1 → 1.5.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 +8 -0
- package/README.md +1 -1
- package/editors/vscode/README.md +17 -0
- package/editors/vscode/extension.js +140 -21
- package/editors/vscode/icons/flare.svg +5 -0
- package/editors/vscode/language-configuration.json +21 -0
- package/editors/vscode/package.json +61 -7
- package/editors/vscode/snippets/flare.json +27 -0
- package/editors/vscode/syntaxes/flare.tmLanguage.json +75 -0
- package/examples/game/.vscode/extensions.json +3 -1
- package/examples/game/.vscode/settings.json +2 -1
- package/examples/game/include/clpp/libs/roster.clh +6 -0
- package/examples/game/src/ReplicatedStorage/Shared/Net/Net.flare +5 -0
- package/examples/game/src/ServerScriptService/Boot/DataBoot.server.clpp +1 -1
- package/generated/cli.js +2 -2
- package/generated/flare/index.js +15 -3
- package/generated/flare/language.js +561 -0
- package/generated/flare/parse.js +7 -6
- package/generated/intellisense.js +20 -2
- package/generated/lsp.js +118 -7
- package/include/clpp/libs/roster.clh +6 -0
- package/package.json +1 -1
- package/runtime/Roster/init.luau +6 -0
- package/src/cli.ts +2 -2
- package/src/flare/index.ts +20 -1
- package/src/flare/language.ts +708 -0
- package/src/flare/parse.ts +6 -6
- package/src/intellisense.ts +19 -2
- package/src/lsp.ts +151 -8
- package/templates/game/.vscode/extensions.json +3 -1
- package/templates/game/.vscode/settings.json +2 -1
- package/templates/game/include/clpp/libs/roster.clh +6 -0
- package/templates/game/src/ReplicatedStorage/Shared/Net/Net.flare +5 -0
- package/templates/game/src/ServerScriptService/Boot/DataBoot.server.clpp +1 -1
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { FLARE_IDENT, FLARE_TYPE_ALIASES } from "./parse.js";
|
|
2
|
+
import type { FlarePacket, FlareQuery, FlareSide, FlareType } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export type FlareSeverity = "error" | "warning" | "hint";
|
|
5
|
+
|
|
6
|
+
export type FlareDiagnostic = {
|
|
7
|
+
line: number;
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
message: string;
|
|
11
|
+
severity: FlareSeverity;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type FlareCompletionKind = "keyword" | "type" | "snippet" | "value" | "field";
|
|
15
|
+
|
|
16
|
+
export type FlareCompletion = {
|
|
17
|
+
label: string;
|
|
18
|
+
kind: FlareCompletionKind;
|
|
19
|
+
detail: string;
|
|
20
|
+
documentation: string;
|
|
21
|
+
insertText: string;
|
|
22
|
+
snippet: boolean;
|
|
23
|
+
sortText: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type FlareHover = {
|
|
27
|
+
contents: string;
|
|
28
|
+
start: number;
|
|
29
|
+
end: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type FlareSymbol = {
|
|
33
|
+
name: string;
|
|
34
|
+
detail: string;
|
|
35
|
+
kind: "packet" | "query" | "option";
|
|
36
|
+
line: number;
|
|
37
|
+
start: number;
|
|
38
|
+
end: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type FlareSignature = {
|
|
42
|
+
label: string;
|
|
43
|
+
documentation: string;
|
|
44
|
+
parameters: { label: string; documentation: string }[];
|
|
45
|
+
activeParameter: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type FlareAnalysis = {
|
|
49
|
+
name: string;
|
|
50
|
+
packets: FlarePacket[];
|
|
51
|
+
queries: FlareQuery[];
|
|
52
|
+
diagnostics: FlareDiagnostic[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const IDENT_RE = new RegExp(`^${FLARE_IDENT}$`);
|
|
56
|
+
const WORD_RE = /[A-Za-z_][A-Za-z0-9_]*/g;
|
|
57
|
+
|
|
58
|
+
export const FLARE_CANONICAL_TYPES: FlareType[] = [
|
|
59
|
+
"u8",
|
|
60
|
+
"u16",
|
|
61
|
+
"u32",
|
|
62
|
+
"i8",
|
|
63
|
+
"i16",
|
|
64
|
+
"i32",
|
|
65
|
+
"f32",
|
|
66
|
+
"f64",
|
|
67
|
+
"bool",
|
|
68
|
+
"string",
|
|
69
|
+
"Vector3",
|
|
70
|
+
"Vector2",
|
|
71
|
+
"CFrame",
|
|
72
|
+
"Color3",
|
|
73
|
+
"UDim",
|
|
74
|
+
"UDim2",
|
|
75
|
+
"BrickColor",
|
|
76
|
+
"buffer",
|
|
77
|
+
"Instance",
|
|
78
|
+
"Player",
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
export const FLARE_TYPE_DOCS: Record<string, string> = {
|
|
82
|
+
u8: "Unsigned 8-bit integer (0–255). Packet/query ids use this.",
|
|
83
|
+
u16: "Unsigned 16-bit integer.",
|
|
84
|
+
u32: "Unsigned 32-bit integer.",
|
|
85
|
+
i8: "Signed 8-bit integer.",
|
|
86
|
+
i16: "Signed 16-bit integer.",
|
|
87
|
+
i32: "Signed 32-bit integer. Alias: `int`.",
|
|
88
|
+
int: "Alias for `i32`.",
|
|
89
|
+
f32: "32-bit float. Alias: `float`.",
|
|
90
|
+
float: "Alias for `f32`.",
|
|
91
|
+
f64: "64-bit float. Aliases: `double`, `number`. Use for `tick()`.",
|
|
92
|
+
double: "Alias for `f64`.",
|
|
93
|
+
number: "Alias for `f64`.",
|
|
94
|
+
bool: "Boolean. Alias: `boolean`.",
|
|
95
|
+
boolean: "Alias for `bool`.",
|
|
96
|
+
string: "UTF-8 string (length-prefixed on the wire).",
|
|
97
|
+
Vector3: "Roblox Vector3 (three f32s).",
|
|
98
|
+
Vector2: "Roblox Vector2 (two f32s).",
|
|
99
|
+
CFrame: "Roblox CFrame.",
|
|
100
|
+
Color3: "Roblox Color3.",
|
|
101
|
+
UDim: "Roblox UDim.",
|
|
102
|
+
UDim2: "Roblox UDim2.",
|
|
103
|
+
BrickColor: "Roblox BrickColor.",
|
|
104
|
+
buffer: "Luau buffer payload.",
|
|
105
|
+
Instance: "Roblox Instance (sidecar array, not packed in the buffer).",
|
|
106
|
+
Player: "Roblox Player instance (sidecar). On `from Server` `Fire`, the first `Player` is the **recipient**, not a schema field.",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const TYPE_LABELS = [...new Set([...FLARE_CANONICAL_TYPES, "int", "float", "double", "boolean"])];
|
|
110
|
+
|
|
111
|
+
function lineRange(text: string, fallback = 0): { start: number; end: number } {
|
|
112
|
+
const start = text.search(/\S/);
|
|
113
|
+
if (start < 0) {
|
|
114
|
+
return { start: fallback, end: Math.max(text.length, fallback + 1) };
|
|
115
|
+
}
|
|
116
|
+
return { start, end: text.length };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function tokenRange(text: string, token: string): { start: number; end: number } {
|
|
120
|
+
const start = text.indexOf(token);
|
|
121
|
+
if (start < 0) {
|
|
122
|
+
return lineRange(text);
|
|
123
|
+
}
|
|
124
|
+
return { start, end: start + token.length };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function splitLines(source: string): string[] {
|
|
128
|
+
return String(source).replace(/\r\n/g, "\n").split("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isBlank(line: string): boolean {
|
|
132
|
+
const trimmed = line.trim();
|
|
133
|
+
return !trimmed || trimmed.startsWith("#") || trimmed.startsWith("//");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseFieldsLoose(
|
|
137
|
+
list: string,
|
|
138
|
+
line: number,
|
|
139
|
+
lineText: string,
|
|
140
|
+
diagnostics: FlareDiagnostic[],
|
|
141
|
+
): { type: FlareType; name: string }[] {
|
|
142
|
+
const trimmed = list.trim();
|
|
143
|
+
if (!trimmed) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const fields: { type: FlareType; name: string }[] = [];
|
|
147
|
+
for (const part of trimmed.split(",")) {
|
|
148
|
+
const piece = part.trim();
|
|
149
|
+
if (!piece) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const match = piece.match(new RegExp(`^(${FLARE_IDENT})\\s+(${FLARE_IDENT})$`));
|
|
153
|
+
if (!match) {
|
|
154
|
+
diagnostics.push({
|
|
155
|
+
line,
|
|
156
|
+
...tokenRange(lineText, piece),
|
|
157
|
+
message: `expected 'Type name' in '${piece}'`,
|
|
158
|
+
severity: "error",
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const mapped = FLARE_TYPE_ALIASES[match[1]];
|
|
163
|
+
if (!mapped) {
|
|
164
|
+
const hint = closestType(match[1]);
|
|
165
|
+
diagnostics.push({
|
|
166
|
+
line,
|
|
167
|
+
...tokenRange(lineText, match[1]),
|
|
168
|
+
message: hint ? `unknown type '${match[1]}' — did you mean '${hint}'?` : `unknown type '${match[1]}'`,
|
|
169
|
+
severity: "error",
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
fields.push({ type: mapped, name: match[2] });
|
|
174
|
+
}
|
|
175
|
+
return fields;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function closestType(raw: string): string | null {
|
|
179
|
+
const lower = raw.toLowerCase();
|
|
180
|
+
let best: string | null = null;
|
|
181
|
+
let bestDist = 3;
|
|
182
|
+
for (const label of TYPE_LABELS) {
|
|
183
|
+
const dist = levenshtein(lower, label.toLowerCase());
|
|
184
|
+
if (dist < bestDist) {
|
|
185
|
+
bestDist = dist;
|
|
186
|
+
best = label;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return best;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function levenshtein(a: string, b: string): number {
|
|
193
|
+
if (a === b) {
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
const rows = a.length + 1;
|
|
197
|
+
const cols = b.length + 1;
|
|
198
|
+
const grid = Array.from({ length: rows }, () => new Array<number>(cols).fill(0));
|
|
199
|
+
for (let i = 0; i < rows; i++) {
|
|
200
|
+
grid[i][0] = i;
|
|
201
|
+
}
|
|
202
|
+
for (let j = 0; j < cols; j++) {
|
|
203
|
+
grid[0][j] = j;
|
|
204
|
+
}
|
|
205
|
+
for (let i = 1; i < rows; i++) {
|
|
206
|
+
for (let j = 1; j < cols; j++) {
|
|
207
|
+
grid[i][j] = Math.min(
|
|
208
|
+
grid[i - 1][j] + 1,
|
|
209
|
+
grid[i][j - 1] + 1,
|
|
210
|
+
grid[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return grid[a.length][b.length];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function parseSideLoose(raw: string, line: number, lineText: string, diagnostics: FlareDiagnostic[]): FlareSide | null {
|
|
218
|
+
const side = raw.toLowerCase();
|
|
219
|
+
if (side === "client") {
|
|
220
|
+
return "Client";
|
|
221
|
+
}
|
|
222
|
+
if (side === "server") {
|
|
223
|
+
return "Server";
|
|
224
|
+
}
|
|
225
|
+
diagnostics.push({
|
|
226
|
+
line,
|
|
227
|
+
...tokenRange(lineText, raw),
|
|
228
|
+
message: `from must be Client or Server, got '${raw}'`,
|
|
229
|
+
severity: "error",
|
|
230
|
+
});
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function analyzeFlare(source: string, filePath = "Net.flare", defaultName = "Net"): FlareAnalysis {
|
|
235
|
+
const diagnostics: FlareDiagnostic[] = [];
|
|
236
|
+
const packets: FlarePacket[] = [];
|
|
237
|
+
const queries: FlareQuery[] = [];
|
|
238
|
+
const seen = new Map<string, { line: number; kind: string }>();
|
|
239
|
+
let name = defaultName;
|
|
240
|
+
let packetId = 1;
|
|
241
|
+
let queryId = 1;
|
|
242
|
+
const lines = splitLines(source);
|
|
243
|
+
|
|
244
|
+
for (let i = 0; i < lines.length; i++) {
|
|
245
|
+
const lineText = lines[i];
|
|
246
|
+
const line = lineText.trim();
|
|
247
|
+
if (isBlank(lineText)) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const opt = line.match(/^opt\s+name\s*=\s*(.+)$/i);
|
|
252
|
+
if (opt) {
|
|
253
|
+
const value = opt[1].trim().replace(/^["']|["']$/g, "");
|
|
254
|
+
if (!IDENT_RE.test(value)) {
|
|
255
|
+
diagnostics.push({
|
|
256
|
+
line: i,
|
|
257
|
+
...tokenRange(lineText, opt[1].trim()),
|
|
258
|
+
message: "opt name must be an identifier",
|
|
259
|
+
severity: "error",
|
|
260
|
+
});
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
name = value;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (/^opt\b/i.test(line)) {
|
|
268
|
+
diagnostics.push({
|
|
269
|
+
line: i,
|
|
270
|
+
...lineRange(lineText),
|
|
271
|
+
message: "expected `opt name = Net`",
|
|
272
|
+
severity: "error",
|
|
273
|
+
});
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const packet = line.match(
|
|
278
|
+
new RegExp(
|
|
279
|
+
`^packet\\s+(${FLARE_IDENT})\\s*\\((.*)\\)\\s+from\\s+(${FLARE_IDENT})(?:\\s+(${FLARE_IDENT}))?\\s*$`,
|
|
280
|
+
"i",
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
if (packet) {
|
|
284
|
+
const from = parseSideLoose(packet[3], i, lineText, diagnostics);
|
|
285
|
+
const reliability = packet[4];
|
|
286
|
+
if (reliability && !/^(unreliable|reliable)$/i.test(reliability)) {
|
|
287
|
+
diagnostics.push({
|
|
288
|
+
line: i,
|
|
289
|
+
...tokenRange(lineText, reliability),
|
|
290
|
+
message: `expected unreliable or reliable, got '${reliability}'`,
|
|
291
|
+
severity: "error",
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
if (packetId > 255) {
|
|
295
|
+
diagnostics.push({
|
|
296
|
+
line: i,
|
|
297
|
+
...tokenRange(lineText, packet[1]),
|
|
298
|
+
message: "too many packets (max 255)",
|
|
299
|
+
severity: "error",
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const fields = parseFieldsLoose(packet[2], i, lineText, diagnostics);
|
|
304
|
+
if (!from) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const dup = seen.get(packet[1]);
|
|
308
|
+
if (dup) {
|
|
309
|
+
diagnostics.push({
|
|
310
|
+
line: i,
|
|
311
|
+
...tokenRange(lineText, packet[1]),
|
|
312
|
+
message: `duplicate ${dup.kind} '${packet[1]}'`,
|
|
313
|
+
severity: "error",
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
seen.set(packet[1], { line: i, kind: "packet" });
|
|
318
|
+
packets.push({
|
|
319
|
+
kind: "packet",
|
|
320
|
+
name: packet[1],
|
|
321
|
+
id: packetId++,
|
|
322
|
+
from,
|
|
323
|
+
reliable: String(reliability || "reliable").toLowerCase() !== "unreliable",
|
|
324
|
+
fields,
|
|
325
|
+
});
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const query = line.match(
|
|
330
|
+
new RegExp(`^query\\s+(${FLARE_IDENT})\\s*\\((.*)\\)\\s*->\\s*(${FLARE_IDENT})\\s*$`, "i"),
|
|
331
|
+
);
|
|
332
|
+
if (query) {
|
|
333
|
+
if (queryId > 255) {
|
|
334
|
+
diagnostics.push({
|
|
335
|
+
line: i,
|
|
336
|
+
...tokenRange(lineText, query[1]),
|
|
337
|
+
message: "too many queries (max 255)",
|
|
338
|
+
severity: "error",
|
|
339
|
+
});
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const returns = FLARE_TYPE_ALIASES[query[3]];
|
|
343
|
+
if (!returns) {
|
|
344
|
+
const hint = closestType(query[3]);
|
|
345
|
+
diagnostics.push({
|
|
346
|
+
line: i,
|
|
347
|
+
...tokenRange(lineText, query[3]),
|
|
348
|
+
message: hint ? `unknown type '${query[3]}' — did you mean '${hint}'?` : `unknown type '${query[3]}'`,
|
|
349
|
+
severity: "error",
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const request = parseFieldsLoose(query[2], i, lineText, diagnostics);
|
|
353
|
+
const dup = seen.get(query[1]);
|
|
354
|
+
if (dup) {
|
|
355
|
+
diagnostics.push({
|
|
356
|
+
line: i,
|
|
357
|
+
...tokenRange(lineText, query[1]),
|
|
358
|
+
message: `duplicate ${dup.kind} '${query[1]}'`,
|
|
359
|
+
severity: "error",
|
|
360
|
+
});
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (!returns) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
seen.set(query[1], { line: i, kind: "query" });
|
|
367
|
+
queries.push({
|
|
368
|
+
kind: "query",
|
|
369
|
+
name: query[1],
|
|
370
|
+
id: queryId++,
|
|
371
|
+
request,
|
|
372
|
+
returns,
|
|
373
|
+
});
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
diagnostics.push({
|
|
378
|
+
line: i,
|
|
379
|
+
...lineRange(lineText),
|
|
380
|
+
message: "expected packet Tag(Type name, ...) from Client|Server or query Tag(...) -> Type",
|
|
381
|
+
severity: "error",
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (packets.length === 0 && queries.length === 0) {
|
|
386
|
+
diagnostics.push({
|
|
387
|
+
line: 0,
|
|
388
|
+
start: 0,
|
|
389
|
+
end: Math.max(lines[0]?.length ?? 1, 1),
|
|
390
|
+
message: "schema is empty — add a packet or query",
|
|
391
|
+
severity: lines.some((line) => !isBlank(line)) ? "error" : "hint",
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return { name, packets, queries, diagnostics };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function diagnoseFlare(source: string, filePath?: string): FlareDiagnostic[] {
|
|
399
|
+
return analyzeFlare(source, filePath).diagnostics;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function fieldList(fields: { type: string; name: string }[]): string {
|
|
403
|
+
return fields.map((field) => `${field.type} ${field.name}`).join(", ");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function packetApi(packet: FlarePacket, ns: string): string {
|
|
407
|
+
const args = fieldList(packet.fields);
|
|
408
|
+
if (packet.from === "Client") {
|
|
409
|
+
return [
|
|
410
|
+
`**packet ${packet.name}** \`${args}\` from Client${packet.reliable ? "" : " unreliable"}`,
|
|
411
|
+
"",
|
|
412
|
+
`Generated \`${ns}.${packet.name}\`:`,
|
|
413
|
+
"- Client: `FireServer(...)`",
|
|
414
|
+
"- Server: `Connect` — first arg is `Player`",
|
|
415
|
+
].join("\n");
|
|
416
|
+
}
|
|
417
|
+
return [
|
|
418
|
+
`**packet ${packet.name}** \`${args}\` from Server${packet.reliable ? "" : " unreliable"}`,
|
|
419
|
+
"",
|
|
420
|
+
`Generated \`${ns}.${packet.name}\`:`,
|
|
421
|
+
"- Server: `Fire(recipient, …fields)` / `FireAll(…fields)`",
|
|
422
|
+
"- Client: `Connect` — fields only, no recipient",
|
|
423
|
+
].join("\n");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function queryApi(query: FlareQuery, ns: string): string {
|
|
427
|
+
return [
|
|
428
|
+
`**query ${query.name}** \`${fieldList(query.request)}\` -> ${query.returns}`,
|
|
429
|
+
"",
|
|
430
|
+
`Generated \`${ns}.${query.name}\`:`,
|
|
431
|
+
"- Client: `Invoke(...)`",
|
|
432
|
+
"- Server: `On` — first arg is `Player`, **return** the result",
|
|
433
|
+
].join("\n");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function keywordDocs(word: string): string | null {
|
|
437
|
+
const table: Record<string, string> = {
|
|
438
|
+
opt: "`opt name = Net` — generated module / header name.",
|
|
439
|
+
name: "Identifier written by `cluaupp build` (`Net.clh`, `Net.Hit`, …).",
|
|
440
|
+
packet: "One-way remote. `from Client` = FireServer; `from Server` = Fire / FireAll.",
|
|
441
|
+
query: "RPC. Client `Invoke`, server `On` must return the result type.",
|
|
442
|
+
from: "`from Client` or `from Server` — who **sends** the packet.",
|
|
443
|
+
Client: "The client sends. Server `Connect` receives `Player` first.",
|
|
444
|
+
Server: "The server sends. `Fire(player, …)` first arg is the recipient.",
|
|
445
|
+
unreliable: "Unreliable datagram (no ordering/retry). Server-origin sparks, not money.",
|
|
446
|
+
reliable: "Default. Ordered, retried. Keep currency off this channel.",
|
|
447
|
+
};
|
|
448
|
+
return table[word] ?? null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function wordAt(lineText: string, character: number): { word: string; start: number; end: number } | null {
|
|
452
|
+
WORD_RE.lastIndex = 0;
|
|
453
|
+
let match: RegExpExecArray | null;
|
|
454
|
+
while ((match = WORD_RE.exec(lineText))) {
|
|
455
|
+
const start = match.index;
|
|
456
|
+
const end = start + match[0].length;
|
|
457
|
+
if (character >= start && character <= end) {
|
|
458
|
+
return { word: match[0], start, end };
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function hoverAt(source: string, line: number, character: number): FlareHover | null {
|
|
465
|
+
const lines = splitLines(source);
|
|
466
|
+
const lineText = lines[line] ?? "";
|
|
467
|
+
const found = wordAt(lineText, character);
|
|
468
|
+
if (!found) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
const analysis = analyzeFlare(source);
|
|
472
|
+
const packet = analysis.packets.find((item) => item.name === found.word);
|
|
473
|
+
if (packet) {
|
|
474
|
+
return { contents: packetApi(packet, analysis.name), start: found.start, end: found.end };
|
|
475
|
+
}
|
|
476
|
+
const query = analysis.queries.find((item) => item.name === found.word);
|
|
477
|
+
if (query) {
|
|
478
|
+
return { contents: queryApi(query, analysis.name), start: found.start, end: found.end };
|
|
479
|
+
}
|
|
480
|
+
const typeDoc = FLARE_TYPE_DOCS[found.word];
|
|
481
|
+
if (typeDoc) {
|
|
482
|
+
const canon = FLARE_TYPE_ALIASES[found.word] ?? found.word;
|
|
483
|
+
return { contents: `**${found.word}**${canon !== found.word ? ` → \`${canon}\`` : ""}\n\n${typeDoc}`, start: found.start, end: found.end };
|
|
484
|
+
}
|
|
485
|
+
const keyword = keywordDocs(found.word);
|
|
486
|
+
if (keyword) {
|
|
487
|
+
return { contents: keyword, start: found.start, end: found.end };
|
|
488
|
+
}
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function item(
|
|
493
|
+
label: string,
|
|
494
|
+
kind: FlareCompletionKind,
|
|
495
|
+
detail: string,
|
|
496
|
+
documentation: string,
|
|
497
|
+
insertText = label,
|
|
498
|
+
snippet = false,
|
|
499
|
+
sortText?: string,
|
|
500
|
+
): FlareCompletion {
|
|
501
|
+
return { label, kind, detail, documentation, insertText, snippet, sortText: sortText ?? label };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function typeItems(): FlareCompletion[] {
|
|
505
|
+
return TYPE_LABELS.map((label, index) =>
|
|
506
|
+
item(label, "type", FLARE_TYPE_ALIASES[label] ?? label, FLARE_TYPE_DOCS[label] ?? "", label, false, `t${String(index).padStart(2, "0")}`),
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const SNIPPETS: FlareCompletion[] = [
|
|
511
|
+
item(
|
|
512
|
+
"packet (from Client)",
|
|
513
|
+
"snippet",
|
|
514
|
+
"Client → server packet",
|
|
515
|
+
"FireServer on the client; Connect on the server with Player first.",
|
|
516
|
+
"packet ${1:Hit}(${2:Player} ${3:target}, ${4:i32} ${5:damage}) from Client",
|
|
517
|
+
true,
|
|
518
|
+
"0packetc",
|
|
519
|
+
),
|
|
520
|
+
item(
|
|
521
|
+
"packet (from Server)",
|
|
522
|
+
"snippet",
|
|
523
|
+
"Server → client packet",
|
|
524
|
+
"Fire(recipient, …fields) / FireAll. Client Connect sees fields only.",
|
|
525
|
+
"packet ${1:Welcome}(${2:i32} ${3:userId}, ${4:f64} ${5:clock}) from Server",
|
|
526
|
+
true,
|
|
527
|
+
"0packets",
|
|
528
|
+
),
|
|
529
|
+
item(
|
|
530
|
+
"packet (unreliable)",
|
|
531
|
+
"snippet",
|
|
532
|
+
"Unreliable server packet",
|
|
533
|
+
"No retry. Visual sparks, not economy.",
|
|
534
|
+
"packet ${1:Spark}(${2:Vector3} ${3:pos}) from Server unreliable",
|
|
535
|
+
true,
|
|
536
|
+
"0packetu",
|
|
537
|
+
),
|
|
538
|
+
item(
|
|
539
|
+
"query",
|
|
540
|
+
"snippet",
|
|
541
|
+
"RPC query",
|
|
542
|
+
"Client Invoke; server On returns the result.",
|
|
543
|
+
"query ${1:Session}() -> ${2:i32}",
|
|
544
|
+
true,
|
|
545
|
+
"0query",
|
|
546
|
+
),
|
|
547
|
+
item("opt name", "snippet", "Generated module name", "Writes Net.clh / Net.luau.", "opt name = ${1:Net}", true, "0opt"),
|
|
548
|
+
];
|
|
549
|
+
|
|
550
|
+
function insideParens(text: string): boolean {
|
|
551
|
+
const open = text.lastIndexOf("(");
|
|
552
|
+
const close = text.lastIndexOf(")");
|
|
553
|
+
return open >= 0 && open > close;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function lastParamFragment(text: string): string {
|
|
557
|
+
const open = text.lastIndexOf("(");
|
|
558
|
+
if (open < 0) {
|
|
559
|
+
return "";
|
|
560
|
+
}
|
|
561
|
+
const inner = text.slice(open + 1);
|
|
562
|
+
const parts = inner.split(",");
|
|
563
|
+
return (parts[parts.length - 1] ?? "").trim();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function completionsAt(source: string, line: number, character: number): FlareCompletion[] {
|
|
567
|
+
const lines = splitLines(source);
|
|
568
|
+
const lineText = lines[line] ?? "";
|
|
569
|
+
const before = lineText.slice(0, character);
|
|
570
|
+
const text = before.trimStart();
|
|
571
|
+
|
|
572
|
+
if (!text) {
|
|
573
|
+
return [
|
|
574
|
+
item("packet", "keyword", "One-way remote", keywordDocs("packet") ?? "", "packet "),
|
|
575
|
+
item("query", "keyword", "RPC", keywordDocs("query") ?? "", "query "),
|
|
576
|
+
item("opt", "keyword", "Module name", keywordDocs("opt") ?? "", "opt name = Net"),
|
|
577
|
+
...SNIPPETS,
|
|
578
|
+
];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (/^opt(\s+\w*)?$/i.test(text)) {
|
|
582
|
+
return [item("name", "field", "opt name = Net", keywordDocs("name") ?? "")];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (/^opt\s+name\s*$/i.test(text)) {
|
|
586
|
+
return [item("=", "keyword", "opt name = Net", "Assign the generated identifier.", "= Net")];
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (/\bfrom\s+(Client|Server)\s+\w*$/i.test(text)) {
|
|
590
|
+
return [
|
|
591
|
+
item("unreliable", "keyword", "Unreliable datagram", keywordDocs("unreliable") ?? ""),
|
|
592
|
+
item("reliable", "keyword", "Ordered retry (default)", keywordDocs("reliable") ?? ""),
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (/\bfrom\s+\w*$/i.test(text)) {
|
|
597
|
+
return [
|
|
598
|
+
item("Client", "value", "from Client", keywordDocs("Client") ?? ""),
|
|
599
|
+
item("Server", "value", "from Server", keywordDocs("Server") ?? ""),
|
|
600
|
+
];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (/^packet\b.*\)\s+$/i.test(text) || /^packet\b.*\)\s+\w*$/i.test(text)) {
|
|
604
|
+
return [item("from", "keyword", "from Client | Server", keywordDocs("from") ?? "", "from ")];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (/->\s*\w*$/.test(text)) {
|
|
608
|
+
return typeItems();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if ((/^packet\b/i.test(text) || /^query\b/i.test(text)) && insideParens(text)) {
|
|
612
|
+
const fragment = lastParamFragment(text);
|
|
613
|
+
if (!fragment) {
|
|
614
|
+
return typeItems();
|
|
615
|
+
}
|
|
616
|
+
const bits = fragment.split(/\s+/);
|
|
617
|
+
if (bits.length === 1) {
|
|
618
|
+
const mapped = FLARE_TYPE_ALIASES[bits[0]];
|
|
619
|
+
if (mapped) {
|
|
620
|
+
return [
|
|
621
|
+
item("name", "field", "Field identifier", "Second token is the field name (`i32 damage`).", "${1:value}", true),
|
|
622
|
+
];
|
|
623
|
+
}
|
|
624
|
+
return typeItems();
|
|
625
|
+
}
|
|
626
|
+
return [];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (/^packet\s+$/i.test(text) || /^query\s+$/i.test(text)) {
|
|
630
|
+
return [];
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return SNIPPETS.filter((snippet) => snippet.label.toLowerCase().includes(text.toLowerCase().split(/\s+/)[0] ?? ""));
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function symbolsIn(source: string): FlareSymbol[] {
|
|
637
|
+
const analysis = analyzeFlare(source);
|
|
638
|
+
const lines = splitLines(source);
|
|
639
|
+
const symbols: FlareSymbol[] = [];
|
|
640
|
+
if (analysis.name) {
|
|
641
|
+
const optLine = lines.findIndex((line) => /^\s*opt\s+name\s*=/i.test(line));
|
|
642
|
+
if (optLine >= 0) {
|
|
643
|
+
symbols.push({
|
|
644
|
+
name: analysis.name,
|
|
645
|
+
detail: "opt name",
|
|
646
|
+
kind: "option",
|
|
647
|
+
line: optLine,
|
|
648
|
+
...lineRange(lines[optLine]),
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
for (const packet of analysis.packets) {
|
|
653
|
+
const line = lines.findIndex((row) => new RegExp(`^\\s*packet\\s+${packet.name}\\b`).test(row));
|
|
654
|
+
if (line < 0) {
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
symbols.push({
|
|
658
|
+
name: packet.name,
|
|
659
|
+
detail: `from ${packet.from}${packet.reliable ? "" : " unreliable"}`,
|
|
660
|
+
kind: "packet",
|
|
661
|
+
line,
|
|
662
|
+
...tokenRange(lines[line], packet.name),
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
for (const query of analysis.queries) {
|
|
666
|
+
const line = lines.findIndex((row) => new RegExp(`^\\s*query\\s+${query.name}\\b`).test(row));
|
|
667
|
+
if (line < 0) {
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
symbols.push({
|
|
671
|
+
name: query.name,
|
|
672
|
+
detail: `-> ${query.returns}`,
|
|
673
|
+
kind: "query",
|
|
674
|
+
line,
|
|
675
|
+
...tokenRange(lines[line], query.name),
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
return symbols;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export function signatureAt(source: string, line: number, character: number): FlareSignature | null {
|
|
682
|
+
const lines = splitLines(source);
|
|
683
|
+
const lineText = lines[line] ?? "";
|
|
684
|
+
const before = lineText.slice(0, character);
|
|
685
|
+
const packet = before.match(/packet\s+(\w+)\s*\(([^)]*)$/i);
|
|
686
|
+
const query = before.match(/query\s+(\w+)\s*\(([^)]*)$/i);
|
|
687
|
+
const match = packet || query;
|
|
688
|
+
if (!match) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
const inner = match[2] ?? "";
|
|
692
|
+
const activeParameter = inner.split(",").length - 1;
|
|
693
|
+
return {
|
|
694
|
+
label: `${packet ? "packet" : "query"} ${match[1]}(Type name, …)`,
|
|
695
|
+
documentation: packet
|
|
696
|
+
? "Each field is `Type name`. Then `from Client` or `from Server`."
|
|
697
|
+
: "Each field is `Type name`. Then `-> Type` for the return.",
|
|
698
|
+
parameters: [
|
|
699
|
+
{ label: "Type name", documentation: "Wire type then identifier, e.g. `i32 damage`." },
|
|
700
|
+
{ label: "…", documentation: "More fields separated by commas." },
|
|
701
|
+
],
|
|
702
|
+
activeParameter: Math.max(0, activeParameter),
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export function isFlareDocument(uri: string): boolean {
|
|
707
|
+
return /\.flare$/i.test(uri.split("?")[0] ?? "");
|
|
708
|
+
}
|