@lucidkiln/language-server 1.0.0 → 1.1.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/dist/main.js +1299 -71
- package/dist/main.js.map +1 -1
- package/package.json +3 -2
package/dist/main.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { DiagnosticSeverity, DidChangeWatchedFilesNotification, FileChangeType, ProposedFeatures, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
4
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
6
|
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
6
7
|
import { readFile, readdir } from "node:fs/promises";
|
|
@@ -9,6 +10,8 @@ import * as bundledKiln from "@lucidkiln/core";
|
|
|
9
10
|
import { templateText } from "@lucidkiln/core";
|
|
10
11
|
import { getLanguageService } from "vscode-html-languageservice";
|
|
11
12
|
import * as bundledFormat from "@lucidkiln/core/format";
|
|
13
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
14
|
+
import { parse } from "@lucidkiln/core/syntax";
|
|
12
15
|
//#region src/builtins.ts
|
|
13
16
|
const block = (name, header) => `${name}${header} %}\n\t$0\n{% end${name} %}`;
|
|
14
17
|
const TAGS = {
|
|
@@ -121,7 +124,7 @@ const LOOP_PROPERTIES = {
|
|
|
121
124
|
last: "`true` on the last item.",
|
|
122
125
|
length: "Total number of items."
|
|
123
126
|
};
|
|
124
|
-
const KEYWORDS = [
|
|
127
|
+
const KEYWORDS$1 = [
|
|
125
128
|
"true",
|
|
126
129
|
"false",
|
|
127
130
|
"null",
|
|
@@ -211,6 +214,10 @@ const cursorContext = (text, offset) => {
|
|
|
211
214
|
start,
|
|
212
215
|
template: props[2]
|
|
213
216
|
};
|
|
217
|
+
if (/[\w\])]\??\.$/.test(head) && !/(?:^|[^\w$])\d+\.$/.test(head)) return {
|
|
218
|
+
kind: "member",
|
|
219
|
+
start
|
|
220
|
+
};
|
|
214
221
|
if (/[.\w]$/.test(head) || /["']$/.test(head)) return null;
|
|
215
222
|
return {
|
|
216
223
|
kind: "expression",
|
|
@@ -295,6 +302,107 @@ const symbolAt = (text, offset) => {
|
|
|
295
302
|
};
|
|
296
303
|
};
|
|
297
304
|
//#endregion
|
|
305
|
+
//#region src/calls.ts
|
|
306
|
+
const CLOSERS = {
|
|
307
|
+
"(": ")",
|
|
308
|
+
"[": "]",
|
|
309
|
+
"{": "}"
|
|
310
|
+
};
|
|
311
|
+
/** Words before `(` that aren't calls, as in `not (a or b)`. */
|
|
312
|
+
const KEYWORDS = /* @__PURE__ */ new Set([
|
|
313
|
+
"and",
|
|
314
|
+
"or",
|
|
315
|
+
"not",
|
|
316
|
+
"in",
|
|
317
|
+
"is",
|
|
318
|
+
"if",
|
|
319
|
+
"else"
|
|
320
|
+
]);
|
|
321
|
+
const NAMED = /^\s*([A-Za-z_]\w*)\s*:(?!:)/;
|
|
322
|
+
const callContext = (text, offset) => {
|
|
323
|
+
const delimiter = enclosingDelimiter(text, offset);
|
|
324
|
+
if (!delimiter) return null;
|
|
325
|
+
const stack = [];
|
|
326
|
+
let quote = null;
|
|
327
|
+
for (let index = delimiter.start + 2; index < offset; index++) {
|
|
328
|
+
const char = text[index];
|
|
329
|
+
if (quote) {
|
|
330
|
+
if (char === "\\") index++;
|
|
331
|
+
else if (char === quote) quote = null;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (char === "\"" || char === "'") quote = char;
|
|
335
|
+
else if (char in CLOSERS) {
|
|
336
|
+
let callee = null;
|
|
337
|
+
if (char === "(") {
|
|
338
|
+
const match = /(\|\s*|\.\s*)?([A-Za-z_]\w*)\s*$/.exec(text.slice(delimiter.start + 2, index));
|
|
339
|
+
const name = match?.[2];
|
|
340
|
+
if (name && !KEYWORDS.has(name) && !match[1]?.startsWith(".")) callee = {
|
|
341
|
+
kind: match[1] ? "filter" : "function",
|
|
342
|
+
name
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
stack.push({
|
|
346
|
+
char,
|
|
347
|
+
callee,
|
|
348
|
+
commas: 0,
|
|
349
|
+
segment: index + 1,
|
|
350
|
+
sawNamed: false
|
|
351
|
+
});
|
|
352
|
+
} else if (char === ")" || char === "]" || char === "}") stack.pop();
|
|
353
|
+
else if (char === ",") {
|
|
354
|
+
const frame = stack.at(-1);
|
|
355
|
+
if (frame) {
|
|
356
|
+
if (NAMED.test(text.slice(frame.segment, index))) frame.sawNamed = true;
|
|
357
|
+
frame.commas++;
|
|
358
|
+
frame.segment = index + 1;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const frame = stack.at(-1);
|
|
363
|
+
if (frame?.char !== "(" || !frame.callee) return null;
|
|
364
|
+
const segment = text.slice(frame.segment, offset);
|
|
365
|
+
const word = /[A-Za-z_]\w*$/.exec(segment)?.[0] ?? "";
|
|
366
|
+
return {
|
|
367
|
+
...frame.callee,
|
|
368
|
+
index: frame.commas,
|
|
369
|
+
named: NAMED.exec(segment)?.[1] ?? null,
|
|
370
|
+
sawNamed: frame.sawNamed,
|
|
371
|
+
namePosition: /^\s*[A-Za-z_]?\w*$/.test(segment),
|
|
372
|
+
start: offset - word.length
|
|
373
|
+
};
|
|
374
|
+
};
|
|
375
|
+
/**
|
|
376
|
+
* Splits a parameter list, such as `a: string, b?: { c: number }`, at its top-level commas. Each part keeps its
|
|
377
|
+
* offsets, so a signature can point at the parameter it's on.
|
|
378
|
+
*/
|
|
379
|
+
const splitParameters = (list) => {
|
|
380
|
+
const parts = [];
|
|
381
|
+
let depth = 0;
|
|
382
|
+
let start = 0;
|
|
383
|
+
const push = (end) => {
|
|
384
|
+
const raw = list.slice(start, end);
|
|
385
|
+
const leading = raw.length - raw.trimStart().length;
|
|
386
|
+
const text = raw.trim();
|
|
387
|
+
if (text) parts.push({
|
|
388
|
+
text,
|
|
389
|
+
start: start + leading,
|
|
390
|
+
end: start + leading + text.length
|
|
391
|
+
});
|
|
392
|
+
};
|
|
393
|
+
for (let index = 0; index < list.length; index++) {
|
|
394
|
+
const char = list[index];
|
|
395
|
+
if ("([{<".includes(char)) depth++;
|
|
396
|
+
else if (")]}".includes(char) || char === ">" && list[index - 1] !== "=") depth--;
|
|
397
|
+
else if (char === "," && depth === 0) {
|
|
398
|
+
push(index);
|
|
399
|
+
start = index + 1;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
push(list.length);
|
|
403
|
+
return parts;
|
|
404
|
+
};
|
|
405
|
+
//#endregion
|
|
298
406
|
//#region src/docs.ts
|
|
299
407
|
/**
|
|
300
408
|
* What each built-in does, from its section of the docs site, up to the end of its first example. The docs build writes
|
|
@@ -637,6 +745,244 @@ const htmlHover = (document, offset) => {
|
|
|
637
745
|
return service.doHover(html, document.positionAt(offset), parsed);
|
|
638
746
|
};
|
|
639
747
|
//#endregion
|
|
748
|
+
//#region src/typescript-6.ts
|
|
749
|
+
/** A project checked by TypeScript 6's classic API, in this process. */
|
|
750
|
+
const openTypeScript6 = (ts, configPath) => {
|
|
751
|
+
const parseConfig = () => {
|
|
752
|
+
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, {
|
|
753
|
+
...ts.sys,
|
|
754
|
+
onUnRecoverableConfigFileDiagnostic: () => {}
|
|
755
|
+
});
|
|
756
|
+
if (!parsed) throw new Error(`can't read ${configPath}`);
|
|
757
|
+
return parsed;
|
|
758
|
+
};
|
|
759
|
+
let config = parseConfig();
|
|
760
|
+
const virtual = virtualPath(configPath, config.fileNames);
|
|
761
|
+
let text = "";
|
|
762
|
+
/** Bumped when a file changes, so the language service reads it again. */
|
|
763
|
+
const versions = /* @__PURE__ */ new Map();
|
|
764
|
+
const version = (path) => versions.get(path) ?? 0;
|
|
765
|
+
let fileNames = [...config.fileNames, virtual];
|
|
766
|
+
const read = (path) => samePath(path, virtual) ? text : ts.sys.readFile(path);
|
|
767
|
+
const service = ts.createLanguageService({
|
|
768
|
+
getCompilationSettings: () => config.options,
|
|
769
|
+
getScriptFileNames: () => fileNames,
|
|
770
|
+
getScriptVersion: (path) => String(version(path)),
|
|
771
|
+
getScriptSnapshot: (path) => {
|
|
772
|
+
const content = read(path);
|
|
773
|
+
return content === void 0 ? void 0 : ts.ScriptSnapshot.fromString(content);
|
|
774
|
+
},
|
|
775
|
+
getCurrentDirectory: () => dirname(configPath),
|
|
776
|
+
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
|
|
777
|
+
fileExists: (path) => samePath(path, virtual) || ts.sys.fileExists(path),
|
|
778
|
+
readFile: read,
|
|
779
|
+
readDirectory: (...args) => ts.sys.readDirectory(...args),
|
|
780
|
+
directoryExists: (path) => ts.sys.directoryExists(path),
|
|
781
|
+
getDirectories: (path) => ts.sys.getDirectories(path)
|
|
782
|
+
}, ts.createDocumentRegistry());
|
|
783
|
+
return {
|
|
784
|
+
query(source, use) {
|
|
785
|
+
if (source !== text) {
|
|
786
|
+
text = source;
|
|
787
|
+
versions.set(virtual, version(virtual) + 1);
|
|
788
|
+
}
|
|
789
|
+
const program = service.getProgram();
|
|
790
|
+
const file = program?.getSourceFile(virtual);
|
|
791
|
+
if (!program || !file) return null;
|
|
792
|
+
const checker = program.getTypeChecker();
|
|
793
|
+
const typeOf = (alias) => {
|
|
794
|
+
const declaration = file.statements.find((statement) => ts.isTypeAliasDeclaration(statement) && statement.name.text === alias);
|
|
795
|
+
return declaration ? checker.getTypeAtLocation(declaration.name) : void 0;
|
|
796
|
+
};
|
|
797
|
+
return use({
|
|
798
|
+
describe: (alias) => {
|
|
799
|
+
const type = typeOf(alias);
|
|
800
|
+
return type ? checker.typeToString(type, void 0, ts.TypeFormatFlags.NoTruncation) : null;
|
|
801
|
+
},
|
|
802
|
+
members: (alias) => {
|
|
803
|
+
const type = typeOf(alias);
|
|
804
|
+
if (!type) return [];
|
|
805
|
+
return checker.getPropertiesOfType(type).map((symbol) => {
|
|
806
|
+
const node = symbol.declarations?.[0];
|
|
807
|
+
const declaredIn = node?.getSourceFile();
|
|
808
|
+
return {
|
|
809
|
+
name: symbol.name,
|
|
810
|
+
optional: (symbol.flags & ts.SymbolFlags.Optional) !== 0,
|
|
811
|
+
type: checker.typeToString(checker.getTypeOfSymbolAtLocation(symbol, file), void 0, ts.TypeFormatFlags.NoTruncation),
|
|
812
|
+
docs: ts.displayPartsToString(symbol.getDocumentationComment(checker)),
|
|
813
|
+
tags: symbol.getJsDocTags(checker).map((tag) => ({
|
|
814
|
+
name: tag.name,
|
|
815
|
+
text: ts.displayPartsToString(tag.text)
|
|
816
|
+
})),
|
|
817
|
+
location: node && declaredIn ? {
|
|
818
|
+
path: declaredIn.fileName,
|
|
819
|
+
...declaredIn.getLineAndCharacterOfPosition(node.getStart(declaredIn))
|
|
820
|
+
} : null
|
|
821
|
+
};
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
},
|
|
826
|
+
changed(changes) {
|
|
827
|
+
for (const change of changes) versions.set(change.path, version(change.path) + 1);
|
|
828
|
+
if (changes.some((change) => change.kind !== "changed")) {
|
|
829
|
+
config = parseConfig();
|
|
830
|
+
fileNames = [...config.fileNames, virtual];
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
close() {
|
|
834
|
+
service.dispose();
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
};
|
|
838
|
+
//#endregion
|
|
839
|
+
//#region src/typescript-7.ts
|
|
840
|
+
/** `TypeFormatFlags.NoTruncation`, so long types are written in full. */
|
|
841
|
+
const NO_TRUNCATION = 1;
|
|
842
|
+
/** A project checked by TypeScript 7's API, which runs the native compiler in its own process. */
|
|
843
|
+
const openTypeScript7 = (sync, configPath) => {
|
|
844
|
+
let virtual = "";
|
|
845
|
+
let text = "";
|
|
846
|
+
const api = new sync.API({
|
|
847
|
+
cwd: dirname(configPath),
|
|
848
|
+
fs: {
|
|
849
|
+
readFile: (path) => samePath(path, virtual) ? text : void 0,
|
|
850
|
+
fileExists: (path) => samePath(path, virtual) ? true : void 0,
|
|
851
|
+
getAccessibleEntries: (folder) => virtual && samePath(folder, dirname(virtual)) ? entriesWith(folder, basename(virtual)) : void 0
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
virtual = virtualPath(configPath, api.parseConfigFile(configPath).fileNames);
|
|
855
|
+
let snapshot = api.updateSnapshot({ openProjects: [configPath] });
|
|
856
|
+
const update = (changes) => {
|
|
857
|
+
snapshot.dispose();
|
|
858
|
+
snapshot = api.updateSnapshot(changes);
|
|
859
|
+
};
|
|
860
|
+
return {
|
|
861
|
+
query(source, use) {
|
|
862
|
+
if (source !== text) {
|
|
863
|
+
text = source;
|
|
864
|
+
update({ fileChanges: { changed: [virtual] } });
|
|
865
|
+
}
|
|
866
|
+
const project = snapshot.getProject(configPath);
|
|
867
|
+
if (!project?.program.getSourceFile(virtual)) return null;
|
|
868
|
+
const { checker } = project;
|
|
869
|
+
const typeOf = (alias) => {
|
|
870
|
+
const at = text.indexOf(`type ${alias} =`);
|
|
871
|
+
return at === -1 ? void 0 : checker.getTypeAtPosition(virtual, at + 5);
|
|
872
|
+
};
|
|
873
|
+
return use({
|
|
874
|
+
describe: (alias) => {
|
|
875
|
+
const type = typeOf(alias);
|
|
876
|
+
return type ? checker.typeToString(type, void 0, NO_TRUNCATION) : null;
|
|
877
|
+
},
|
|
878
|
+
members: (alias) => {
|
|
879
|
+
const type = typeOf(alias);
|
|
880
|
+
if (!type) return [];
|
|
881
|
+
return checker.getPropertiesOfType(type).map((symbol) => {
|
|
882
|
+
const node = symbol.declarations[0]?.resolve(project);
|
|
883
|
+
const file = node?.getSourceFile();
|
|
884
|
+
const memberType = checker.getTypeOfSymbol(symbol);
|
|
885
|
+
return {
|
|
886
|
+
name: symbol.name,
|
|
887
|
+
optional: (symbol.flags & sync.SymbolFlags.Optional) !== 0,
|
|
888
|
+
type: memberType ? checker.typeToString(memberType, void 0, NO_TRUNCATION) : "unknown",
|
|
889
|
+
docs: checker.getDocumentationCommentOfSymbol(symbol),
|
|
890
|
+
tags: checker.getJsDocTagsOfSymbol(symbol).map((tag) => ({
|
|
891
|
+
name: tag.name,
|
|
892
|
+
text: tag.text ?? ""
|
|
893
|
+
})),
|
|
894
|
+
location: node && file ? {
|
|
895
|
+
path: file.fileName,
|
|
896
|
+
...lineAndCharacter(file.text, node.getStart(file))
|
|
897
|
+
} : null
|
|
898
|
+
};
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
},
|
|
903
|
+
changed(changes) {
|
|
904
|
+
const paths = (kind) => changes.filter((change) => change.kind === kind).map((change) => change.path);
|
|
905
|
+
update({ fileChanges: {
|
|
906
|
+
created: paths("created"),
|
|
907
|
+
changed: paths("changed"),
|
|
908
|
+
deleted: paths("deleted")
|
|
909
|
+
} });
|
|
910
|
+
},
|
|
911
|
+
close() {
|
|
912
|
+
snapshot.dispose();
|
|
913
|
+
api.close();
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
};
|
|
917
|
+
//#endregion
|
|
918
|
+
//#region src/typescript.ts
|
|
919
|
+
/** The generated file's name. Nothing in the project should use it. */
|
|
920
|
+
const VIRTUAL_NAME = "__kiln_types__.ts";
|
|
921
|
+
/** Where the generated file goes: the folder of the project's first TypeScript source file, or the config's folder. */
|
|
922
|
+
const virtualPath = (configPath, fileNames) => {
|
|
923
|
+
const root = dirname(configPath);
|
|
924
|
+
const source = fileNames.find((name) => {
|
|
925
|
+
const path = relative(root, name);
|
|
926
|
+
return /\.[cm]?tsx?$/.test(name) && !/\.d\.[cm]?ts$/.test(name) && !path.startsWith("..") && !path.split(sep).includes("node_modules");
|
|
927
|
+
});
|
|
928
|
+
return join(source ? dirname(source) : root, VIRTUAL_NAME);
|
|
929
|
+
};
|
|
930
|
+
/** Whether two paths are the same file. TypeScript may change a path's case on case-insensitive file systems. */
|
|
931
|
+
const samePath = (a, b) => a === b || a.toLowerCase() === b.toLowerCase();
|
|
932
|
+
/** A folder's entries with the generated file added, for TypeScript's include globs. */
|
|
933
|
+
const entriesWith = (folder, name) => {
|
|
934
|
+
const entries = readdirSync(folder, { withFileTypes: true });
|
|
935
|
+
return {
|
|
936
|
+
files: [...entries.filter((entry) => entry.isFile()).map((entry) => entry.name), name],
|
|
937
|
+
directories: entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
|
|
938
|
+
};
|
|
939
|
+
};
|
|
940
|
+
/** The zero-based line and character of an offset in `text`. */
|
|
941
|
+
const lineAndCharacter = (text, offset) => {
|
|
942
|
+
const lines = text.slice(0, offset).split("\n");
|
|
943
|
+
return {
|
|
944
|
+
line: lines.length - 1,
|
|
945
|
+
character: lines.at(-1).length
|
|
946
|
+
};
|
|
947
|
+
};
|
|
948
|
+
/** The nearest `tsconfig.json` in `folder` or a folder above it. */
|
|
949
|
+
const findConfig = (folder) => {
|
|
950
|
+
for (let current = folder;; current = dirname(current)) {
|
|
951
|
+
const candidate = join(current, "tsconfig.json");
|
|
952
|
+
try {
|
|
953
|
+
readFileSync(candidate);
|
|
954
|
+
return candidate;
|
|
955
|
+
} catch {}
|
|
956
|
+
if (dirname(current) === current) return null;
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
/**
|
|
960
|
+
* Loads the TypeScript installed for the project in `folder`, with the nearest `tsconfig.json`. Null when either is
|
|
961
|
+
* missing or the version is older than 6. Version 7 and later use TypeScript's new API; version 6 uses the classic one.
|
|
962
|
+
*/
|
|
963
|
+
const openTypeScript = async (folder, warn) => {
|
|
964
|
+
const configPath = findConfig(folder);
|
|
965
|
+
if (!configPath) return null;
|
|
966
|
+
const require = createRequire(join(folder, "kiln.json"));
|
|
967
|
+
let major;
|
|
968
|
+
try {
|
|
969
|
+
const pkg = JSON.parse(readFileSync(require.resolve("typescript/package.json"), "utf8"));
|
|
970
|
+
major = Number(pkg.version.split(".")[0]);
|
|
971
|
+
} catch {
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
try {
|
|
975
|
+
if (major >= 7) {
|
|
976
|
+
const sync = await import(pathToFileURL(require.resolve("typescript/unstable/sync")).href);
|
|
977
|
+
return openTypeScript7(sync, configPath);
|
|
978
|
+
}
|
|
979
|
+
if (major === 6) return openTypeScript6(require("typescript"), configPath);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
warn(`Kiln: couldn't load TypeScript. ${error.message}`);
|
|
982
|
+
}
|
|
983
|
+
return null;
|
|
984
|
+
};
|
|
985
|
+
//#endregion
|
|
640
986
|
//#region src/project.ts
|
|
641
987
|
/** Diagnostics that need to know the project's names and templates, so they are dropped without a `kiln.json`. */
|
|
642
988
|
const NEEDS_MANIFEST = /* @__PURE__ */ new Set([
|
|
@@ -777,13 +1123,18 @@ const openProject = async (manifestPath, fallbackRoot, warn) => {
|
|
|
777
1123
|
manifest = null;
|
|
778
1124
|
kiln = createKiln(options);
|
|
779
1125
|
}
|
|
1126
|
+
let typescript;
|
|
1127
|
+
const folder = manifestPath && manifest ? dirname(manifestPath) : null;
|
|
780
1128
|
return {
|
|
781
1129
|
manifestPath,
|
|
782
1130
|
manifest,
|
|
783
1131
|
root,
|
|
784
1132
|
kiln,
|
|
785
1133
|
format,
|
|
786
|
-
overlay
|
|
1134
|
+
overlay,
|
|
1135
|
+
typescript: () => typescript ??= folder ? openTypeScript(folder, warn) : Promise.resolve(null),
|
|
1136
|
+
typescriptChanged: (changes) => void typescript?.then((opened) => opened?.changed(changes)),
|
|
1137
|
+
close: () => void typescript?.then((opened) => opened?.close())
|
|
787
1138
|
};
|
|
788
1139
|
};
|
|
789
1140
|
/** Checks a template's text and returns its own diagnostics. */
|
|
@@ -791,6 +1142,525 @@ const diagnose = async (project, name, text) => {
|
|
|
791
1142
|
return (await project.kiln.check(name, text)).filter((diagnostic) => diagnostic.template === name && (project.manifest !== null || !NEEDS_MANIFEST.has(diagnostic.code)));
|
|
792
1143
|
};
|
|
793
1144
|
//#endregion
|
|
1145
|
+
//#region src/scopes.ts
|
|
1146
|
+
/** Whether a variable can be used at `offset`. A scope's end is exclusive, except at the end of the template. */
|
|
1147
|
+
const sees = (binding, offset, length) => offset >= binding.from && (offset < binding.scope.end || binding.scope.end === length);
|
|
1148
|
+
/** The offset of `name` as a whole word in `text`, searching `span` from `after`. Falls back to the span's start. */
|
|
1149
|
+
const nameIn = (text, span, name, after = span.start) => {
|
|
1150
|
+
const pattern = new RegExp(`(?<![\\w$])${name}(?![\\w$])`, "g");
|
|
1151
|
+
pattern.lastIndex = after;
|
|
1152
|
+
const match = pattern.exec(text.slice(0, span.end));
|
|
1153
|
+
const start = match ? match.index : span.start;
|
|
1154
|
+
return {
|
|
1155
|
+
start,
|
|
1156
|
+
end: start + name.length
|
|
1157
|
+
};
|
|
1158
|
+
};
|
|
1159
|
+
/** The expressions directly inside an expression. */
|
|
1160
|
+
const childExpressions = (expression) => {
|
|
1161
|
+
const ofArgs = (args) => args.map((arg) => arg.value);
|
|
1162
|
+
switch (expression.type) {
|
|
1163
|
+
case "Interpolation": return expression.parts;
|
|
1164
|
+
case "Array": return expression.elements.map((element) => element.type === "Spread" ? element.value : element);
|
|
1165
|
+
case "Hash": return expression.entries.flatMap((entry) => entry.type === "Spread" ? [entry.value] : [entry.key, entry.value]);
|
|
1166
|
+
case "Unary": return [expression.operand];
|
|
1167
|
+
case "Binary": return [expression.left, expression.right];
|
|
1168
|
+
case "Conditional": return [
|
|
1169
|
+
expression.test,
|
|
1170
|
+
expression.consequent,
|
|
1171
|
+
...expression.alternate ? [expression.alternate] : []
|
|
1172
|
+
];
|
|
1173
|
+
case "Member": return [expression.object, expression.property];
|
|
1174
|
+
case "Call": return [expression.callee, ...ofArgs(expression.args)];
|
|
1175
|
+
case "Filter":
|
|
1176
|
+
case "Test": return [expression.input, ...ofArgs(expression.args)];
|
|
1177
|
+
case "Arrow": return [expression.body];
|
|
1178
|
+
case "OptionalChain": return [expression.expression];
|
|
1179
|
+
default: return [];
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
/** The expressions a statement holds itself, not those in nested statements. */
|
|
1183
|
+
const statementExpressions = (statement) => {
|
|
1184
|
+
switch (statement.type) {
|
|
1185
|
+
case "Output": return [statement.expression];
|
|
1186
|
+
case "If": return statement.branches.map((branch) => branch.test);
|
|
1187
|
+
case "For": return [statement.iterable];
|
|
1188
|
+
case "Set": return [statement.value];
|
|
1189
|
+
case "Props": return statement.props.flatMap((prop) => prop.default ? [prop.default] : []);
|
|
1190
|
+
case "Extends": return [statement.template];
|
|
1191
|
+
case "Include":
|
|
1192
|
+
case "Component":
|
|
1193
|
+
case "Island": return [statement.template, ...statement.with ? [statement.with] : []];
|
|
1194
|
+
case "Macro": return statement.params.flatMap((param) => param.default ? [param.default] : []);
|
|
1195
|
+
case "With": return [statement.value];
|
|
1196
|
+
case "Cache": return [statement.key, ...statement.seconds ? [statement.seconds] : []];
|
|
1197
|
+
case "Apply": return statement.filters.flatMap((filter) => filter.args.map((arg) => arg.value));
|
|
1198
|
+
default: return [];
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
/** The statement lists inside a statement, and whether each starts a new scope. */
|
|
1202
|
+
const childBodies = (statement) => {
|
|
1203
|
+
switch (statement.type) {
|
|
1204
|
+
case "If": return [...statement.branches.map((branch) => ({
|
|
1205
|
+
body: branch.body,
|
|
1206
|
+
scoped: false
|
|
1207
|
+
})), ...statement.alternate ? [{
|
|
1208
|
+
body: statement.alternate,
|
|
1209
|
+
scoped: false
|
|
1210
|
+
}] : []];
|
|
1211
|
+
case "For": return [{
|
|
1212
|
+
body: statement.body,
|
|
1213
|
+
scoped: true
|
|
1214
|
+
}, ...statement.alternate ? [{
|
|
1215
|
+
body: statement.alternate,
|
|
1216
|
+
scoped: false
|
|
1217
|
+
}] : []];
|
|
1218
|
+
case "Component": return [{
|
|
1219
|
+
body: statement.body,
|
|
1220
|
+
scoped: true
|
|
1221
|
+
}, ...statement.fills.map((fill) => ({
|
|
1222
|
+
body: fill.body,
|
|
1223
|
+
scoped: true
|
|
1224
|
+
}))];
|
|
1225
|
+
case "SetBlock":
|
|
1226
|
+
case "Add":
|
|
1227
|
+
case "Cache":
|
|
1228
|
+
case "Apply":
|
|
1229
|
+
case "Autoescape":
|
|
1230
|
+
case "Island":
|
|
1231
|
+
case "Slot": return [{
|
|
1232
|
+
body: statement.body,
|
|
1233
|
+
scoped: false
|
|
1234
|
+
}];
|
|
1235
|
+
case "Block":
|
|
1236
|
+
case "Macro":
|
|
1237
|
+
case "With": return [{
|
|
1238
|
+
body: statement.body,
|
|
1239
|
+
scoped: true
|
|
1240
|
+
}];
|
|
1241
|
+
default: return [];
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
/** Every expression in a list of statements, including nested ones, outermost first. */
|
|
1245
|
+
function* expressionsIn(statements) {
|
|
1246
|
+
const visit = function* (expression) {
|
|
1247
|
+
yield expression;
|
|
1248
|
+
for (const child of childExpressions(expression)) yield* visit(child);
|
|
1249
|
+
};
|
|
1250
|
+
for (const statement of statements) {
|
|
1251
|
+
for (const expression of statementExpressions(statement)) yield* visit(expression);
|
|
1252
|
+
for (const { body } of childBodies(statement)) yield* expressionsIn(body);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/** The innermost expression whose span holds `offset`, and the expressions around it, outermost first. */
|
|
1256
|
+
const expressionPathAt = (ast, offset) => {
|
|
1257
|
+
const path = [];
|
|
1258
|
+
const visit = (expression) => {
|
|
1259
|
+
if (offset < expression.span.start || offset > expression.span.end) return false;
|
|
1260
|
+
path.push(expression);
|
|
1261
|
+
for (const child of childExpressions(expression)) if (visit(child)) return true;
|
|
1262
|
+
return true;
|
|
1263
|
+
};
|
|
1264
|
+
for (const expression of expressionsIn(ast.body)) if (visit(expression)) break;
|
|
1265
|
+
return path;
|
|
1266
|
+
};
|
|
1267
|
+
/**
|
|
1268
|
+
* Parses a template and finds every variable it brings into scope, following Kiln's rules: `for`, `with`, `block`,
|
|
1269
|
+
* `macro`, `component` and `fill` start a scope, and `set` updates a variable that is already visible or creates one in
|
|
1270
|
+
* the current scope.
|
|
1271
|
+
*/
|
|
1272
|
+
const templateScopes = (text, isTest) => {
|
|
1273
|
+
const { ast } = parse(text, { isTest });
|
|
1274
|
+
const bindings = [];
|
|
1275
|
+
const visible = (name, offset) => bindings.some((binding) => binding.name === name && sees(binding, offset, text.length));
|
|
1276
|
+
const arrows = (expression) => {
|
|
1277
|
+
for (const node of expressionsIn([{
|
|
1278
|
+
type: "Output",
|
|
1279
|
+
expression,
|
|
1280
|
+
span: expression.span
|
|
1281
|
+
}])) {
|
|
1282
|
+
if (node.type !== "Filter") continue;
|
|
1283
|
+
for (const arg of node.args) {
|
|
1284
|
+
if (arg.value.type !== "Arrow") continue;
|
|
1285
|
+
const arrow = arg.value;
|
|
1286
|
+
const head = text.slice(arrow.span.start, arrow.body.span.start);
|
|
1287
|
+
arrow.params.forEach((name, index) => {
|
|
1288
|
+
const at = nameIn(head, {
|
|
1289
|
+
start: 0,
|
|
1290
|
+
end: head.length
|
|
1291
|
+
}, name);
|
|
1292
|
+
bindings.push({
|
|
1293
|
+
name,
|
|
1294
|
+
source: {
|
|
1295
|
+
kind: "arrow",
|
|
1296
|
+
filter: node.name,
|
|
1297
|
+
input: node.input,
|
|
1298
|
+
index
|
|
1299
|
+
},
|
|
1300
|
+
definition: {
|
|
1301
|
+
start: arrow.span.start + at.start,
|
|
1302
|
+
end: arrow.span.start + at.end
|
|
1303
|
+
},
|
|
1304
|
+
scope: arrow.span,
|
|
1305
|
+
from: arrow.span.start
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
const walk = (statements, scope) => {
|
|
1312
|
+
for (const statement of statements) {
|
|
1313
|
+
for (const expression of statementExpressions(statement)) arrows(expression);
|
|
1314
|
+
const add = (name, source, definition, inner = scope, from = statement.span.end) => bindings.push({
|
|
1315
|
+
name,
|
|
1316
|
+
source,
|
|
1317
|
+
definition,
|
|
1318
|
+
scope: inner,
|
|
1319
|
+
from
|
|
1320
|
+
});
|
|
1321
|
+
switch (statement.type) {
|
|
1322
|
+
case "Props":
|
|
1323
|
+
for (const prop of statement.props) add(prop.name, {
|
|
1324
|
+
kind: "prop",
|
|
1325
|
+
type: prop.type,
|
|
1326
|
+
optional: prop.default !== null
|
|
1327
|
+
}, {
|
|
1328
|
+
start: prop.span.start,
|
|
1329
|
+
end: prop.span.start + prop.name.length
|
|
1330
|
+
}, scope, scope.start);
|
|
1331
|
+
if (statement.rest) {
|
|
1332
|
+
const rest = statement.rest;
|
|
1333
|
+
add(rest, { kind: "rest" }, nameIn(text, statement.span, rest), scope, scope.start);
|
|
1334
|
+
}
|
|
1335
|
+
break;
|
|
1336
|
+
case "Set":
|
|
1337
|
+
case "SetBlock":
|
|
1338
|
+
if (!visible(statement.name, statement.span.start)) {
|
|
1339
|
+
const after = text.indexOf("set", statement.span.start) + 3;
|
|
1340
|
+
add(statement.name, statement.type === "Set" ? {
|
|
1341
|
+
kind: "set",
|
|
1342
|
+
value: statement.value
|
|
1343
|
+
} : { kind: "capture" }, nameIn(text, statement.span, statement.name, after));
|
|
1344
|
+
}
|
|
1345
|
+
break;
|
|
1346
|
+
case "For": {
|
|
1347
|
+
const after = text.indexOf("for", statement.span.start) + 3;
|
|
1348
|
+
const from = statement.iterable.span.end;
|
|
1349
|
+
if (statement.keyName) add(statement.keyName, {
|
|
1350
|
+
kind: "key",
|
|
1351
|
+
iterable: statement.iterable
|
|
1352
|
+
}, nameIn(text, statement.span, statement.keyName, after), statement.span, from);
|
|
1353
|
+
add(statement.valueName, {
|
|
1354
|
+
kind: "item",
|
|
1355
|
+
iterable: statement.iterable
|
|
1356
|
+
}, nameIn(text, statement.span, statement.valueName, statement.keyName ? nameIn(text, statement.span, statement.keyName, after).end : after), statement.span, from);
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
case "Macro":
|
|
1360
|
+
for (const param of statement.params) add(param.name, { kind: "param" }, {
|
|
1361
|
+
start: param.span.start,
|
|
1362
|
+
end: param.span.start + param.name.length
|
|
1363
|
+
}, statement.span, param.span.end);
|
|
1364
|
+
break;
|
|
1365
|
+
case "With":
|
|
1366
|
+
if (statement.value.type === "Hash") for (const entry of statement.value.entries) {
|
|
1367
|
+
if (entry.type !== "Property" || entry.key.type !== "Literal" || typeof entry.key.value !== "string") continue;
|
|
1368
|
+
add(entry.key.value, {
|
|
1369
|
+
kind: "with",
|
|
1370
|
+
value: statement.value,
|
|
1371
|
+
key: entry.key.value
|
|
1372
|
+
}, entry.key.span, statement.span, statement.value.span.end);
|
|
1373
|
+
}
|
|
1374
|
+
break;
|
|
1375
|
+
case "Import": {
|
|
1376
|
+
const after = text.lastIndexOf(" as ", statement.span.end);
|
|
1377
|
+
add(statement.alias, { kind: "import" }, nameIn(text, statement.span, statement.alias, Math.max(after, 0)));
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
case "FromImport": for (const item of statement.names) add(item.alias, { kind: "import" }, nameIn(text, item.span, item.alias, item.span.start));
|
|
1381
|
+
}
|
|
1382
|
+
for (const { body, scoped } of childBodies(statement)) walk(body, scoped ? statement.span : scope);
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
walk(ast.body, ast.span);
|
|
1386
|
+
return {
|
|
1387
|
+
ast,
|
|
1388
|
+
bindings
|
|
1389
|
+
};
|
|
1390
|
+
};
|
|
1391
|
+
/** The variable `name` refers to at `offset`: the one in the innermost scope that can see it there. */
|
|
1392
|
+
const bindingAt = (scopes, name, offset) => {
|
|
1393
|
+
let found = null;
|
|
1394
|
+
for (const binding of scopes.bindings) {
|
|
1395
|
+
if (binding.name !== name || !sees(binding, offset, scopes.ast.span.end)) continue;
|
|
1396
|
+
if (!found || binding.scope.end - binding.scope.start < found.scope.end - found.scope.start) found = binding;
|
|
1397
|
+
}
|
|
1398
|
+
return found;
|
|
1399
|
+
};
|
|
1400
|
+
/** Every variable visible at `offset`, innermost first, one per name. */
|
|
1401
|
+
const bindingsAt = (scopes, offset) => {
|
|
1402
|
+
return [...new Set(scopes.bindings.map((binding) => binding.name))].flatMap((name) => {
|
|
1403
|
+
const binding = bindingAt(scopes, name, offset);
|
|
1404
|
+
return binding ? [binding] : [];
|
|
1405
|
+
});
|
|
1406
|
+
};
|
|
1407
|
+
//#endregion
|
|
1408
|
+
//#region src/types.ts
|
|
1409
|
+
/**
|
|
1410
|
+
* Kiln's rules for values, written as TypeScript helper types. Each Kiln expression becomes a type built from these,
|
|
1411
|
+
* and the project's own TypeScript works out what it is.
|
|
1412
|
+
*
|
|
1413
|
+
* - `Get`: `a.b` and `a[0]`, reading a property, a map's value or a list's item.
|
|
1414
|
+
* - `Item`, `Key`: what `for` gives for each pass.
|
|
1415
|
+
* - `Result`: what a call gives once Kiln waits for it. `Params` and `Returns` describe the function for hover.
|
|
1416
|
+
* - `Members`: the values whose properties a template can read, so lists, maps and primitives complete nothing.
|
|
1417
|
+
* - `Last`: a function's last parameter, which named arguments are gathered into.
|
|
1418
|
+
* - `Fits`, `NamedFit`: whether an argument fits its parameter. Unknown types always fit, so the editor never guesses.
|
|
1419
|
+
*/
|
|
1420
|
+
const PRELUDE = `import type { KilnFilters, KilnFunctions, KilnGlobals, KilnTests, KilnTypes, Markup, RenderAware } from "@lucidkiln/core";
|
|
1421
|
+
type Named<N extends string> = N extends keyof KilnTypes ? KilnTypes[N] : unknown;
|
|
1422
|
+
type Get<V, K> = NonNullable<V> extends infer O ? O extends ReadonlyMap<unknown, infer M> ? M : O extends readonly (infer I)[] ? (K extends number ? I : unknown) : K extends keyof O ? O[K] : unknown : never;
|
|
1423
|
+
type Item<V> = NonNullable<V> extends infer O ? O extends string ? unknown : O extends readonly (infer I)[] ? I : O extends ReadonlyMap<unknown, infer M> ? M : O extends ReadonlySet<infer S> ? S : O extends Iterable<infer I> ? I : O extends object ? O[keyof O] : unknown : never;
|
|
1424
|
+
type Key<V> = NonNullable<V> extends infer O ? O extends string ? unknown : O extends readonly unknown[] | ReadonlySet<unknown> ? number : O extends ReadonlyMap<infer K, unknown> ? K : O extends object ? keyof O & string : unknown : never;
|
|
1425
|
+
type Returns<F> = F extends RenderAware<any, any, infer R> ? R : F extends (...args: any) => infer R ? R : unknown;
|
|
1426
|
+
type Result<F> = Awaited<Returns<F>>;
|
|
1427
|
+
type Params<F> = F extends RenderAware<any, infer A, any> ? A : F extends (...args: infer A) => any ? A : never;
|
|
1428
|
+
type Tail<A> = A extends readonly [unknown?, ...infer R] ? R : A;
|
|
1429
|
+
type Last<A> = Required<A> extends readonly [...unknown[], infer L] ? L : A extends readonly (infer E)[] ? E : unknown;
|
|
1430
|
+
type Fits<A, P> = [P] extends [never] ? true : unknown extends P ? true : unknown extends A ? true : [Exclude<A, Markup>] extends [P] ? true : false;
|
|
1431
|
+
type NamedFit<L, K, A> = unknown extends L ? "ok" : [L] extends [never] ? "ok" : NonNullable<L> extends infer O ? string extends keyof O ? "ok" : K extends keyof O ? Fits<A, Exclude<O[K], undefined>> extends true ? "ok" : "mismatch" : "missing" : "ok";
|
|
1432
|
+
type Members<V> = NonNullable<V> extends infer O ? O extends string | number | boolean | bigint | symbol | Function | Date | Markup | readonly unknown[] | ReadonlyMap<unknown, unknown> | ReadonlySet<unknown> ? never : O : never;
|
|
1433
|
+
`;
|
|
1434
|
+
const BUILTIN_TYPES = {
|
|
1435
|
+
null: "null",
|
|
1436
|
+
boolean: "boolean",
|
|
1437
|
+
number: "number",
|
|
1438
|
+
string: "string | Markup",
|
|
1439
|
+
array: "readonly unknown[]",
|
|
1440
|
+
hash: "Readonly<Record<string, unknown>> | ReadonlyMap<string, unknown>",
|
|
1441
|
+
set: "ReadonlySet<unknown>",
|
|
1442
|
+
date: "Date",
|
|
1443
|
+
markup: "Markup",
|
|
1444
|
+
object: "object"
|
|
1445
|
+
};
|
|
1446
|
+
/** A prop's declared type as TypeScript, the same way `generateTypes` writes it. */
|
|
1447
|
+
const propType = (type) => {
|
|
1448
|
+
if (type === null) return "unknown";
|
|
1449
|
+
if (type.length === 0) return "never";
|
|
1450
|
+
return type.map((member) => {
|
|
1451
|
+
if (typeof member === "string") return BUILTIN_TYPES[member] ?? "unknown";
|
|
1452
|
+
if ("literal" in member) return JSON.stringify(member.literal);
|
|
1453
|
+
if ("named" in member) return `Named<${JSON.stringify(member.named)}>`;
|
|
1454
|
+
if ("list" in member) return `readonly (${propType(member.list)})[]`;
|
|
1455
|
+
if (member.shape.length === 0) return "object";
|
|
1456
|
+
return `{ ${member.shape.map((key) => `${JSON.stringify(key.name)}${key.optional ? "?" : ""}: ${propType(key.type)}`).join("; ")} }`;
|
|
1457
|
+
}).join(" | ");
|
|
1458
|
+
};
|
|
1459
|
+
const BUILTIN_FUNCTIONS = {
|
|
1460
|
+
range: "number[]",
|
|
1461
|
+
max: "number",
|
|
1462
|
+
min: "number",
|
|
1463
|
+
cycle: "unknown",
|
|
1464
|
+
date: "Date",
|
|
1465
|
+
attrs: "Markup",
|
|
1466
|
+
dump: "Markup",
|
|
1467
|
+
parent: "Markup",
|
|
1468
|
+
block: "Markup"
|
|
1469
|
+
};
|
|
1470
|
+
/** What each built-in filter gives, from the type of the value before the `|` and the types of its arguments. */
|
|
1471
|
+
const BUILTIN_FILTERS = {
|
|
1472
|
+
upper: () => "string",
|
|
1473
|
+
lower: () => "string",
|
|
1474
|
+
capitalize: () => "string",
|
|
1475
|
+
title: () => "string",
|
|
1476
|
+
trim: () => "string",
|
|
1477
|
+
replace: () => "string",
|
|
1478
|
+
split: () => "string[]",
|
|
1479
|
+
truncate: () => "string",
|
|
1480
|
+
stripTags: () => "string",
|
|
1481
|
+
urlEncode: () => "string",
|
|
1482
|
+
json: () => "Markup",
|
|
1483
|
+
escape: () => "Markup",
|
|
1484
|
+
raw: () => "Markup",
|
|
1485
|
+
default: (input, [fallback = "string"]) => `NonNullable<${input}> | ${fallback}`,
|
|
1486
|
+
length: () => "number",
|
|
1487
|
+
first: (input) => `Item<${input}>`,
|
|
1488
|
+
last: (input) => `Item<${input}>`,
|
|
1489
|
+
slice: (input) => input,
|
|
1490
|
+
join: () => "string",
|
|
1491
|
+
keys: (input) => `Key<${input}>[]`,
|
|
1492
|
+
merge: (input) => input,
|
|
1493
|
+
reverse: (input) => input,
|
|
1494
|
+
sort: (input) => input,
|
|
1495
|
+
map: () => "unknown[]",
|
|
1496
|
+
filter: (input) => input,
|
|
1497
|
+
reduce: () => "unknown",
|
|
1498
|
+
find: (input) => `Item<${input}> | null`,
|
|
1499
|
+
column: () => "unknown[]",
|
|
1500
|
+
batch: (input) => `Item<${input}>[][]`,
|
|
1501
|
+
abs: () => "number",
|
|
1502
|
+
round: () => "number",
|
|
1503
|
+
formatNumber: () => "string",
|
|
1504
|
+
plural: () => "string",
|
|
1505
|
+
formatDate: () => "string"
|
|
1506
|
+
};
|
|
1507
|
+
/** The list item an arrow function's parameter gets, by filter: which parameter it is. */
|
|
1508
|
+
const ITEM_PARAMS = {
|
|
1509
|
+
map: [0],
|
|
1510
|
+
filter: [0],
|
|
1511
|
+
find: [0],
|
|
1512
|
+
sort: [0, 1],
|
|
1513
|
+
reduce: [1]
|
|
1514
|
+
};
|
|
1515
|
+
const MAX_DEPTH = 32;
|
|
1516
|
+
/** The TypeScript type of a variable, from where its value comes from. */
|
|
1517
|
+
const bindingType = (source, context, depth = 0) => {
|
|
1518
|
+
const of = (expression) => expressionType(expression, context, depth + 1);
|
|
1519
|
+
switch (source.kind) {
|
|
1520
|
+
case "prop": return propType(source.type);
|
|
1521
|
+
case "rest": return "Readonly<Record<string, unknown>>";
|
|
1522
|
+
case "set": return of(source.value);
|
|
1523
|
+
case "capture": return "Markup";
|
|
1524
|
+
case "item": return `Item<${of(source.iterable)}>`;
|
|
1525
|
+
case "key": return `Key<${of(source.iterable)}>`;
|
|
1526
|
+
case "arrow": return ITEM_PARAMS[source.filter]?.includes(source.index) ? `Item<${of(source.input)}>` : "unknown";
|
|
1527
|
+
case "with": return `Get<${of(source.value)}, ${JSON.stringify(source.key)}>`;
|
|
1528
|
+
default: return "unknown";
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
const union = (types) => types.length === 0 ? "never" : [...new Set(types)].join(" | ");
|
|
1532
|
+
const literalKey = (expression) => expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") ? JSON.stringify(expression.value) : null;
|
|
1533
|
+
/** Writes the TypeScript type of a Kiln expression, using the helper types in `PRELUDE`. */
|
|
1534
|
+
const expressionType = (expression, context, depth = 0) => {
|
|
1535
|
+
if (depth > MAX_DEPTH) return "unknown";
|
|
1536
|
+
const of = (inner) => expressionType(inner, context, depth + 1);
|
|
1537
|
+
const args = (list) => list.map((arg) => arg.type === "Spread" ? "unknown" : of(arg.value));
|
|
1538
|
+
switch (expression.type) {
|
|
1539
|
+
case "Literal": return expression.value === null ? "null" : typeof expression.value;
|
|
1540
|
+
case "Interpolation": return "string";
|
|
1541
|
+
case "Array": return `(${union(expression.elements.map((element) => element.type === "Spread" ? `Item<${of(element.value)}>` : of(element)))})[]`;
|
|
1542
|
+
case "Hash": return `{ ${expression.entries.flatMap((entry) => {
|
|
1543
|
+
if (entry.type === "Spread") return [];
|
|
1544
|
+
const key = entry.key.type === "Name" ? JSON.stringify(entry.key.name) : literalKey(entry.key);
|
|
1545
|
+
return key ? [`${key}: ${of(entry.value)}`] : [];
|
|
1546
|
+
}).join("; ")} }`;
|
|
1547
|
+
case "Unary": return expression.operator === "not" ? "boolean" : "number";
|
|
1548
|
+
case "Binary": switch (expression.operator) {
|
|
1549
|
+
case "??":
|
|
1550
|
+
case "?:": return `NonNullable<${of(expression.left)}> | ${of(expression.right)}`;
|
|
1551
|
+
case "~": return "string";
|
|
1552
|
+
case "..": return "number[]";
|
|
1553
|
+
case "+":
|
|
1554
|
+
case "-":
|
|
1555
|
+
case "*":
|
|
1556
|
+
case "/":
|
|
1557
|
+
case "//":
|
|
1558
|
+
case "%":
|
|
1559
|
+
case "**": return "number";
|
|
1560
|
+
default: return "boolean";
|
|
1561
|
+
}
|
|
1562
|
+
case "Conditional": return union([of(expression.consequent), expression.alternate ? of(expression.alternate) : "null"]);
|
|
1563
|
+
case "Member": {
|
|
1564
|
+
const key = literalKey(expression.property) ?? "string";
|
|
1565
|
+
return `Get<${of(expression.object)}, ${key}>`;
|
|
1566
|
+
}
|
|
1567
|
+
case "Call": {
|
|
1568
|
+
const { callee } = expression;
|
|
1569
|
+
if (callee.type === "Member") {
|
|
1570
|
+
const key = literalKey(callee.property) ?? "string";
|
|
1571
|
+
return `Result<Get<${of(callee.object)}, ${key}>>`;
|
|
1572
|
+
}
|
|
1573
|
+
if (callee.type !== "Name") return "unknown";
|
|
1574
|
+
const name = callee.name;
|
|
1575
|
+
if (context.macros.has(name)) return "Markup";
|
|
1576
|
+
const builtin = BUILTIN_FUNCTIONS[name];
|
|
1577
|
+
if (builtin && !context.functions.includes(name)) return builtin;
|
|
1578
|
+
return `Result<Get<KilnFunctions, ${JSON.stringify(name)}>>`;
|
|
1579
|
+
}
|
|
1580
|
+
case "Filter": {
|
|
1581
|
+
const input = of(expression.input);
|
|
1582
|
+
const builtin = BUILTIN_FILTERS[expression.name];
|
|
1583
|
+
if (!builtin || context.filters.includes(expression.name)) return `Result<Get<KilnFilters, ${JSON.stringify(expression.name)}>>`;
|
|
1584
|
+
const arrow = expression.args[0]?.value;
|
|
1585
|
+
if (expression.name === "map" && arrow?.type === "Arrow") return `(${of(arrow.body)})[]`;
|
|
1586
|
+
return builtin(input, args(expression.args));
|
|
1587
|
+
}
|
|
1588
|
+
case "Test": return "boolean";
|
|
1589
|
+
case "OptionalChain": return of(expression.expression);
|
|
1590
|
+
case "Name": {
|
|
1591
|
+
if (expression.name === "loop") return "unknown";
|
|
1592
|
+
const binding = bindingAt(context.scopes, expression.name, expression.span.start);
|
|
1593
|
+
if (binding) return bindingType(binding.source, context, depth + 1);
|
|
1594
|
+
return `Get<KilnGlobals, ${JSON.stringify(expression.name)}>`;
|
|
1595
|
+
}
|
|
1596
|
+
default: return "unknown";
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
/** The registries a project describes in TypeScript, by the kind of name the language server looks up. */
|
|
1600
|
+
const REGISTRIES = {
|
|
1601
|
+
function: "KilnFunctions",
|
|
1602
|
+
filter: "KilnFilters",
|
|
1603
|
+
test: "KilnTests",
|
|
1604
|
+
global: "KilnGlobals"
|
|
1605
|
+
};
|
|
1606
|
+
/** Checks a file of `type name = type` aliases with the project's TypeScript, and reads it with `use`. */
|
|
1607
|
+
const withTypes = (typescript, aliases, use) => {
|
|
1608
|
+
const source = PRELUDE + Object.entries(aliases).map(([name, type]) => `type ${name} = ${type};\n`).join("");
|
|
1609
|
+
return typescript.query(source, use);
|
|
1610
|
+
};
|
|
1611
|
+
/** The TypeScript type of a registered function, filter or test, by kind and name. */
|
|
1612
|
+
const callableType = (kind, name) => `Get<${REGISTRIES[kind]}, ${JSON.stringify(name)}>`;
|
|
1613
|
+
/** Its parameters as a tuple: a filter's first parameter is the value before the `|`, so `withInput` keeps it. */
|
|
1614
|
+
const parametersType = (kind, name, withInput = false) => {
|
|
1615
|
+
const params = `Params<${callableType(kind, name)}>`;
|
|
1616
|
+
return kind === "function" || withInput ? params : `Tail<${params}>`;
|
|
1617
|
+
};
|
|
1618
|
+
/** A host function, filter or test as hover shows it, such as `documents(collection: string): Promise<Page[]>`. */
|
|
1619
|
+
const describeCallable = (typescript, kind, name) => withTypes(typescript, {
|
|
1620
|
+
Registry: REGISTRIES[kind],
|
|
1621
|
+
Parameters: parametersType(kind, name),
|
|
1622
|
+
Return: `Returns<${callableType(kind, name)}>`
|
|
1623
|
+
}, (read) => {
|
|
1624
|
+
const member = read.members("Registry").find((item) => item.name === name);
|
|
1625
|
+
const parameters = read.describe("Parameters");
|
|
1626
|
+
const result = read.describe("Return");
|
|
1627
|
+
if (!member || !parameters || !result) return null;
|
|
1628
|
+
const list = parameters.replace(/^\[|\]$/g, "");
|
|
1629
|
+
const prefix = `${kind === "filter" ? "|" : kind === "test" ? "is " : ""}${name}(`;
|
|
1630
|
+
return {
|
|
1631
|
+
label: `${prefix}${list}): ${result}`,
|
|
1632
|
+
parameters: splitParameters(list).map((part) => ({
|
|
1633
|
+
name: /^(?:\.\.\.)?([\w$]+)/.exec(part.text)?.[1] ?? part.text,
|
|
1634
|
+
start: prefix.length + part.start,
|
|
1635
|
+
end: prefix.length + part.end
|
|
1636
|
+
})),
|
|
1637
|
+
member
|
|
1638
|
+
};
|
|
1639
|
+
});
|
|
1640
|
+
/** The names a function or filter takes as named arguments: the properties of its last parameter. */
|
|
1641
|
+
const namedOptions = (typescript, kind, name) => withTypes(typescript, { Options: `Members<Last<${parametersType(kind, name)}>>` }, (read) => read.members("Options")) ?? [];
|
|
1642
|
+
/** Checks arguments against their parameters with one TypeScript query. Null for an argument that fits. */
|
|
1643
|
+
const checkArguments = (typescript, checks) => {
|
|
1644
|
+
if (checks.length === 0) return [];
|
|
1645
|
+
const aliases = {};
|
|
1646
|
+
checks.forEach((check, index) => {
|
|
1647
|
+
const expected = "index" in check ? `Required<${check.parameters}>[${check.index}]` : `Exclude<Get<Last<${check.parameters}>, ${JSON.stringify(check.name)}>, undefined>`;
|
|
1648
|
+
aliases[`Fit${index}`] = "index" in check ? `Fits<${check.type}, ${expected}> extends true ? "ok" : "mismatch"` : `NamedFit<Last<${check.parameters}>, ${JSON.stringify(check.name)}, ${check.type}>`;
|
|
1649
|
+
aliases[`Expected${index}`] = expected;
|
|
1650
|
+
aliases[`Actual${index}`] = check.type;
|
|
1651
|
+
});
|
|
1652
|
+
return withTypes(typescript, aliases, (read) => checks.map((_, index) => {
|
|
1653
|
+
const fit = read.describe(`Fit${index}`);
|
|
1654
|
+
if (fit === "\"missing\"") return { kind: "missing" };
|
|
1655
|
+
if (fit !== "\"mismatch\"") return null;
|
|
1656
|
+
return {
|
|
1657
|
+
kind: "mismatch",
|
|
1658
|
+
expected: read.describe(`Expected${index}`) ?? "unknown",
|
|
1659
|
+
actual: read.describe(`Actual${index}`) ?? "unknown"
|
|
1660
|
+
};
|
|
1661
|
+
})) ?? checks.map(() => null);
|
|
1662
|
+
};
|
|
1663
|
+
//#endregion
|
|
794
1664
|
//#region src/features.ts
|
|
795
1665
|
const analyse = async (project, name, text) => {
|
|
796
1666
|
try {
|
|
@@ -833,21 +1703,62 @@ const builtinItems = (builtins, docKind, kind, extra) => [...Object.entries(buil
|
|
|
833
1703
|
kind,
|
|
834
1704
|
detail: "From your project"
|
|
835
1705
|
}))];
|
|
836
|
-
/**
|
|
837
|
-
const
|
|
838
|
-
const
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1706
|
+
/** Parses a template and finds its variables, with what `expressionType` needs to work out their types. */
|
|
1707
|
+
const templateTypes = (open, text) => {
|
|
1708
|
+
const manifest = open.project.manifest;
|
|
1709
|
+
const scopes = templateScopes(text, (name) => name in TESTS || (manifest?.tests.includes(name) ?? false));
|
|
1710
|
+
const macros = /* @__PURE__ */ new Set([...macrosIn(text).keys(), ...scopes.bindings.filter((binding) => binding.source.kind === "import").map((binding) => binding.name)]);
|
|
1711
|
+
return {
|
|
1712
|
+
scopes,
|
|
1713
|
+
context: {
|
|
1714
|
+
scopes,
|
|
1715
|
+
functions: manifest?.functions ?? [],
|
|
1716
|
+
filters: manifest?.filters ?? [],
|
|
1717
|
+
macros
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
};
|
|
1721
|
+
/** The property under the cursor in `a.b`, with the expression it's read from. */
|
|
1722
|
+
const propertyAt = (scopes, text, offset) => {
|
|
1723
|
+
const path = expressionPathAt(scopes.ast, offset);
|
|
1724
|
+
for (let index = path.length - 1; index >= 0; index--) {
|
|
1725
|
+
const node = path[index];
|
|
1726
|
+
if (node.type !== "Member") continue;
|
|
1727
|
+
const { property } = node;
|
|
1728
|
+
if (property.type === "Literal" && typeof property.value === "string" && offset >= property.span.start && offset <= property.span.end && text[property.span.start - 1] === ".") return {
|
|
1729
|
+
name: property.value,
|
|
1730
|
+
object: node.object,
|
|
1731
|
+
span: property.span
|
|
1732
|
+
};
|
|
849
1733
|
}
|
|
850
|
-
return
|
|
1734
|
+
return null;
|
|
1735
|
+
};
|
|
1736
|
+
/** A member's doc comment and tags, as Markdown. */
|
|
1737
|
+
const memberDocs = (member) => [member.docs, ...member.tags.map((tag) => `*@${tag.name}* ${tag.text}`.trim())].filter(Boolean).join("\n\n");
|
|
1738
|
+
/** The properties TypeScript finds on the value of `object`. Empty without TypeScript. */
|
|
1739
|
+
const membersOf = async (open, object, context) => {
|
|
1740
|
+
const typescript = await open.project.typescript();
|
|
1741
|
+
if (!typescript) return [];
|
|
1742
|
+
const type = expressionType(object, context);
|
|
1743
|
+
return withTypes(typescript, { Value: `Members<${type}>` }, (read) => read.members("Value")) ?? [];
|
|
1744
|
+
};
|
|
1745
|
+
/** A function, filter, test or global the project describes in TypeScript. */
|
|
1746
|
+
const registered = async (open, kind, name) => {
|
|
1747
|
+
const typescript = await open.project.typescript();
|
|
1748
|
+
if (!typescript) return null;
|
|
1749
|
+
return withTypes(typescript, { Registry: REGISTRIES[kind] }, (read) => read.members("Registry").find((member) => member.name === name)) ?? null;
|
|
1750
|
+
};
|
|
1751
|
+
const BINDING_NOTES = {
|
|
1752
|
+
prop: "A prop of this template.",
|
|
1753
|
+
rest: "The variables passed to this template that it doesn't declare.",
|
|
1754
|
+
set: "Set in this template.",
|
|
1755
|
+
capture: "Set in this template, from a `set` block.",
|
|
1756
|
+
item: "A loop variable.",
|
|
1757
|
+
key: "A loop variable.",
|
|
1758
|
+
arrow: "An arrow function's parameter.",
|
|
1759
|
+
with: "From the `with` tag.",
|
|
1760
|
+
param: "A macro argument.",
|
|
1761
|
+
import: "Imported macros."
|
|
851
1762
|
};
|
|
852
1763
|
const complete = async (open, offset) => {
|
|
853
1764
|
const { document, project, name } = open;
|
|
@@ -900,6 +1811,22 @@ const complete = async (open, offset) => {
|
|
|
900
1811
|
});
|
|
901
1812
|
case "filter": return builtinItems(FILTERS, "filters", CompletionItemKind.Function, manifest?.filters ?? []);
|
|
902
1813
|
case "test": return builtinItems(TESTS, "tests", CompletionItemKind.Function, manifest?.tests ?? []);
|
|
1814
|
+
case "member": {
|
|
1815
|
+
const patched = context.start === offset ? `${text.slice(0, offset)}__kiln__${text.slice(offset)}` : text;
|
|
1816
|
+
const types = templateTypes(open, patched);
|
|
1817
|
+
const property = propertyAt(types.scopes, patched, context.start);
|
|
1818
|
+
if (!property) return [];
|
|
1819
|
+
return (await membersOf(open, property.object, types.context)).map((member) => ({
|
|
1820
|
+
label: member.name,
|
|
1821
|
+
kind: CompletionItemKind.Property,
|
|
1822
|
+
detail: `${member.name}${member.optional ? "?" : ""}: ${member.type}`,
|
|
1823
|
+
documentation: memberDocs(member) ? markdown(memberDocs(member)) : void 0,
|
|
1824
|
+
textEdit: {
|
|
1825
|
+
range: replace(),
|
|
1826
|
+
newText: member.name
|
|
1827
|
+
}
|
|
1828
|
+
}));
|
|
1829
|
+
}
|
|
903
1830
|
case "loop": return Object.entries(LOOP_PROPERTIES).map(([label, description]) => ({
|
|
904
1831
|
label,
|
|
905
1832
|
kind: CompletionItemKind.Property,
|
|
@@ -916,14 +1843,17 @@ const complete = async (open, offset) => {
|
|
|
916
1843
|
}
|
|
917
1844
|
case "expression": {
|
|
918
1845
|
const analysis = name ? await analyse(project, name, text) : null;
|
|
1846
|
+
const { scopes } = templateTypes(open, text);
|
|
919
1847
|
const variables = /* @__PURE__ */ new Set([
|
|
920
1848
|
...analysis?.props.map((prop) => prop.name) ?? [],
|
|
921
1849
|
...analysis?.rest ? [analysis.rest] : [],
|
|
922
|
-
...
|
|
1850
|
+
...bindingsAt(scopes, offset).map((binding) => binding.name),
|
|
1851
|
+
...macrosIn(text).keys(),
|
|
923
1852
|
...manifest?.globals ?? []
|
|
924
1853
|
]);
|
|
925
1854
|
const props = new Map(analysis?.props.map((prop) => [prop.name, prop]));
|
|
926
1855
|
return [
|
|
1856
|
+
...await namedArgumentItems(open, text, offset),
|
|
927
1857
|
...[...variables].map((label) => {
|
|
928
1858
|
const prop = props.get(label);
|
|
929
1859
|
return {
|
|
@@ -933,7 +1863,7 @@ const complete = async (open, offset) => {
|
|
|
933
1863
|
};
|
|
934
1864
|
}),
|
|
935
1865
|
...builtinItems(FUNCTIONS, "functions", CompletionItemKind.Function, manifest?.functions ?? []),
|
|
936
|
-
...KEYWORDS.map((label) => ({
|
|
1866
|
+
...KEYWORDS$1.map((label) => ({
|
|
937
1867
|
label,
|
|
938
1868
|
kind: CompletionItemKind.Keyword
|
|
939
1869
|
}))
|
|
@@ -946,21 +1876,51 @@ const macrosIn = (text) => new Map([...text.matchAll(/\{%-?\s*macro\s+(\w+)\s*\(
|
|
|
946
1876
|
params: match[2].trim(),
|
|
947
1877
|
start: match.index
|
|
948
1878
|
}]));
|
|
949
|
-
const codeBlock = (code) =>
|
|
1879
|
+
const codeBlock = (code, language = "kiln") => `\`\`\`${language}\n${code}\n\`\`\``;
|
|
950
1880
|
/** A hover's parts, such as a signature and its docs, with a rule between each. Missing parts are left out. */
|
|
951
1881
|
const sections = (...parts) => parts.filter(Boolean).join("\n\n---\n\n");
|
|
952
1882
|
/** A tag's syntax, from its completion snippet: named placeholders become their names, and the rest become `…`. */
|
|
953
1883
|
const tagSyntax = (snippet) => `{% ${snippet.replace(/\$\{\d+:([^}]*)\}/g, "$1").replace(/\$\{?\d+\}?/g, "…")}`;
|
|
1884
|
+
/** A registered function, filter or test as the project's TypeScript describes it: its signature, then its docs. */
|
|
1885
|
+
const describedCallable = async (open, kind, name) => {
|
|
1886
|
+
const typescript = await open.project.typescript();
|
|
1887
|
+
const described = typescript ? describeCallable(typescript, kind, name) : null;
|
|
1888
|
+
return described ? sections(codeBlock(described.label, "ts"), memberDocs(described.member)) : null;
|
|
1889
|
+
};
|
|
1890
|
+
/** The variable a name refers to: the one it declares when the cursor is on a declaration, or the one in scope. */
|
|
1891
|
+
const bindingFor = (scopes, symbol) => scopes.bindings.find((binding) => binding.name === symbol.name && symbol.start >= binding.definition.start && symbol.start < binding.definition.end) ?? bindingAt(scopes, symbol.name, symbol.start);
|
|
1892
|
+
/** The property under the cursor in `a.b`, and what TypeScript knows about it. */
|
|
1893
|
+
const memberAt = async (open, text, offset) => {
|
|
1894
|
+
const types = templateTypes(open, text);
|
|
1895
|
+
const property = propertyAt(types.scopes, text, offset);
|
|
1896
|
+
if (!property) return null;
|
|
1897
|
+
const member = (await membersOf(open, property.object, types.context)).find((item) => item.name === property.name);
|
|
1898
|
+
return member ? {
|
|
1899
|
+
member,
|
|
1900
|
+
span: property.span
|
|
1901
|
+
} : null;
|
|
1902
|
+
};
|
|
954
1903
|
const hover = async (open, offset) => {
|
|
955
1904
|
const { document, project, name } = open;
|
|
956
1905
|
const text = document.getText();
|
|
957
1906
|
const symbol = symbolAt(text, offset);
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1907
|
+
const rangeOf = (span) => ({
|
|
1908
|
+
start: document.positionAt(span.start),
|
|
1909
|
+
end: document.positionAt(span.end)
|
|
1910
|
+
});
|
|
1911
|
+
if (!symbol) {
|
|
1912
|
+
if (!enclosingDelimiter(text, offset)) return htmlHover(document, offset);
|
|
1913
|
+
const found = await memberAt(open, text, offset);
|
|
1914
|
+
if (!found) return null;
|
|
1915
|
+
const { member } = found;
|
|
1916
|
+
return {
|
|
1917
|
+
contents: markdown(sections(codeBlock(`${member.name}${member.optional ? "?" : ""}: ${member.type}`, "ts"), memberDocs(member))),
|
|
1918
|
+
range: rangeOf(found.span)
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
963
1921
|
const fromProject = (names, what) => names?.includes(symbol.name) ? `A ${what} your project registers.` : null;
|
|
1922
|
+
/** Whether the project registers a name, which replaces a built-in with the same name. */
|
|
1923
|
+
const registers = (names) => names?.includes(symbol.name) ?? false;
|
|
964
1924
|
let value = null;
|
|
965
1925
|
/** A built-in's signature, then its docs. */
|
|
966
1926
|
const builtin = (signature, kind) => sections(codeBlock(signature), docsFor(kind, symbol.name));
|
|
@@ -973,12 +1933,12 @@ const hover = async (open, offset) => {
|
|
|
973
1933
|
}
|
|
974
1934
|
case "filter": {
|
|
975
1935
|
const doc = FILTERS[symbol.name];
|
|
976
|
-
value = doc ? builtin(`|${doc.signature}`, "filters") : fromProject(project.manifest?.filters, "filter");
|
|
1936
|
+
value = doc && !registers(project.manifest?.filters) ? builtin(`|${doc.signature}`, "filters") : await describedCallable(open, "filter", symbol.name) ?? fromProject(project.manifest?.filters, "filter");
|
|
977
1937
|
break;
|
|
978
1938
|
}
|
|
979
1939
|
case "test": {
|
|
980
1940
|
const doc = TESTS[symbol.name];
|
|
981
|
-
value = doc ? builtin(`is ${doc.signature}`, "tests") : fromProject(project.manifest?.tests, "test");
|
|
1941
|
+
value = doc && !registers(project.manifest?.tests) ? builtin(`is ${doc.signature}`, "tests") : await describedCallable(open, "test", symbol.name) ?? fromProject(project.manifest?.tests, "test");
|
|
982
1942
|
break;
|
|
983
1943
|
}
|
|
984
1944
|
case "loop": {
|
|
@@ -990,16 +1950,24 @@ const hover = async (open, offset) => {
|
|
|
990
1950
|
const macro = macrosIn(text).get(symbol.name);
|
|
991
1951
|
const doc = FUNCTIONS[symbol.name];
|
|
992
1952
|
if (macro) value = codeBlock(`macro ${symbol.name}(${macro.params})`);
|
|
993
|
-
else if (doc) value = builtin(doc.signature, "functions");
|
|
994
|
-
else value = fromProject(project.manifest?.functions, "function");
|
|
1953
|
+
else if (doc && !registers(project.manifest?.functions)) value = builtin(doc.signature, "functions");
|
|
1954
|
+
else value = await describedCallable(open, "function", symbol.name) ?? fromProject(project.manifest?.functions, "function");
|
|
995
1955
|
break;
|
|
996
1956
|
}
|
|
997
1957
|
case "variable": {
|
|
998
|
-
const
|
|
999
|
-
const
|
|
1000
|
-
if (
|
|
1001
|
-
|
|
1002
|
-
|
|
1958
|
+
const types = templateTypes(open, text);
|
|
1959
|
+
const binding = bindingFor(types.scopes, symbol);
|
|
1960
|
+
if (binding?.source.kind === "prop" || binding?.source.kind === "rest") {
|
|
1961
|
+
const prop = (name ? await analyse(project, name, text) : null)?.props.find((item) => item.name === symbol.name);
|
|
1962
|
+
value = sections(codeBlock(prop ? describeProp(prop) : `...${symbol.name}`), BINDING_NOTES[binding.source.kind]);
|
|
1963
|
+
} else if (binding) {
|
|
1964
|
+
const typescript = await project.typescript();
|
|
1965
|
+
const type = typescript ? withTypes(typescript, { Value: bindingType(binding.source, types.context) }, (read) => read.describe("Value")) : null;
|
|
1966
|
+
value = sections(codeBlock(type && type !== "unknown" ? `${symbol.name}: ${type}` : symbol.name, "ts"), BINDING_NOTES[binding.source.kind]);
|
|
1967
|
+
} else {
|
|
1968
|
+
const global = await registered(open, "global", symbol.name);
|
|
1969
|
+
value = global ? sections(codeBlock(`${symbol.name}: ${global.type}`, "ts"), memberDocs(global)) : project.manifest?.globals.includes(symbol.name) ? "A global your project provides." : null;
|
|
1970
|
+
}
|
|
1003
1971
|
break;
|
|
1004
1972
|
}
|
|
1005
1973
|
case "template": {
|
|
@@ -1021,22 +1989,19 @@ const hover = async (open, offset) => {
|
|
|
1021
1989
|
}
|
|
1022
1990
|
return value ? {
|
|
1023
1991
|
contents: markdown(value),
|
|
1024
|
-
range
|
|
1992
|
+
range: rangeOf(symbol)
|
|
1025
1993
|
} : null;
|
|
1026
1994
|
};
|
|
1027
|
-
const
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
end:
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
});
|
|
1995
|
+
const point = (line, character) => {
|
|
1996
|
+
const position = {
|
|
1997
|
+
line,
|
|
1998
|
+
character
|
|
1999
|
+
};
|
|
2000
|
+
return {
|
|
2001
|
+
start: position,
|
|
2002
|
+
end: position
|
|
2003
|
+
};
|
|
2004
|
+
};
|
|
1040
2005
|
/** The line and column of an offset in another template's text. */
|
|
1041
2006
|
const positionIn = (text, offset) => {
|
|
1042
2007
|
const lines = text.slice(0, offset).split("\n");
|
|
@@ -1045,28 +2010,63 @@ const positionIn = (text, offset) => {
|
|
|
1045
2010
|
character: lines.at(-1).length
|
|
1046
2011
|
};
|
|
1047
2012
|
};
|
|
2013
|
+
/**
|
|
2014
|
+
* Where the name under the cursor is defined. The link's origin is the whole name, so editors underline all of
|
|
2015
|
+
* `"layouts/base"` rather than one word of it.
|
|
2016
|
+
*
|
|
2017
|
+
* Variables go to where the template declares them. Without `kiln.json`, template names have no root, so nothing in
|
|
2018
|
+
* other templates is found. With the project's TypeScript, its functions, filters, tests, globals and the properties of
|
|
2019
|
+
* typed values go to their TypeScript source.
|
|
2020
|
+
*/
|
|
1048
2021
|
const definition = async (open, offset) => {
|
|
1049
2022
|
const { document, project, name } = open;
|
|
1050
2023
|
const text = document.getText();
|
|
2024
|
+
const link = (origin, uri, range) => ({
|
|
2025
|
+
originSelectionRange: {
|
|
2026
|
+
start: document.positionAt(origin.start),
|
|
2027
|
+
end: document.positionAt(origin.end)
|
|
2028
|
+
},
|
|
2029
|
+
targetUri: uri,
|
|
2030
|
+
targetRange: range,
|
|
2031
|
+
targetSelectionRange: range
|
|
2032
|
+
});
|
|
2033
|
+
const inSource = (origin, location) => link(origin, pathToFileURL(location.path).href, point(location.line, location.character));
|
|
1051
2034
|
const symbol = symbolAt(text, offset);
|
|
1052
|
-
if (!symbol)
|
|
2035
|
+
if (!symbol) {
|
|
2036
|
+
if (!enclosingDelimiter(text, offset)) return null;
|
|
2037
|
+
const found = await memberAt(open, text, offset);
|
|
2038
|
+
return found?.member.location ? inSource(found.span, found.member.location) : null;
|
|
2039
|
+
}
|
|
2040
|
+
const inTemplate = (template, line = 0, character = 0) => link(symbol, pathToFileURL(pathOf(project, template)).href, point(line, character));
|
|
1053
2041
|
const load = (template) => project.overlay.get(template) ?? readTemplate(project, template);
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
2042
|
+
/** Goes to a function, filter, test or global in the project's TypeScript. */
|
|
2043
|
+
const inTypeScript = async (kind) => {
|
|
2044
|
+
const member = await registered(open, kind, symbol.name);
|
|
2045
|
+
return member?.location ? inSource(symbol, member.location) : null;
|
|
2046
|
+
};
|
|
2047
|
+
const blockName = symbol.kind === "variable" && /\{%-?\s*block\s+$/.test(text.slice(Math.max(0, symbol.start - 40), symbol.start));
|
|
2048
|
+
if (symbol.kind === "variable" && !blockName) {
|
|
2049
|
+
const binding = bindingFor(templateTypes(open, text).scopes, symbol);
|
|
2050
|
+
if (binding) return link(symbol, document.uri, {
|
|
2051
|
+
start: document.positionAt(binding.definition.start),
|
|
2052
|
+
end: document.positionAt(binding.definition.end)
|
|
2053
|
+
});
|
|
2054
|
+
return inTypeScript("global");
|
|
1057
2055
|
}
|
|
1058
2056
|
if (symbol.kind === "function") {
|
|
1059
2057
|
const local = macrosIn(text).get(symbol.name);
|
|
1060
2058
|
if (local) {
|
|
1061
2059
|
const position = document.positionAt(local.start);
|
|
1062
|
-
return
|
|
1063
|
-
uri: document.uri,
|
|
1064
|
-
range: {
|
|
1065
|
-
start: position,
|
|
1066
|
-
end: position
|
|
1067
|
-
}
|
|
1068
|
-
};
|
|
2060
|
+
return link(symbol, document.uri, point(position.line, position.character));
|
|
1069
2061
|
}
|
|
2062
|
+
}
|
|
2063
|
+
if (!project.manifest) return null;
|
|
2064
|
+
if (symbol.kind === "template") {
|
|
2065
|
+
const target = resolveName(symbol.name, name ?? void 0);
|
|
2066
|
+
return target ? inTemplate(target) : null;
|
|
2067
|
+
}
|
|
2068
|
+
if (symbol.kind === "filter" || symbol.kind === "test") return inTypeScript(symbol.kind);
|
|
2069
|
+
if (symbol.kind === "function") {
|
|
1070
2070
|
for (const match of text.matchAll(/\{%-?\s*from\s+(["'])([^"']*)\1\s+import\s+([^%]*)%\}/g)) for (const part of match[3].split(",")) {
|
|
1071
2071
|
const [original, alias = original] = part.trim().split(/\s+as\s+/);
|
|
1072
2072
|
if (alias !== symbol.name || !original) continue;
|
|
@@ -1075,13 +2075,12 @@ const definition = async (open, offset) => {
|
|
|
1075
2075
|
const macro = source ? macrosIn(source).get(original) : null;
|
|
1076
2076
|
if (target && source && macro) {
|
|
1077
2077
|
const position = positionIn(source, macro.start);
|
|
1078
|
-
return
|
|
2078
|
+
return inTemplate(target, position.line, position.character);
|
|
1079
2079
|
}
|
|
1080
2080
|
}
|
|
1081
|
-
return
|
|
2081
|
+
return inTypeScript("function");
|
|
1082
2082
|
}
|
|
1083
|
-
|
|
1084
|
-
if (symbol.kind === "variable" && tag && name) {
|
|
2083
|
+
if (blockName && name) {
|
|
1085
2084
|
let current = name;
|
|
1086
2085
|
for (let depth = 0; depth < 20; depth++) {
|
|
1087
2086
|
const parent = (await analyse(project, current, current === name ? text : void 0))?.templates.find((ref) => ref.kind === "extends")?.name;
|
|
@@ -1090,7 +2089,7 @@ const definition = async (open, offset) => {
|
|
|
1090
2089
|
const block = source ? new RegExp(`\\{%-?\\s*block\\s+${symbol.name}\\b`).exec(source) : null;
|
|
1091
2090
|
if (source && block) {
|
|
1092
2091
|
const position = positionIn(source, block.index);
|
|
1093
|
-
return
|
|
2092
|
+
return inTemplate(parent, position.line, position.character);
|
|
1094
2093
|
}
|
|
1095
2094
|
current = parent;
|
|
1096
2095
|
}
|
|
@@ -1144,6 +2143,192 @@ const symbols = (document) => {
|
|
|
1144
2143
|
}
|
|
1145
2144
|
return found;
|
|
1146
2145
|
};
|
|
2146
|
+
/** Whether the project registers a function or filter, which replaces a built-in with the same name. */
|
|
2147
|
+
const isRegistered = (project, kind, name) => (kind === "function" ? project.manifest?.functions : project.manifest?.filters)?.includes(name) ?? false;
|
|
2148
|
+
/** A built-in's documented signature, such as `round(precision?, method?)`, when it takes arguments. */
|
|
2149
|
+
const builtinSignature = (kind, name) => {
|
|
2150
|
+
const doc = (kind === "function" ? FUNCTIONS : FILTERS)[name];
|
|
2151
|
+
if (!doc?.signature.includes("(")) return null;
|
|
2152
|
+
const label = `${kind === "filter" ? "|" : ""}${doc.signature}`;
|
|
2153
|
+
const open = label.indexOf("(") + 1;
|
|
2154
|
+
const list = label.slice(open, label.lastIndexOf(")"));
|
|
2155
|
+
return {
|
|
2156
|
+
label,
|
|
2157
|
+
parameters: splitParameters(list).map((part) => ({
|
|
2158
|
+
name: /^(?:\.\.\.)?(\w+)/.exec(part.text)?.[1] ?? part.text,
|
|
2159
|
+
start: open + part.start,
|
|
2160
|
+
end: open + part.end
|
|
2161
|
+
}))
|
|
2162
|
+
};
|
|
2163
|
+
};
|
|
2164
|
+
/** Named arguments the function or filter at the cursor takes, such as `limit:`, when an argument could start there. */
|
|
2165
|
+
const namedArgumentItems = async (open, text, offset) => {
|
|
2166
|
+
const call = callContext(text, offset);
|
|
2167
|
+
if (!call?.namePosition) return [];
|
|
2168
|
+
const range = {
|
|
2169
|
+
start: open.document.positionAt(call.start),
|
|
2170
|
+
end: open.document.positionAt(offset)
|
|
2171
|
+
};
|
|
2172
|
+
const item = (label, detail, docs) => ({
|
|
2173
|
+
label: `${label}:`,
|
|
2174
|
+
kind: CompletionItemKind.Field,
|
|
2175
|
+
detail,
|
|
2176
|
+
documentation: docs ? markdown(docs) : void 0,
|
|
2177
|
+
sortText: `0${label}`,
|
|
2178
|
+
textEdit: {
|
|
2179
|
+
range,
|
|
2180
|
+
newText: `${label}: `
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
const builtin = isRegistered(open.project, call.kind, call.name) ? null : builtinSignature(call.kind, call.name);
|
|
2184
|
+
if (builtin) return builtin.parameters.map((param) => item(param.name));
|
|
2185
|
+
if (call.kind === "function" && macrosIn(text).has(call.name)) return [];
|
|
2186
|
+
const typescript = await open.project.typescript();
|
|
2187
|
+
if (!typescript) return [];
|
|
2188
|
+
return namedOptions(typescript, call.kind, call.name).map((member) => item(member.name, `${member.name}${member.optional ? "?" : ""}: ${member.type}`, memberDocs(member)));
|
|
2189
|
+
};
|
|
2190
|
+
/**
|
|
2191
|
+
* The parameters of the function or filter whose arguments the cursor is in, with the one it's on active. Named
|
|
2192
|
+
* arguments are gathered into the last parameter, so a named argument makes that one active and shows its own type.
|
|
2193
|
+
*/
|
|
2194
|
+
const signatureHelp = async (open, offset) => {
|
|
2195
|
+
const text = open.document.getText();
|
|
2196
|
+
const call = callContext(text, offset);
|
|
2197
|
+
if (!call) return null;
|
|
2198
|
+
let signature = null;
|
|
2199
|
+
const macro = call.kind === "function" ? macrosIn(text).get(call.name) : null;
|
|
2200
|
+
if (macro) signature = {
|
|
2201
|
+
label: `${call.name}(${macro.params})`,
|
|
2202
|
+
parameters: splitParameters(macro.params).map((part) => ({
|
|
2203
|
+
name: /^\w+/.exec(part.text)?.[0] ?? part.text,
|
|
2204
|
+
start: call.name.length + 1 + part.start,
|
|
2205
|
+
end: call.name.length + 1 + part.end
|
|
2206
|
+
}))
|
|
2207
|
+
};
|
|
2208
|
+
else if (!isRegistered(open.project, call.kind, call.name)) {
|
|
2209
|
+
const builtin = builtinSignature(call.kind, call.name);
|
|
2210
|
+
if (builtin) signature = {
|
|
2211
|
+
...builtin,
|
|
2212
|
+
docs: docsFor(call.kind === "function" ? "functions" : "filters", call.name)
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
const typescript = signature ? null : await open.project.typescript();
|
|
2216
|
+
if (typescript) {
|
|
2217
|
+
const described = describeCallable(typescript, call.kind, call.name);
|
|
2218
|
+
if (described) {
|
|
2219
|
+
const params = new Map(described.member.tags.flatMap((tag) => {
|
|
2220
|
+
const match = /^(\w+)\s*(.*)$/s.exec(tag.text);
|
|
2221
|
+
return tag.name === "param" && match ? [[match[1], match[2]]] : [];
|
|
2222
|
+
}));
|
|
2223
|
+
signature = {
|
|
2224
|
+
label: described.label,
|
|
2225
|
+
parameters: described.parameters,
|
|
2226
|
+
docs: described.member.docs,
|
|
2227
|
+
paramDocs: params
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
if (!signature || signature.parameters.length === 0) return null;
|
|
2232
|
+
const last = signature.parameters.length - 1;
|
|
2233
|
+
const active = call.named || call.sawNamed ? last : Math.min(call.index, last);
|
|
2234
|
+
let namedDocs = null;
|
|
2235
|
+
if (call.named && typescript) {
|
|
2236
|
+
const option = namedOptions(typescript, call.kind, call.name).find((member) => member.name === call.named);
|
|
2237
|
+
if (option) namedDocs = sections(codeBlock(`${option.name}${option.optional ? "?" : ""}: ${option.type}`, "ts"), memberDocs(option));
|
|
2238
|
+
}
|
|
2239
|
+
const found = signature;
|
|
2240
|
+
return {
|
|
2241
|
+
signatures: [{
|
|
2242
|
+
label: found.label,
|
|
2243
|
+
documentation: found.docs ? markdown(found.docs) : void 0,
|
|
2244
|
+
parameters: found.parameters.map((param, index) => {
|
|
2245
|
+
const docs = index === active && namedDocs ? namedDocs : found.paramDocs?.get(param.name);
|
|
2246
|
+
return {
|
|
2247
|
+
label: [param.start, param.end],
|
|
2248
|
+
documentation: docs ? markdown(docs) : void 0
|
|
2249
|
+
};
|
|
2250
|
+
})
|
|
2251
|
+
}],
|
|
2252
|
+
activeSignature: 0,
|
|
2253
|
+
activeParameter: active
|
|
2254
|
+
};
|
|
2255
|
+
};
|
|
2256
|
+
/**
|
|
2257
|
+
* Checks the arguments of calls to the project's own functions and filters against their TypeScript types. A filter's
|
|
2258
|
+
* value before the `|` is its first argument. Calls with `...` spreads, and arguments whose type is unknown, aren't
|
|
2259
|
+
* checked.
|
|
2260
|
+
*/
|
|
2261
|
+
const argumentWarnings = async (open) => {
|
|
2262
|
+
const typescript = await open.project.typescript();
|
|
2263
|
+
if (!typescript) return [];
|
|
2264
|
+
const text = open.document.getText();
|
|
2265
|
+
const { scopes, context } = templateTypes(open, text);
|
|
2266
|
+
/**
|
|
2267
|
+
* Each check, with what it's for: a filter's input, a positional or a named argument. A wrong type is reported on
|
|
2268
|
+
* the value, and a named argument the function doesn't take on its name.
|
|
2269
|
+
*/
|
|
2270
|
+
const checks = [];
|
|
2271
|
+
for (const node of expressionsIn(scopes.ast.body)) {
|
|
2272
|
+
let kind;
|
|
2273
|
+
let name;
|
|
2274
|
+
if (node.type === "Call" && node.callee.type === "Name") {
|
|
2275
|
+
kind = "function";
|
|
2276
|
+
name = node.callee.name;
|
|
2277
|
+
if (context.macros.has(name)) continue;
|
|
2278
|
+
} else if (node.type === "Filter") {
|
|
2279
|
+
kind = "filter";
|
|
2280
|
+
name = node.name;
|
|
2281
|
+
} else continue;
|
|
2282
|
+
if ((kind === "function" ? FUNCTIONS : FILTERS)[name] && !isRegistered(open.project, kind, name)) continue;
|
|
2283
|
+
if (node.args.some((arg) => arg.type === "Spread")) continue;
|
|
2284
|
+
const parameters = parametersType(kind, name, true);
|
|
2285
|
+
[...node.type === "Filter" ? [node.input] : [], ...node.args.flatMap((arg) => arg.type === "Positional" ? [arg.value] : [])].forEach((value, index) => {
|
|
2286
|
+
checks.push({
|
|
2287
|
+
type: expressionType(value, context),
|
|
2288
|
+
parameters,
|
|
2289
|
+
index,
|
|
2290
|
+
span: value.span,
|
|
2291
|
+
callee: name,
|
|
2292
|
+
role: node.type === "Filter" && index === 0 ? "input" : "positional"
|
|
2293
|
+
});
|
|
2294
|
+
});
|
|
2295
|
+
for (const arg of node.args) {
|
|
2296
|
+
if (arg.type !== "Named") continue;
|
|
2297
|
+
checks.push({
|
|
2298
|
+
type: expressionType(arg.value, context),
|
|
2299
|
+
parameters,
|
|
2300
|
+
name: arg.name,
|
|
2301
|
+
span: arg.value.span,
|
|
2302
|
+
nameSpan: {
|
|
2303
|
+
start: arg.span.start,
|
|
2304
|
+
end: arg.span.start + arg.name.length
|
|
2305
|
+
},
|
|
2306
|
+
callee: name,
|
|
2307
|
+
role: "named"
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
return checkArguments(typescript, checks).flatMap((problem, index) => {
|
|
2312
|
+
const check = checks[index];
|
|
2313
|
+
if (!problem || !check) return [];
|
|
2314
|
+
if (problem.kind === "missing") {
|
|
2315
|
+
const option = "name" in check ? check.name : "";
|
|
2316
|
+
return [{
|
|
2317
|
+
...check.nameSpan ?? check.span,
|
|
2318
|
+
code: "unknown-argument",
|
|
2319
|
+
message: `\`${check.callee}\` doesn't take \`${option}\`.`
|
|
2320
|
+
}];
|
|
2321
|
+
}
|
|
2322
|
+
const expected = `\`${problem.expected}\``;
|
|
2323
|
+
const actual = `not \`${problem.actual}\``;
|
|
2324
|
+
const message = check.role === "input" ? `\`${check.callee}\` needs ${expected} before the \`|\`, ${actual}.` : check.role === "named" && "name" in check ? `\`${check.name}\` should be ${expected}, ${actual}.` : `This argument should be ${expected}, ${actual}.`;
|
|
2325
|
+
return [{
|
|
2326
|
+
...check.span,
|
|
2327
|
+
code: "argument-type",
|
|
2328
|
+
message
|
|
2329
|
+
}];
|
|
2330
|
+
});
|
|
2331
|
+
};
|
|
1147
2332
|
//#endregion
|
|
1148
2333
|
//#region src/server.ts
|
|
1149
2334
|
/** Serves Kiln templates over a language server connection. */
|
|
@@ -1151,11 +2336,14 @@ const startServer = (connection) => {
|
|
|
1151
2336
|
const documents = new TextDocuments(TextDocument);
|
|
1152
2337
|
let workspaceFolders = [];
|
|
1153
2338
|
let canWatchFiles = false;
|
|
2339
|
+
/** Whether the client takes `LocationLink`s from go to definition. Plain locations lose the clicked range. */
|
|
2340
|
+
let definitionLinks = false;
|
|
1154
2341
|
/** Open projects, by the path of their `kiln.json`, or their folder when they have none. */
|
|
1155
2342
|
const projects = /* @__PURE__ */ new Map();
|
|
1156
2343
|
connection.onInitialize((params) => {
|
|
1157
2344
|
workspaceFolders = (params.workspaceFolders ?? []).map((folder) => folder.uri).concat(params.rootUri && !params.workspaceFolders ? [params.rootUri] : []).filter((uri) => uri.startsWith("file:")).map((uri) => fileURLToPath(uri));
|
|
1158
2345
|
canWatchFiles = params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
|
|
2346
|
+
definitionLinks = params.capabilities.textDocument?.definition?.linkSupport === true;
|
|
1159
2347
|
return {
|
|
1160
2348
|
capabilities: {
|
|
1161
2349
|
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
@@ -1173,6 +2361,10 @@ const startServer = (connection) => {
|
|
|
1173
2361
|
"="
|
|
1174
2362
|
] },
|
|
1175
2363
|
hoverProvider: true,
|
|
2364
|
+
signatureHelpProvider: {
|
|
2365
|
+
triggerCharacters: ["(", ","],
|
|
2366
|
+
retriggerCharacters: [":", " "]
|
|
2367
|
+
},
|
|
1176
2368
|
definitionProvider: true,
|
|
1177
2369
|
documentLinkProvider: { resolveProvider: false },
|
|
1178
2370
|
documentSymbolProvider: true,
|
|
@@ -1184,7 +2376,12 @@ const startServer = (connection) => {
|
|
|
1184
2376
|
});
|
|
1185
2377
|
connection.onInitialized(() => {
|
|
1186
2378
|
if (!canWatchFiles) return;
|
|
1187
|
-
connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [
|
|
2379
|
+
connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [
|
|
2380
|
+
{ globPattern: "**/*.kiln" },
|
|
2381
|
+
{ globPattern: "**/kiln.json" },
|
|
2382
|
+
{ globPattern: "**/*.{ts,tsx,mts,cts}" },
|
|
2383
|
+
{ globPattern: "**/tsconfig*.json" }
|
|
2384
|
+
] });
|
|
1188
2385
|
});
|
|
1189
2386
|
/** The project a file belongs to: the nearest `kiln.json` inside its workspace folder, or the folder itself. */
|
|
1190
2387
|
const projectFor = async (path) => {
|
|
@@ -1227,6 +2424,7 @@ const startServer = (connection) => {
|
|
|
1227
2424
|
const { project, name } = open;
|
|
1228
2425
|
const version = document.version;
|
|
1229
2426
|
const diagnostics = await diagnose(project, name, document.getText()).catch(() => []);
|
|
2427
|
+
const warnings = await argumentWarnings(open).catch(() => []);
|
|
1230
2428
|
if (documents.get(document.uri)?.version !== version) return;
|
|
1231
2429
|
connection.sendDiagnostics({
|
|
1232
2430
|
uri: document.uri,
|
|
@@ -1240,7 +2438,16 @@ const startServer = (connection) => {
|
|
|
1240
2438
|
code: diagnostic.code,
|
|
1241
2439
|
source: "kiln",
|
|
1242
2440
|
message: diagnostic.message
|
|
1243
|
-
}))
|
|
2441
|
+
})).concat(warnings.map((warning) => ({
|
|
2442
|
+
range: {
|
|
2443
|
+
start: document.positionAt(warning.start),
|
|
2444
|
+
end: document.positionAt(warning.end)
|
|
2445
|
+
},
|
|
2446
|
+
severity: DiagnosticSeverity.Warning,
|
|
2447
|
+
code: warning.code,
|
|
2448
|
+
source: "kiln",
|
|
2449
|
+
message: warning.message
|
|
2450
|
+
})))
|
|
1244
2451
|
});
|
|
1245
2452
|
};
|
|
1246
2453
|
/** Every open template in a project sees an edit to any of them, since one template's props can break another. */
|
|
@@ -1266,9 +2473,22 @@ const startServer = (connection) => {
|
|
|
1266
2473
|
open.project.kiln.invalidate(open.name);
|
|
1267
2474
|
await checkProject(open.project);
|
|
1268
2475
|
});
|
|
2476
|
+
/** Closes every open project, so the next request opens them again from their settings. */
|
|
2477
|
+
const closeProjects = async () => {
|
|
2478
|
+
const open = [...projects.values()];
|
|
2479
|
+
projects.clear();
|
|
2480
|
+
for (const project of open) (await project).close();
|
|
2481
|
+
};
|
|
2482
|
+
connection.onShutdown(closeProjects);
|
|
1269
2483
|
connection.onDidChangeWatchedFiles(async ({ changes }) => {
|
|
1270
|
-
|
|
1271
|
-
|
|
2484
|
+
const isSettings = (uri) => uri.endsWith("/kiln.json") || /\/tsconfig[^/]*\.json$/.test(uri);
|
|
2485
|
+
const typescriptChanges = changes.flatMap((change) => change.uri.startsWith("file:") && /\.[cm]?tsx?$/.test(change.uri) ? [{
|
|
2486
|
+
path: fileURLToPath(change.uri),
|
|
2487
|
+
kind: change.type === FileChangeType.Created ? "created" : change.type === FileChangeType.Deleted ? "deleted" : "changed"
|
|
2488
|
+
}] : []);
|
|
2489
|
+
if (typescriptChanges.length > 0) for (const project of projects.values()) (await project).typescriptChanged(typescriptChanges);
|
|
2490
|
+
if (changes.some((change) => isSettings(change.uri))) {
|
|
2491
|
+
await closeProjects();
|
|
1272
2492
|
for (const document of documents.all()) {
|
|
1273
2493
|
const open = await openTemplate(document);
|
|
1274
2494
|
if (open?.name) {
|
|
@@ -1277,7 +2497,7 @@ const startServer = (connection) => {
|
|
|
1277
2497
|
}
|
|
1278
2498
|
}
|
|
1279
2499
|
} else for (const change of changes) {
|
|
1280
|
-
if (!change.uri.startsWith("file:")) continue;
|
|
2500
|
+
if (!change.uri.startsWith("file:") || !change.uri.endsWith(".kiln")) continue;
|
|
1281
2501
|
const path = fileURLToPath(change.uri);
|
|
1282
2502
|
const project = await projectFor(path);
|
|
1283
2503
|
const name = nameOf(project, path);
|
|
@@ -1293,7 +2513,15 @@ const startServer = (connection) => {
|
|
|
1293
2513
|
};
|
|
1294
2514
|
connection.onCompletion(withTemplate([], (open, params) => complete(open, open.document.offsetAt(params.position))));
|
|
1295
2515
|
connection.onHover(withTemplate(null, (open, params) => hover(open, open.document.offsetAt(params.position))));
|
|
1296
|
-
connection.
|
|
2516
|
+
connection.onSignatureHelp(withTemplate(null, (open, params) => signatureHelp(open, open.document.offsetAt(params.position))));
|
|
2517
|
+
connection.onDefinition(withTemplate(null, async (open, params) => {
|
|
2518
|
+
const link = await definition(open, open.document.offsetAt(params.position));
|
|
2519
|
+
if (!link || definitionLinks) return link && [link];
|
|
2520
|
+
return {
|
|
2521
|
+
uri: link.targetUri,
|
|
2522
|
+
range: link.targetSelectionRange
|
|
2523
|
+
};
|
|
2524
|
+
}));
|
|
1297
2525
|
connection.onDocumentLinks(withTemplate([], (open) => links(open)));
|
|
1298
2526
|
connection.onDocumentSymbol(withTemplate([], (open) => symbols(open.document)));
|
|
1299
2527
|
/** Formats a template, or part of one, as edits. Templates with syntax errors are left alone. */
|