@lucidkiln/language-server 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +1363 -72
- 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
|
|
@@ -303,7 +411,7 @@ const symbolAt = (text, offset) => {
|
|
|
303
411
|
const DOCS = {
|
|
304
412
|
tags: {
|
|
305
413
|
"add": {
|
|
306
|
-
"markdown": "- Content goes to the outlet with the same name, in the order it was added. It doesn't matter whether `add to` runs before or after the outlet.\n- The content
|
|
414
|
+
"markdown": "- Content goes to the outlet with the same name, in the order it was added. It doesn't matter whether `add to` runs before or after the outlet.\n- The content can read the variables around it. Variables you `set` inside it stay inside it.\n- Content for an outlet the page doesn't have is dropped. A component still works on a page without that outlet, and when you [render one block](https://kilnlang.com/docs/api/rendering/#rendering-one-block).",
|
|
307
415
|
"url": "https://kilnlang.com/docs/tags/outlet/#adding-content"
|
|
308
416
|
},
|
|
309
417
|
"apply": {
|
|
@@ -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,585 @@ 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
|
+
* - `Params` and `Returns` describe a function for hover. A function made with `withRender` is an object whose `call`
|
|
1416
|
+
* takes `RenderInfo` first, so `Params` leaves that out.
|
|
1417
|
+
* - `arg`, `plain`, `aware`, `dropRender`, `Called`: a call to a host function, written as a real call so TypeScript
|
|
1418
|
+
* works out a generic function's type parameters from the arguments. See `typeWriter`.
|
|
1419
|
+
* - `Members`: the values whose properties a template can read, so lists, maps and primitives complete nothing.
|
|
1420
|
+
* - `Last`: a function's last parameter, which named arguments are gathered into.
|
|
1421
|
+
* - `Fits`, `NamedFit`: whether an argument fits its parameter. Unknown types always fit, so the editor never guesses.
|
|
1422
|
+
*/
|
|
1423
|
+
const PRELUDE = `import type { KilnFilters, KilnFunctions, KilnGlobals, KilnTests, KilnTypes, Markup } from "@lucidkiln/core";
|
|
1424
|
+
type Named<N extends string> = N extends keyof KilnTypes ? KilnTypes[N] : unknown;
|
|
1425
|
+
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;
|
|
1426
|
+
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;
|
|
1427
|
+
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;
|
|
1428
|
+
type Returns<F> = F extends (...args: never) => infer R ? R : F extends { readonly call: (...args: never) => infer R } ? R : unknown;
|
|
1429
|
+
type Params<F> = F extends (...args: infer A) => unknown ? A : F extends { readonly call: (render: never, ...args: infer A) => unknown } ? A : never;
|
|
1430
|
+
declare function arg<T>(): T;
|
|
1431
|
+
declare function plain<F>(fn: F): F extends (...args: never) => unknown ? F : never;
|
|
1432
|
+
declare function aware<F>(fn: F): F extends (...args: never) => unknown ? never : F extends { readonly call: infer C } ? C : never;
|
|
1433
|
+
declare function dropRender<A extends unknown[], R>(fn: (render: never, ...args: A) => R): (...args: A) => R;
|
|
1434
|
+
type Called<F, Plain, Aware> = unknown extends F ? unknown : F extends (...args: never) => unknown ? Awaited<Plain> : F extends { readonly call: unknown } ? Awaited<Aware> : unknown;
|
|
1435
|
+
type Tail<A> = A extends readonly [unknown?, ...infer R] ? R : A;
|
|
1436
|
+
type Last<A> = Required<A> extends readonly [...unknown[], infer L] ? L : A extends readonly (infer E)[] ? E : unknown;
|
|
1437
|
+
type Fits<A, P> = [P] extends [never] ? true : unknown extends P ? true : unknown extends A ? true : [Exclude<A, Markup>] extends [P] ? true : false;
|
|
1438
|
+
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";
|
|
1439
|
+
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;
|
|
1440
|
+
`;
|
|
1441
|
+
const BUILTIN_TYPES = {
|
|
1442
|
+
null: "null",
|
|
1443
|
+
boolean: "boolean",
|
|
1444
|
+
number: "number",
|
|
1445
|
+
string: "string | Markup",
|
|
1446
|
+
array: "readonly unknown[]",
|
|
1447
|
+
hash: "Readonly<Record<string, unknown>> | ReadonlyMap<string, unknown>",
|
|
1448
|
+
set: "ReadonlySet<unknown>",
|
|
1449
|
+
date: "Date",
|
|
1450
|
+
markup: "Markup",
|
|
1451
|
+
object: "object"
|
|
1452
|
+
};
|
|
1453
|
+
/** A prop's declared type as TypeScript, the same way `generateTypes` writes it. */
|
|
1454
|
+
const propType = (type) => {
|
|
1455
|
+
if (type === null) return "unknown";
|
|
1456
|
+
if (type.length === 0) return "never";
|
|
1457
|
+
return type.map((member) => {
|
|
1458
|
+
if (typeof member === "string") return BUILTIN_TYPES[member] ?? "unknown";
|
|
1459
|
+
if ("literal" in member) return JSON.stringify(member.literal);
|
|
1460
|
+
if ("named" in member) return `Named<${JSON.stringify(member.named)}>`;
|
|
1461
|
+
if ("list" in member) return `readonly (${propType(member.list)})[]`;
|
|
1462
|
+
if (member.shape.length === 0) return "object";
|
|
1463
|
+
return `{ ${member.shape.map((key) => `${JSON.stringify(key.name)}${key.optional ? "?" : ""}: ${propType(key.type)}`).join("; ")} }`;
|
|
1464
|
+
}).join(" | ");
|
|
1465
|
+
};
|
|
1466
|
+
const BUILTIN_FUNCTIONS = {
|
|
1467
|
+
range: "number[]",
|
|
1468
|
+
max: "number",
|
|
1469
|
+
min: "number",
|
|
1470
|
+
cycle: "unknown",
|
|
1471
|
+
date: "Date",
|
|
1472
|
+
attrs: "Markup",
|
|
1473
|
+
dump: "Markup",
|
|
1474
|
+
parent: "Markup",
|
|
1475
|
+
block: "Markup"
|
|
1476
|
+
};
|
|
1477
|
+
/** What each built-in filter gives, from the type of the value before the `|` and the types of its arguments. */
|
|
1478
|
+
const BUILTIN_FILTERS = {
|
|
1479
|
+
upper: () => "string",
|
|
1480
|
+
lower: () => "string",
|
|
1481
|
+
capitalize: () => "string",
|
|
1482
|
+
title: () => "string",
|
|
1483
|
+
trim: () => "string",
|
|
1484
|
+
replace: () => "string",
|
|
1485
|
+
split: () => "string[]",
|
|
1486
|
+
truncate: () => "string",
|
|
1487
|
+
stripTags: () => "string",
|
|
1488
|
+
urlEncode: () => "string",
|
|
1489
|
+
json: () => "Markup",
|
|
1490
|
+
escape: () => "Markup",
|
|
1491
|
+
raw: () => "Markup",
|
|
1492
|
+
default: (input, [fallback = "string"]) => `NonNullable<${input}> | ${fallback}`,
|
|
1493
|
+
length: () => "number",
|
|
1494
|
+
first: (input) => `Item<${input}>`,
|
|
1495
|
+
last: (input) => `Item<${input}>`,
|
|
1496
|
+
slice: (input) => input,
|
|
1497
|
+
join: () => "string",
|
|
1498
|
+
keys: (input) => `Key<${input}>[]`,
|
|
1499
|
+
merge: (input) => input,
|
|
1500
|
+
reverse: (input) => input,
|
|
1501
|
+
sort: (input) => input,
|
|
1502
|
+
map: () => "unknown[]",
|
|
1503
|
+
filter: (input) => input,
|
|
1504
|
+
reduce: () => "unknown",
|
|
1505
|
+
find: (input) => `Item<${input}> | null`,
|
|
1506
|
+
column: () => "unknown[]",
|
|
1507
|
+
batch: (input) => `Item<${input}>[][]`,
|
|
1508
|
+
abs: () => "number",
|
|
1509
|
+
round: () => "number",
|
|
1510
|
+
formatNumber: () => "string",
|
|
1511
|
+
plural: () => "string",
|
|
1512
|
+
formatDate: () => "string"
|
|
1513
|
+
};
|
|
1514
|
+
/** The list item an arrow function's parameter gets, by filter: which parameter it is. */
|
|
1515
|
+
const ITEM_PARAMS = {
|
|
1516
|
+
map: [0],
|
|
1517
|
+
filter: [0],
|
|
1518
|
+
find: [0],
|
|
1519
|
+
sort: [0, 1],
|
|
1520
|
+
reduce: [1]
|
|
1521
|
+
};
|
|
1522
|
+
const MAX_DEPTH = 32;
|
|
1523
|
+
const union = (types) => types.length === 0 ? "never" : [...new Set(types)].join(" | ");
|
|
1524
|
+
const literalKey = (expression) => expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") ? JSON.stringify(expression.value) : null;
|
|
1525
|
+
/**
|
|
1526
|
+
* Writes the types of a template's values. A literal's type is its value, so `"page"` is `"page"`. A call to a host
|
|
1527
|
+
* function or filter becomes a real call in `statements`, so TypeScript works out a generic function's type parameters
|
|
1528
|
+
* from the arguments:
|
|
1529
|
+
*
|
|
1530
|
+
* ```ts
|
|
1531
|
+
* declare const $fn0: Get<KilnFunctions, "document">;
|
|
1532
|
+
* const $plain0 = plain($fn0)(arg<"page">());
|
|
1533
|
+
* const $aware0 = dropRender(aware($fn0))(arg<"page">());
|
|
1534
|
+
* ```
|
|
1535
|
+
*
|
|
1536
|
+
* The call's type is `Called<typeof $fn0, typeof $plain0, typeof $aware0>`: the first call for a plain function, the
|
|
1537
|
+
* second for one made with `withRender`. Each expression is written once, however often a template reads it.
|
|
1538
|
+
*/
|
|
1539
|
+
const typeWriter = (context) => {
|
|
1540
|
+
const statements = [];
|
|
1541
|
+
const written = /* @__PURE__ */ new Map();
|
|
1542
|
+
/** Writes a call to `callee`, a TypeScript type, with the types of its arguments, and returns the result's type. */
|
|
1543
|
+
const call = (callee, args) => {
|
|
1544
|
+
const id = statements.length;
|
|
1545
|
+
const list = args.join(", ");
|
|
1546
|
+
statements.push(`declare const $fn${id}: ${callee};\nconst $plain${id} = plain($fn${id})(${list});\nconst $aware${id} = dropRender(aware($fn${id}))(${list});\n`);
|
|
1547
|
+
return `Called<typeof $fn${id}, typeof $plain${id}, typeof $aware${id}>`;
|
|
1548
|
+
};
|
|
1549
|
+
/** A call's arguments as TypeScript: named arguments are gathered into one hash at the end, as Kiln does. */
|
|
1550
|
+
const callArgs = (list, depth, input) => {
|
|
1551
|
+
const named = list.flatMap((item) => item.type === "Named" ? [`${JSON.stringify(item.name)}: ${expression(item.value, depth)}`] : []);
|
|
1552
|
+
return [
|
|
1553
|
+
...input ? [`arg<${input}>()`] : [],
|
|
1554
|
+
...list.flatMap((item) => {
|
|
1555
|
+
if (item.type === "Named") return [];
|
|
1556
|
+
const type = expression(item.value, depth);
|
|
1557
|
+
return [item.type === "Spread" ? `...arg<Item<${type}>[]>()` : `arg<${type}>()`];
|
|
1558
|
+
}),
|
|
1559
|
+
...named.length > 0 ? [`arg<{ ${named.join("; ")} }>()`] : []
|
|
1560
|
+
];
|
|
1561
|
+
};
|
|
1562
|
+
const binding = (source, depth) => {
|
|
1563
|
+
const of = (value) => expression(value, depth + 1);
|
|
1564
|
+
switch (source.kind) {
|
|
1565
|
+
case "prop": return propType(source.type);
|
|
1566
|
+
case "rest": return "Readonly<Record<string, unknown>>";
|
|
1567
|
+
case "set": return of(source.value);
|
|
1568
|
+
case "capture": return "Markup";
|
|
1569
|
+
case "item": return `Item<${of(source.iterable)}>`;
|
|
1570
|
+
case "key": return `Key<${of(source.iterable)}>`;
|
|
1571
|
+
case "arrow": return ITEM_PARAMS[source.filter]?.includes(source.index) ? `Item<${of(source.input)}>` : "unknown";
|
|
1572
|
+
case "with": return `Get<${of(source.value)}, ${JSON.stringify(source.key)}>`;
|
|
1573
|
+
default: return "unknown";
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
const expression = (node, depth) => {
|
|
1577
|
+
if (depth > MAX_DEPTH) return "unknown";
|
|
1578
|
+
const known = written.get(node);
|
|
1579
|
+
if (known) return known;
|
|
1580
|
+
const type = write(node, depth + 1);
|
|
1581
|
+
written.set(node, type);
|
|
1582
|
+
return type;
|
|
1583
|
+
};
|
|
1584
|
+
const write = (node, depth) => {
|
|
1585
|
+
const of = (inner) => expression(inner, depth);
|
|
1586
|
+
switch (node.type) {
|
|
1587
|
+
case "Literal": return typeof node.value === "string" ? JSON.stringify(node.value) : String(node.value);
|
|
1588
|
+
case "Interpolation": return "string";
|
|
1589
|
+
case "Array": return `(${union(node.elements.map((element) => element.type === "Spread" ? `Item<${of(element.value)}>` : of(element)))})[]`;
|
|
1590
|
+
case "Hash": return `{ ${node.entries.flatMap((entry) => {
|
|
1591
|
+
if (entry.type === "Spread") return [];
|
|
1592
|
+
const key = entry.key.type === "Name" ? JSON.stringify(entry.key.name) : literalKey(entry.key);
|
|
1593
|
+
return key ? [`${key}: ${of(entry.value)}`] : [];
|
|
1594
|
+
}).join("; ")} }`;
|
|
1595
|
+
case "Unary": return node.operator === "not" ? "boolean" : "number";
|
|
1596
|
+
case "Binary": switch (node.operator) {
|
|
1597
|
+
case "??":
|
|
1598
|
+
case "?:": return `NonNullable<${of(node.left)}> | ${of(node.right)}`;
|
|
1599
|
+
case "~": return "string";
|
|
1600
|
+
case "..": return "number[]";
|
|
1601
|
+
case "+":
|
|
1602
|
+
case "-":
|
|
1603
|
+
case "*":
|
|
1604
|
+
case "/":
|
|
1605
|
+
case "//":
|
|
1606
|
+
case "%":
|
|
1607
|
+
case "**": return "number";
|
|
1608
|
+
default: return "boolean";
|
|
1609
|
+
}
|
|
1610
|
+
case "Conditional": return union([of(node.consequent), node.alternate ? of(node.alternate) : "null"]);
|
|
1611
|
+
case "Member": {
|
|
1612
|
+
const key = literalKey(node.property) ?? "string";
|
|
1613
|
+
return `Get<${of(node.object)}, ${key}>`;
|
|
1614
|
+
}
|
|
1615
|
+
case "Call": {
|
|
1616
|
+
const { callee } = node;
|
|
1617
|
+
if (callee.type === "Member") {
|
|
1618
|
+
const key = literalKey(callee.property) ?? "string";
|
|
1619
|
+
return call(`Get<${of(callee.object)}, ${key}>`, callArgs(node.args, depth));
|
|
1620
|
+
}
|
|
1621
|
+
if (callee.type !== "Name") return "unknown";
|
|
1622
|
+
const name = callee.name;
|
|
1623
|
+
if (context.macros.has(name)) return "Markup";
|
|
1624
|
+
const builtin = BUILTIN_FUNCTIONS[name];
|
|
1625
|
+
if (builtin && !context.functions.includes(name)) return builtin;
|
|
1626
|
+
return call(`Get<KilnFunctions, ${JSON.stringify(name)}>`, callArgs(node.args, depth));
|
|
1627
|
+
}
|
|
1628
|
+
case "Filter": {
|
|
1629
|
+
const input = of(node.input);
|
|
1630
|
+
const builtin = BUILTIN_FILTERS[node.name];
|
|
1631
|
+
if (!builtin || context.filters.includes(node.name)) return call(`Get<KilnFilters, ${JSON.stringify(node.name)}>`, callArgs(node.args, depth, input));
|
|
1632
|
+
const arrow = node.args[0]?.value;
|
|
1633
|
+
if (node.name === "map" && arrow?.type === "Arrow") return `(${of(arrow.body)})[]`;
|
|
1634
|
+
return builtin(input, node.args.map((item) => item.type === "Spread" ? "unknown" : of(item.value)));
|
|
1635
|
+
}
|
|
1636
|
+
case "Test": return "boolean";
|
|
1637
|
+
case "OptionalChain": return of(node.expression);
|
|
1638
|
+
case "Name": {
|
|
1639
|
+
if (node.name === "loop") return "unknown";
|
|
1640
|
+
const found = bindingAt(context.scopes, node.name, node.span.start);
|
|
1641
|
+
if (found) return binding(found.source, depth);
|
|
1642
|
+
return `Get<KilnGlobals, ${JSON.stringify(node.name)}>`;
|
|
1643
|
+
}
|
|
1644
|
+
default: return "unknown";
|
|
1645
|
+
}
|
|
1646
|
+
};
|
|
1647
|
+
return {
|
|
1648
|
+
expression: (node) => expression(node, 0),
|
|
1649
|
+
binding: (source) => binding(source, 0),
|
|
1650
|
+
statements
|
|
1651
|
+
};
|
|
1652
|
+
};
|
|
1653
|
+
/** The registries a project describes in TypeScript, by the kind of name the language server looks up. */
|
|
1654
|
+
const REGISTRIES = {
|
|
1655
|
+
function: "KilnFunctions",
|
|
1656
|
+
filter: "KilnFilters",
|
|
1657
|
+
test: "KilnTests",
|
|
1658
|
+
global: "KilnGlobals"
|
|
1659
|
+
};
|
|
1660
|
+
/**
|
|
1661
|
+
* Checks a file of `type name = type` aliases with the project's TypeScript, and reads it with `use`. `statements` are
|
|
1662
|
+
* what the aliases read, from a `TypeWriter`.
|
|
1663
|
+
*/
|
|
1664
|
+
const withTypes = (typescript, aliases, use, statements = []) => {
|
|
1665
|
+
const source = PRELUDE + statements.join("") + Object.entries(aliases).map(([name, type]) => `type ${name} = ${type};\n`).join("");
|
|
1666
|
+
return typescript.query(source, use);
|
|
1667
|
+
};
|
|
1668
|
+
/** The TypeScript type of a registered function, filter or test, by kind and name. */
|
|
1669
|
+
const callableType = (kind, name) => `Get<${REGISTRIES[kind]}, ${JSON.stringify(name)}>`;
|
|
1670
|
+
/** Its parameters as a tuple: a filter's first parameter is the value before the `|`, so `withInput` keeps it. */
|
|
1671
|
+
const parametersType = (kind, name, withInput = false) => {
|
|
1672
|
+
const params = `Params<${callableType(kind, name)}>`;
|
|
1673
|
+
return kind === "function" || withInput ? params : `Tail<${params}>`;
|
|
1674
|
+
};
|
|
1675
|
+
/** A host function, filter or test as hover shows it, such as `documents(collection: string): Promise<Page[]>`. */
|
|
1676
|
+
const describeCallable = (typescript, kind, name) => withTypes(typescript, {
|
|
1677
|
+
Registry: REGISTRIES[kind],
|
|
1678
|
+
Parameters: parametersType(kind, name),
|
|
1679
|
+
Return: `Returns<${callableType(kind, name)}>`
|
|
1680
|
+
}, (read) => {
|
|
1681
|
+
const member = read.members("Registry").find((item) => item.name === name);
|
|
1682
|
+
const parameters = read.describe("Parameters");
|
|
1683
|
+
const result = read.describe("Return");
|
|
1684
|
+
if (!member || !parameters || !result) return null;
|
|
1685
|
+
const list = parameters.replace(/^\[|\]$/g, "");
|
|
1686
|
+
const prefix = `${kind === "filter" ? "|" : kind === "test" ? "is " : ""}${name}(`;
|
|
1687
|
+
return {
|
|
1688
|
+
label: `${prefix}${list}): ${result}`,
|
|
1689
|
+
parameters: splitParameters(list).map((part) => ({
|
|
1690
|
+
name: /^(?:\.\.\.)?([\w$]+)/.exec(part.text)?.[1] ?? part.text,
|
|
1691
|
+
start: prefix.length + part.start,
|
|
1692
|
+
end: prefix.length + part.end
|
|
1693
|
+
})),
|
|
1694
|
+
member
|
|
1695
|
+
};
|
|
1696
|
+
});
|
|
1697
|
+
/** The names a function or filter takes as named arguments: the properties of its last parameter. */
|
|
1698
|
+
const namedOptions = (typescript, kind, name) => withTypes(typescript, { Options: `Members<Last<${parametersType(kind, name)}>>` }, (read) => read.members("Options")) ?? [];
|
|
1699
|
+
/**
|
|
1700
|
+
* Checks arguments against their parameters with one TypeScript query. Null for an argument that fits. `statements` are
|
|
1701
|
+
* what the argument types read, from a `TypeWriter`.
|
|
1702
|
+
*/
|
|
1703
|
+
const checkArguments = (typescript, checks, statements = []) => {
|
|
1704
|
+
if (checks.length === 0) return [];
|
|
1705
|
+
const aliases = {};
|
|
1706
|
+
checks.forEach((check, index) => {
|
|
1707
|
+
const expected = "index" in check ? `Required<${check.parameters}>[${check.index}]` : `Exclude<Get<Last<${check.parameters}>, ${JSON.stringify(check.name)}>, undefined>`;
|
|
1708
|
+
aliases[`Fit${index}`] = "index" in check ? `Fits<${check.type}, ${expected}> extends true ? "ok" : "mismatch"` : `NamedFit<Last<${check.parameters}>, ${JSON.stringify(check.name)}, ${check.type}>`;
|
|
1709
|
+
aliases[`Expected${index}`] = expected;
|
|
1710
|
+
aliases[`Actual${index}`] = check.type;
|
|
1711
|
+
});
|
|
1712
|
+
return withTypes(typescript, aliases, (read) => checks.map((_, index) => {
|
|
1713
|
+
const fit = read.describe(`Fit${index}`);
|
|
1714
|
+
if (fit === "\"missing\"") return { kind: "missing" };
|
|
1715
|
+
if (fit !== "\"mismatch\"") return null;
|
|
1716
|
+
return {
|
|
1717
|
+
kind: "mismatch",
|
|
1718
|
+
expected: read.describe(`Expected${index}`) ?? "unknown",
|
|
1719
|
+
actual: read.describe(`Actual${index}`) ?? "unknown"
|
|
1720
|
+
};
|
|
1721
|
+
}), statements) ?? checks.map(() => null);
|
|
1722
|
+
};
|
|
1723
|
+
//#endregion
|
|
794
1724
|
//#region src/features.ts
|
|
795
1725
|
const analyse = async (project, name, text) => {
|
|
796
1726
|
try {
|
|
@@ -833,21 +1763,63 @@ const builtinItems = (builtins, docKind, kind, extra) => [...Object.entries(buil
|
|
|
833
1763
|
kind,
|
|
834
1764
|
detail: "From your project"
|
|
835
1765
|
}))];
|
|
836
|
-
/**
|
|
837
|
-
const
|
|
838
|
-
const
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1766
|
+
/** Parses a template and finds its variables, with what `typeWriter` needs to work out their types. */
|
|
1767
|
+
const templateTypes = (open, text) => {
|
|
1768
|
+
const manifest = open.project.manifest;
|
|
1769
|
+
const scopes = templateScopes(text, (name) => name in TESTS || (manifest?.tests.includes(name) ?? false));
|
|
1770
|
+
const macros = /* @__PURE__ */ new Set([...macrosIn(text).keys(), ...scopes.bindings.filter((binding) => binding.source.kind === "import").map((binding) => binding.name)]);
|
|
1771
|
+
return {
|
|
1772
|
+
scopes,
|
|
1773
|
+
context: {
|
|
1774
|
+
scopes,
|
|
1775
|
+
functions: manifest?.functions ?? [],
|
|
1776
|
+
filters: manifest?.filters ?? [],
|
|
1777
|
+
macros
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
};
|
|
1781
|
+
/** The property under the cursor in `a.b`, with the expression it's read from. */
|
|
1782
|
+
const propertyAt = (scopes, text, offset) => {
|
|
1783
|
+
const path = expressionPathAt(scopes.ast, offset);
|
|
1784
|
+
for (let index = path.length - 1; index >= 0; index--) {
|
|
1785
|
+
const node = path[index];
|
|
1786
|
+
if (node.type !== "Member") continue;
|
|
1787
|
+
const { property } = node;
|
|
1788
|
+
if (property.type === "Literal" && typeof property.value === "string" && offset >= property.span.start && offset <= property.span.end && text[property.span.start - 1] === ".") return {
|
|
1789
|
+
name: property.value,
|
|
1790
|
+
object: node.object,
|
|
1791
|
+
span: property.span
|
|
1792
|
+
};
|
|
849
1793
|
}
|
|
850
|
-
return
|
|
1794
|
+
return null;
|
|
1795
|
+
};
|
|
1796
|
+
/** A member's doc comment and tags, as Markdown. */
|
|
1797
|
+
const memberDocs = (member) => [member.docs, ...member.tags.map((tag) => `*@${tag.name}* ${tag.text}`.trim())].filter(Boolean).join("\n\n");
|
|
1798
|
+
/** The properties TypeScript finds on the value of `object`. Empty without TypeScript. */
|
|
1799
|
+
const membersOf = async (open, object, context) => {
|
|
1800
|
+
const typescript = await open.project.typescript();
|
|
1801
|
+
if (!typescript) return [];
|
|
1802
|
+
const types = typeWriter(context);
|
|
1803
|
+
const type = types.expression(object);
|
|
1804
|
+
return withTypes(typescript, { Value: `Members<${type}>` }, (read) => read.members("Value"), types.statements) ?? [];
|
|
1805
|
+
};
|
|
1806
|
+
/** A function, filter, test or global the project describes in TypeScript. */
|
|
1807
|
+
const registered = async (open, kind, name) => {
|
|
1808
|
+
const typescript = await open.project.typescript();
|
|
1809
|
+
if (!typescript) return null;
|
|
1810
|
+
return withTypes(typescript, { Registry: REGISTRIES[kind] }, (read) => read.members("Registry").find((member) => member.name === name)) ?? null;
|
|
1811
|
+
};
|
|
1812
|
+
const BINDING_NOTES = {
|
|
1813
|
+
prop: "A prop of this template.",
|
|
1814
|
+
rest: "The variables passed to this template that it doesn't declare.",
|
|
1815
|
+
set: "Set in this template.",
|
|
1816
|
+
capture: "Set in this template, from a `set` block.",
|
|
1817
|
+
item: "A loop variable.",
|
|
1818
|
+
key: "A loop variable.",
|
|
1819
|
+
arrow: "An arrow function's parameter.",
|
|
1820
|
+
with: "From the `with` tag.",
|
|
1821
|
+
param: "A macro argument.",
|
|
1822
|
+
import: "Imported macros."
|
|
851
1823
|
};
|
|
852
1824
|
const complete = async (open, offset) => {
|
|
853
1825
|
const { document, project, name } = open;
|
|
@@ -900,6 +1872,22 @@ const complete = async (open, offset) => {
|
|
|
900
1872
|
});
|
|
901
1873
|
case "filter": return builtinItems(FILTERS, "filters", CompletionItemKind.Function, manifest?.filters ?? []);
|
|
902
1874
|
case "test": return builtinItems(TESTS, "tests", CompletionItemKind.Function, manifest?.tests ?? []);
|
|
1875
|
+
case "member": {
|
|
1876
|
+
const patched = context.start === offset ? `${text.slice(0, offset)}__kiln__${text.slice(offset)}` : text;
|
|
1877
|
+
const types = templateTypes(open, patched);
|
|
1878
|
+
const property = propertyAt(types.scopes, patched, context.start);
|
|
1879
|
+
if (!property) return [];
|
|
1880
|
+
return (await membersOf(open, property.object, types.context)).map((member) => ({
|
|
1881
|
+
label: member.name,
|
|
1882
|
+
kind: CompletionItemKind.Property,
|
|
1883
|
+
detail: `${member.name}${member.optional ? "?" : ""}: ${member.type}`,
|
|
1884
|
+
documentation: memberDocs(member) ? markdown(memberDocs(member)) : void 0,
|
|
1885
|
+
textEdit: {
|
|
1886
|
+
range: replace(),
|
|
1887
|
+
newText: member.name
|
|
1888
|
+
}
|
|
1889
|
+
}));
|
|
1890
|
+
}
|
|
903
1891
|
case "loop": return Object.entries(LOOP_PROPERTIES).map(([label, description]) => ({
|
|
904
1892
|
label,
|
|
905
1893
|
kind: CompletionItemKind.Property,
|
|
@@ -916,14 +1904,17 @@ const complete = async (open, offset) => {
|
|
|
916
1904
|
}
|
|
917
1905
|
case "expression": {
|
|
918
1906
|
const analysis = name ? await analyse(project, name, text) : null;
|
|
1907
|
+
const { scopes } = templateTypes(open, text);
|
|
919
1908
|
const variables = /* @__PURE__ */ new Set([
|
|
920
1909
|
...analysis?.props.map((prop) => prop.name) ?? [],
|
|
921
1910
|
...analysis?.rest ? [analysis.rest] : [],
|
|
922
|
-
...
|
|
1911
|
+
...bindingsAt(scopes, offset).map((binding) => binding.name),
|
|
1912
|
+
...macrosIn(text).keys(),
|
|
923
1913
|
...manifest?.globals ?? []
|
|
924
1914
|
]);
|
|
925
1915
|
const props = new Map(analysis?.props.map((prop) => [prop.name, prop]));
|
|
926
1916
|
return [
|
|
1917
|
+
...await namedArgumentItems(open, text, offset),
|
|
927
1918
|
...[...variables].map((label) => {
|
|
928
1919
|
const prop = props.get(label);
|
|
929
1920
|
return {
|
|
@@ -933,7 +1924,7 @@ const complete = async (open, offset) => {
|
|
|
933
1924
|
};
|
|
934
1925
|
}),
|
|
935
1926
|
...builtinItems(FUNCTIONS, "functions", CompletionItemKind.Function, manifest?.functions ?? []),
|
|
936
|
-
...KEYWORDS.map((label) => ({
|
|
1927
|
+
...KEYWORDS$1.map((label) => ({
|
|
937
1928
|
label,
|
|
938
1929
|
kind: CompletionItemKind.Keyword
|
|
939
1930
|
}))
|
|
@@ -946,21 +1937,51 @@ const macrosIn = (text) => new Map([...text.matchAll(/\{%-?\s*macro\s+(\w+)\s*\(
|
|
|
946
1937
|
params: match[2].trim(),
|
|
947
1938
|
start: match.index
|
|
948
1939
|
}]));
|
|
949
|
-
const codeBlock = (code) =>
|
|
1940
|
+
const codeBlock = (code, language = "kiln") => `\`\`\`${language}\n${code}\n\`\`\``;
|
|
950
1941
|
/** A hover's parts, such as a signature and its docs, with a rule between each. Missing parts are left out. */
|
|
951
1942
|
const sections = (...parts) => parts.filter(Boolean).join("\n\n---\n\n");
|
|
952
1943
|
/** A tag's syntax, from its completion snippet: named placeholders become their names, and the rest become `…`. */
|
|
953
1944
|
const tagSyntax = (snippet) => `{% ${snippet.replace(/\$\{\d+:([^}]*)\}/g, "$1").replace(/\$\{?\d+\}?/g, "…")}`;
|
|
1945
|
+
/** A registered function, filter or test as the project's TypeScript describes it: its signature, then its docs. */
|
|
1946
|
+
const describedCallable = async (open, kind, name) => {
|
|
1947
|
+
const typescript = await open.project.typescript();
|
|
1948
|
+
const described = typescript ? describeCallable(typescript, kind, name) : null;
|
|
1949
|
+
return described ? sections(codeBlock(described.label, "ts"), memberDocs(described.member)) : null;
|
|
1950
|
+
};
|
|
1951
|
+
/** The variable a name refers to: the one it declares when the cursor is on a declaration, or the one in scope. */
|
|
1952
|
+
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);
|
|
1953
|
+
/** The property under the cursor in `a.b`, and what TypeScript knows about it. */
|
|
1954
|
+
const memberAt = async (open, text, offset) => {
|
|
1955
|
+
const types = templateTypes(open, text);
|
|
1956
|
+
const property = propertyAt(types.scopes, text, offset);
|
|
1957
|
+
if (!property) return null;
|
|
1958
|
+
const member = (await membersOf(open, property.object, types.context)).find((item) => item.name === property.name);
|
|
1959
|
+
return member ? {
|
|
1960
|
+
member,
|
|
1961
|
+
span: property.span
|
|
1962
|
+
} : null;
|
|
1963
|
+
};
|
|
954
1964
|
const hover = async (open, offset) => {
|
|
955
1965
|
const { document, project, name } = open;
|
|
956
1966
|
const text = document.getText();
|
|
957
1967
|
const symbol = symbolAt(text, offset);
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1968
|
+
const rangeOf = (span) => ({
|
|
1969
|
+
start: document.positionAt(span.start),
|
|
1970
|
+
end: document.positionAt(span.end)
|
|
1971
|
+
});
|
|
1972
|
+
if (!symbol) {
|
|
1973
|
+
if (!enclosingDelimiter(text, offset)) return htmlHover(document, offset);
|
|
1974
|
+
const found = await memberAt(open, text, offset);
|
|
1975
|
+
if (!found) return null;
|
|
1976
|
+
const { member } = found;
|
|
1977
|
+
return {
|
|
1978
|
+
contents: markdown(sections(codeBlock(`${member.name}${member.optional ? "?" : ""}: ${member.type}`, "ts"), memberDocs(member))),
|
|
1979
|
+
range: rangeOf(found.span)
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
963
1982
|
const fromProject = (names, what) => names?.includes(symbol.name) ? `A ${what} your project registers.` : null;
|
|
1983
|
+
/** Whether the project registers a name, which replaces a built-in with the same name. */
|
|
1984
|
+
const registers = (names) => names?.includes(symbol.name) ?? false;
|
|
964
1985
|
let value = null;
|
|
965
1986
|
/** A built-in's signature, then its docs. */
|
|
966
1987
|
const builtin = (signature, kind) => sections(codeBlock(signature), docsFor(kind, symbol.name));
|
|
@@ -973,12 +1994,12 @@ const hover = async (open, offset) => {
|
|
|
973
1994
|
}
|
|
974
1995
|
case "filter": {
|
|
975
1996
|
const doc = FILTERS[symbol.name];
|
|
976
|
-
value = doc ? builtin(`|${doc.signature}`, "filters") : fromProject(project.manifest?.filters, "filter");
|
|
1997
|
+
value = doc && !registers(project.manifest?.filters) ? builtin(`|${doc.signature}`, "filters") : await describedCallable(open, "filter", symbol.name) ?? fromProject(project.manifest?.filters, "filter");
|
|
977
1998
|
break;
|
|
978
1999
|
}
|
|
979
2000
|
case "test": {
|
|
980
2001
|
const doc = TESTS[symbol.name];
|
|
981
|
-
value = doc ? builtin(`is ${doc.signature}`, "tests") : fromProject(project.manifest?.tests, "test");
|
|
2002
|
+
value = doc && !registers(project.manifest?.tests) ? builtin(`is ${doc.signature}`, "tests") : await describedCallable(open, "test", symbol.name) ?? fromProject(project.manifest?.tests, "test");
|
|
982
2003
|
break;
|
|
983
2004
|
}
|
|
984
2005
|
case "loop": {
|
|
@@ -990,16 +2011,25 @@ const hover = async (open, offset) => {
|
|
|
990
2011
|
const macro = macrosIn(text).get(symbol.name);
|
|
991
2012
|
const doc = FUNCTIONS[symbol.name];
|
|
992
2013
|
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");
|
|
2014
|
+
else if (doc && !registers(project.manifest?.functions)) value = builtin(doc.signature, "functions");
|
|
2015
|
+
else value = await describedCallable(open, "function", symbol.name) ?? fromProject(project.manifest?.functions, "function");
|
|
995
2016
|
break;
|
|
996
2017
|
}
|
|
997
2018
|
case "variable": {
|
|
998
|
-
const
|
|
999
|
-
const
|
|
1000
|
-
if (
|
|
1001
|
-
|
|
1002
|
-
|
|
2019
|
+
const types = templateTypes(open, text);
|
|
2020
|
+
const binding = bindingFor(types.scopes, symbol);
|
|
2021
|
+
if (binding?.source.kind === "prop" || binding?.source.kind === "rest") {
|
|
2022
|
+
const prop = (name ? await analyse(project, name, text) : null)?.props.find((item) => item.name === symbol.name);
|
|
2023
|
+
value = sections(codeBlock(prop ? describeProp(prop) : `...${symbol.name}`), BINDING_NOTES[binding.source.kind]);
|
|
2024
|
+
} else if (binding) {
|
|
2025
|
+
const typescript = await project.typescript();
|
|
2026
|
+
const writer = typeWriter(types.context);
|
|
2027
|
+
const type = typescript ? withTypes(typescript, { Value: writer.binding(binding.source) }, (read) => read.describe("Value"), writer.statements) : null;
|
|
2028
|
+
value = sections(codeBlock(type && type !== "unknown" ? `${symbol.name}: ${type}` : symbol.name, "ts"), BINDING_NOTES[binding.source.kind]);
|
|
2029
|
+
} else {
|
|
2030
|
+
const global = await registered(open, "global", symbol.name);
|
|
2031
|
+
value = global ? sections(codeBlock(`${symbol.name}: ${global.type}`, "ts"), memberDocs(global)) : project.manifest?.globals.includes(symbol.name) ? "A global your project provides." : null;
|
|
2032
|
+
}
|
|
1003
2033
|
break;
|
|
1004
2034
|
}
|
|
1005
2035
|
case "template": {
|
|
@@ -1021,22 +2051,19 @@ const hover = async (open, offset) => {
|
|
|
1021
2051
|
}
|
|
1022
2052
|
return value ? {
|
|
1023
2053
|
contents: markdown(value),
|
|
1024
|
-
range
|
|
2054
|
+
range: rangeOf(symbol)
|
|
1025
2055
|
} : null;
|
|
1026
2056
|
};
|
|
1027
|
-
const
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
end:
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
});
|
|
2057
|
+
const point = (line, character) => {
|
|
2058
|
+
const position = {
|
|
2059
|
+
line,
|
|
2060
|
+
character
|
|
2061
|
+
};
|
|
2062
|
+
return {
|
|
2063
|
+
start: position,
|
|
2064
|
+
end: position
|
|
2065
|
+
};
|
|
2066
|
+
};
|
|
1040
2067
|
/** The line and column of an offset in another template's text. */
|
|
1041
2068
|
const positionIn = (text, offset) => {
|
|
1042
2069
|
const lines = text.slice(0, offset).split("\n");
|
|
@@ -1045,28 +2072,63 @@ const positionIn = (text, offset) => {
|
|
|
1045
2072
|
character: lines.at(-1).length
|
|
1046
2073
|
};
|
|
1047
2074
|
};
|
|
2075
|
+
/**
|
|
2076
|
+
* Where the name under the cursor is defined. The link's origin is the whole name, so editors underline all of
|
|
2077
|
+
* `"layouts/base"` rather than one word of it.
|
|
2078
|
+
*
|
|
2079
|
+
* Variables go to where the template declares them. Without `kiln.json`, template names have no root, so nothing in
|
|
2080
|
+
* other templates is found. With the project's TypeScript, its functions, filters, tests, globals and the properties of
|
|
2081
|
+
* typed values go to their TypeScript source.
|
|
2082
|
+
*/
|
|
1048
2083
|
const definition = async (open, offset) => {
|
|
1049
2084
|
const { document, project, name } = open;
|
|
1050
2085
|
const text = document.getText();
|
|
2086
|
+
const link = (origin, uri, range) => ({
|
|
2087
|
+
originSelectionRange: {
|
|
2088
|
+
start: document.positionAt(origin.start),
|
|
2089
|
+
end: document.positionAt(origin.end)
|
|
2090
|
+
},
|
|
2091
|
+
targetUri: uri,
|
|
2092
|
+
targetRange: range,
|
|
2093
|
+
targetSelectionRange: range
|
|
2094
|
+
});
|
|
2095
|
+
const inSource = (origin, location) => link(origin, pathToFileURL(location.path).href, point(location.line, location.character));
|
|
1051
2096
|
const symbol = symbolAt(text, offset);
|
|
1052
|
-
if (!symbol)
|
|
2097
|
+
if (!symbol) {
|
|
2098
|
+
if (!enclosingDelimiter(text, offset)) return null;
|
|
2099
|
+
const found = await memberAt(open, text, offset);
|
|
2100
|
+
return found?.member.location ? inSource(found.span, found.member.location) : null;
|
|
2101
|
+
}
|
|
2102
|
+
const inTemplate = (template, line = 0, character = 0) => link(symbol, pathToFileURL(pathOf(project, template)).href, point(line, character));
|
|
1053
2103
|
const load = (template) => project.overlay.get(template) ?? readTemplate(project, template);
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
2104
|
+
/** Goes to a function, filter, test or global in the project's TypeScript. */
|
|
2105
|
+
const inTypeScript = async (kind) => {
|
|
2106
|
+
const member = await registered(open, kind, symbol.name);
|
|
2107
|
+
return member?.location ? inSource(symbol, member.location) : null;
|
|
2108
|
+
};
|
|
2109
|
+
const blockName = symbol.kind === "variable" && /\{%-?\s*block\s+$/.test(text.slice(Math.max(0, symbol.start - 40), symbol.start));
|
|
2110
|
+
if (symbol.kind === "variable" && !blockName) {
|
|
2111
|
+
const binding = bindingFor(templateTypes(open, text).scopes, symbol);
|
|
2112
|
+
if (binding) return link(symbol, document.uri, {
|
|
2113
|
+
start: document.positionAt(binding.definition.start),
|
|
2114
|
+
end: document.positionAt(binding.definition.end)
|
|
2115
|
+
});
|
|
2116
|
+
return inTypeScript("global");
|
|
1057
2117
|
}
|
|
1058
2118
|
if (symbol.kind === "function") {
|
|
1059
2119
|
const local = macrosIn(text).get(symbol.name);
|
|
1060
2120
|
if (local) {
|
|
1061
2121
|
const position = document.positionAt(local.start);
|
|
1062
|
-
return
|
|
1063
|
-
uri: document.uri,
|
|
1064
|
-
range: {
|
|
1065
|
-
start: position,
|
|
1066
|
-
end: position
|
|
1067
|
-
}
|
|
1068
|
-
};
|
|
2122
|
+
return link(symbol, document.uri, point(position.line, position.character));
|
|
1069
2123
|
}
|
|
2124
|
+
}
|
|
2125
|
+
if (!project.manifest) return null;
|
|
2126
|
+
if (symbol.kind === "template") {
|
|
2127
|
+
const target = resolveName(symbol.name, name ?? void 0);
|
|
2128
|
+
return target ? inTemplate(target) : null;
|
|
2129
|
+
}
|
|
2130
|
+
if (symbol.kind === "filter" || symbol.kind === "test") return inTypeScript(symbol.kind);
|
|
2131
|
+
if (symbol.kind === "function") {
|
|
1070
2132
|
for (const match of text.matchAll(/\{%-?\s*from\s+(["'])([^"']*)\1\s+import\s+([^%]*)%\}/g)) for (const part of match[3].split(",")) {
|
|
1071
2133
|
const [original, alias = original] = part.trim().split(/\s+as\s+/);
|
|
1072
2134
|
if (alias !== symbol.name || !original) continue;
|
|
@@ -1075,13 +2137,12 @@ const definition = async (open, offset) => {
|
|
|
1075
2137
|
const macro = source ? macrosIn(source).get(original) : null;
|
|
1076
2138
|
if (target && source && macro) {
|
|
1077
2139
|
const position = positionIn(source, macro.start);
|
|
1078
|
-
return
|
|
2140
|
+
return inTemplate(target, position.line, position.character);
|
|
1079
2141
|
}
|
|
1080
2142
|
}
|
|
1081
|
-
return
|
|
2143
|
+
return inTypeScript("function");
|
|
1082
2144
|
}
|
|
1083
|
-
|
|
1084
|
-
if (symbol.kind === "variable" && tag && name) {
|
|
2145
|
+
if (blockName && name) {
|
|
1085
2146
|
let current = name;
|
|
1086
2147
|
for (let depth = 0; depth < 20; depth++) {
|
|
1087
2148
|
const parent = (await analyse(project, current, current === name ? text : void 0))?.templates.find((ref) => ref.kind === "extends")?.name;
|
|
@@ -1090,7 +2151,7 @@ const definition = async (open, offset) => {
|
|
|
1090
2151
|
const block = source ? new RegExp(`\\{%-?\\s*block\\s+${symbol.name}\\b`).exec(source) : null;
|
|
1091
2152
|
if (source && block) {
|
|
1092
2153
|
const position = positionIn(source, block.index);
|
|
1093
|
-
return
|
|
2154
|
+
return inTemplate(parent, position.line, position.character);
|
|
1094
2155
|
}
|
|
1095
2156
|
current = parent;
|
|
1096
2157
|
}
|
|
@@ -1144,6 +2205,193 @@ const symbols = (document) => {
|
|
|
1144
2205
|
}
|
|
1145
2206
|
return found;
|
|
1146
2207
|
};
|
|
2208
|
+
/** Whether the project registers a function or filter, which replaces a built-in with the same name. */
|
|
2209
|
+
const isRegistered = (project, kind, name) => (kind === "function" ? project.manifest?.functions : project.manifest?.filters)?.includes(name) ?? false;
|
|
2210
|
+
/** A built-in's documented signature, such as `round(precision?, method?)`, when it takes arguments. */
|
|
2211
|
+
const builtinSignature = (kind, name) => {
|
|
2212
|
+
const doc = (kind === "function" ? FUNCTIONS : FILTERS)[name];
|
|
2213
|
+
if (!doc?.signature.includes("(")) return null;
|
|
2214
|
+
const label = `${kind === "filter" ? "|" : ""}${doc.signature}`;
|
|
2215
|
+
const open = label.indexOf("(") + 1;
|
|
2216
|
+
const list = label.slice(open, label.lastIndexOf(")"));
|
|
2217
|
+
return {
|
|
2218
|
+
label,
|
|
2219
|
+
parameters: splitParameters(list).map((part) => ({
|
|
2220
|
+
name: /^(?:\.\.\.)?(\w+)/.exec(part.text)?.[1] ?? part.text,
|
|
2221
|
+
start: open + part.start,
|
|
2222
|
+
end: open + part.end
|
|
2223
|
+
}))
|
|
2224
|
+
};
|
|
2225
|
+
};
|
|
2226
|
+
/** Named arguments the function or filter at the cursor takes, such as `limit:`, when an argument could start there. */
|
|
2227
|
+
const namedArgumentItems = async (open, text, offset) => {
|
|
2228
|
+
const call = callContext(text, offset);
|
|
2229
|
+
if (!call?.namePosition) return [];
|
|
2230
|
+
const range = {
|
|
2231
|
+
start: open.document.positionAt(call.start),
|
|
2232
|
+
end: open.document.positionAt(offset)
|
|
2233
|
+
};
|
|
2234
|
+
const item = (label, detail, docs) => ({
|
|
2235
|
+
label: `${label}:`,
|
|
2236
|
+
kind: CompletionItemKind.Field,
|
|
2237
|
+
detail,
|
|
2238
|
+
documentation: docs ? markdown(docs) : void 0,
|
|
2239
|
+
sortText: `0${label}`,
|
|
2240
|
+
textEdit: {
|
|
2241
|
+
range,
|
|
2242
|
+
newText: `${label}: `
|
|
2243
|
+
}
|
|
2244
|
+
});
|
|
2245
|
+
const builtin = isRegistered(open.project, call.kind, call.name) ? null : builtinSignature(call.kind, call.name);
|
|
2246
|
+
if (builtin) return builtin.parameters.map((param) => item(param.name));
|
|
2247
|
+
if (call.kind === "function" && macrosIn(text).has(call.name)) return [];
|
|
2248
|
+
const typescript = await open.project.typescript();
|
|
2249
|
+
if (!typescript) return [];
|
|
2250
|
+
return namedOptions(typescript, call.kind, call.name).map((member) => item(member.name, `${member.name}${member.optional ? "?" : ""}: ${member.type}`, memberDocs(member)));
|
|
2251
|
+
};
|
|
2252
|
+
/**
|
|
2253
|
+
* The parameters of the function or filter whose arguments the cursor is in, with the one it's on active. Named
|
|
2254
|
+
* arguments are gathered into the last parameter, so a named argument makes that one active and shows its own type.
|
|
2255
|
+
*/
|
|
2256
|
+
const signatureHelp = async (open, offset) => {
|
|
2257
|
+
const text = open.document.getText();
|
|
2258
|
+
const call = callContext(text, offset);
|
|
2259
|
+
if (!call) return null;
|
|
2260
|
+
let signature = null;
|
|
2261
|
+
const macro = call.kind === "function" ? macrosIn(text).get(call.name) : null;
|
|
2262
|
+
if (macro) signature = {
|
|
2263
|
+
label: `${call.name}(${macro.params})`,
|
|
2264
|
+
parameters: splitParameters(macro.params).map((part) => ({
|
|
2265
|
+
name: /^\w+/.exec(part.text)?.[0] ?? part.text,
|
|
2266
|
+
start: call.name.length + 1 + part.start,
|
|
2267
|
+
end: call.name.length + 1 + part.end
|
|
2268
|
+
}))
|
|
2269
|
+
};
|
|
2270
|
+
else if (!isRegistered(open.project, call.kind, call.name)) {
|
|
2271
|
+
const builtin = builtinSignature(call.kind, call.name);
|
|
2272
|
+
if (builtin) signature = {
|
|
2273
|
+
...builtin,
|
|
2274
|
+
docs: docsFor(call.kind === "function" ? "functions" : "filters", call.name)
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
const typescript = signature ? null : await open.project.typescript();
|
|
2278
|
+
if (typescript) {
|
|
2279
|
+
const described = describeCallable(typescript, call.kind, call.name);
|
|
2280
|
+
if (described) {
|
|
2281
|
+
const params = new Map(described.member.tags.flatMap((tag) => {
|
|
2282
|
+
const match = /^(\w+)\s*(.*)$/s.exec(tag.text);
|
|
2283
|
+
return tag.name === "param" && match ? [[match[1], match[2]]] : [];
|
|
2284
|
+
}));
|
|
2285
|
+
signature = {
|
|
2286
|
+
label: described.label,
|
|
2287
|
+
parameters: described.parameters,
|
|
2288
|
+
docs: described.member.docs,
|
|
2289
|
+
paramDocs: params
|
|
2290
|
+
};
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
if (!signature || signature.parameters.length === 0) return null;
|
|
2294
|
+
const last = signature.parameters.length - 1;
|
|
2295
|
+
const active = call.named || call.sawNamed ? last : Math.min(call.index, last);
|
|
2296
|
+
let namedDocs = null;
|
|
2297
|
+
if (call.named && typescript) {
|
|
2298
|
+
const option = namedOptions(typescript, call.kind, call.name).find((member) => member.name === call.named);
|
|
2299
|
+
if (option) namedDocs = sections(codeBlock(`${option.name}${option.optional ? "?" : ""}: ${option.type}`, "ts"), memberDocs(option));
|
|
2300
|
+
}
|
|
2301
|
+
const found = signature;
|
|
2302
|
+
return {
|
|
2303
|
+
signatures: [{
|
|
2304
|
+
label: found.label,
|
|
2305
|
+
documentation: found.docs ? markdown(found.docs) : void 0,
|
|
2306
|
+
parameters: found.parameters.map((param, index) => {
|
|
2307
|
+
const docs = index === active && namedDocs ? namedDocs : found.paramDocs?.get(param.name);
|
|
2308
|
+
return {
|
|
2309
|
+
label: [param.start, param.end],
|
|
2310
|
+
documentation: docs ? markdown(docs) : void 0
|
|
2311
|
+
};
|
|
2312
|
+
})
|
|
2313
|
+
}],
|
|
2314
|
+
activeSignature: 0,
|
|
2315
|
+
activeParameter: active
|
|
2316
|
+
};
|
|
2317
|
+
};
|
|
2318
|
+
/**
|
|
2319
|
+
* Checks the arguments of calls to the project's own functions and filters against their TypeScript types. A filter's
|
|
2320
|
+
* value before the `|` is its first argument. Calls with `...` spreads, and arguments whose type is unknown, aren't
|
|
2321
|
+
* checked.
|
|
2322
|
+
*/
|
|
2323
|
+
const argumentWarnings = async (open) => {
|
|
2324
|
+
const typescript = await open.project.typescript();
|
|
2325
|
+
if (!typescript) return [];
|
|
2326
|
+
const text = open.document.getText();
|
|
2327
|
+
const { scopes, context } = templateTypes(open, text);
|
|
2328
|
+
const types = typeWriter(context);
|
|
2329
|
+
/**
|
|
2330
|
+
* Each check, with what it's for: a filter's input, a positional or a named argument. A wrong type is reported on
|
|
2331
|
+
* the value, and a named argument the function doesn't take on its name.
|
|
2332
|
+
*/
|
|
2333
|
+
const checks = [];
|
|
2334
|
+
for (const node of expressionsIn(scopes.ast.body)) {
|
|
2335
|
+
let kind;
|
|
2336
|
+
let name;
|
|
2337
|
+
if (node.type === "Call" && node.callee.type === "Name") {
|
|
2338
|
+
kind = "function";
|
|
2339
|
+
name = node.callee.name;
|
|
2340
|
+
if (context.macros.has(name)) continue;
|
|
2341
|
+
} else if (node.type === "Filter") {
|
|
2342
|
+
kind = "filter";
|
|
2343
|
+
name = node.name;
|
|
2344
|
+
} else continue;
|
|
2345
|
+
if ((kind === "function" ? FUNCTIONS : FILTERS)[name] && !isRegistered(open.project, kind, name)) continue;
|
|
2346
|
+
if (node.args.some((arg) => arg.type === "Spread")) continue;
|
|
2347
|
+
const parameters = parametersType(kind, name, true);
|
|
2348
|
+
[...node.type === "Filter" ? [node.input] : [], ...node.args.flatMap((arg) => arg.type === "Positional" ? [arg.value] : [])].forEach((value, index) => {
|
|
2349
|
+
checks.push({
|
|
2350
|
+
type: types.expression(value),
|
|
2351
|
+
parameters,
|
|
2352
|
+
index,
|
|
2353
|
+
span: value.span,
|
|
2354
|
+
callee: name,
|
|
2355
|
+
role: node.type === "Filter" && index === 0 ? "input" : "positional"
|
|
2356
|
+
});
|
|
2357
|
+
});
|
|
2358
|
+
for (const arg of node.args) {
|
|
2359
|
+
if (arg.type !== "Named") continue;
|
|
2360
|
+
checks.push({
|
|
2361
|
+
type: types.expression(arg.value),
|
|
2362
|
+
parameters,
|
|
2363
|
+
name: arg.name,
|
|
2364
|
+
span: arg.value.span,
|
|
2365
|
+
nameSpan: {
|
|
2366
|
+
start: arg.span.start,
|
|
2367
|
+
end: arg.span.start + arg.name.length
|
|
2368
|
+
},
|
|
2369
|
+
callee: name,
|
|
2370
|
+
role: "named"
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
return checkArguments(typescript, checks, types.statements).flatMap((problem, index) => {
|
|
2375
|
+
const check = checks[index];
|
|
2376
|
+
if (!problem || !check) return [];
|
|
2377
|
+
if (problem.kind === "missing") {
|
|
2378
|
+
const option = "name" in check ? check.name : "";
|
|
2379
|
+
return [{
|
|
2380
|
+
...check.nameSpan ?? check.span,
|
|
2381
|
+
code: "unknown-argument",
|
|
2382
|
+
message: `\`${check.callee}\` doesn't take \`${option}\`.`
|
|
2383
|
+
}];
|
|
2384
|
+
}
|
|
2385
|
+
const expected = `\`${problem.expected}\``;
|
|
2386
|
+
const actual = `not \`${problem.actual}\``;
|
|
2387
|
+
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}.`;
|
|
2388
|
+
return [{
|
|
2389
|
+
...check.span,
|
|
2390
|
+
code: "argument-type",
|
|
2391
|
+
message
|
|
2392
|
+
}];
|
|
2393
|
+
});
|
|
2394
|
+
};
|
|
1147
2395
|
//#endregion
|
|
1148
2396
|
//#region src/server.ts
|
|
1149
2397
|
/** Serves Kiln templates over a language server connection. */
|
|
@@ -1151,11 +2399,14 @@ const startServer = (connection) => {
|
|
|
1151
2399
|
const documents = new TextDocuments(TextDocument);
|
|
1152
2400
|
let workspaceFolders = [];
|
|
1153
2401
|
let canWatchFiles = false;
|
|
2402
|
+
/** Whether the client takes `LocationLink`s from go to definition. Plain locations lose the clicked range. */
|
|
2403
|
+
let definitionLinks = false;
|
|
1154
2404
|
/** Open projects, by the path of their `kiln.json`, or their folder when they have none. */
|
|
1155
2405
|
const projects = /* @__PURE__ */ new Map();
|
|
1156
2406
|
connection.onInitialize((params) => {
|
|
1157
2407
|
workspaceFolders = (params.workspaceFolders ?? []).map((folder) => folder.uri).concat(params.rootUri && !params.workspaceFolders ? [params.rootUri] : []).filter((uri) => uri.startsWith("file:")).map((uri) => fileURLToPath(uri));
|
|
1158
2408
|
canWatchFiles = params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
|
|
2409
|
+
definitionLinks = params.capabilities.textDocument?.definition?.linkSupport === true;
|
|
1159
2410
|
return {
|
|
1160
2411
|
capabilities: {
|
|
1161
2412
|
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
@@ -1173,6 +2424,10 @@ const startServer = (connection) => {
|
|
|
1173
2424
|
"="
|
|
1174
2425
|
] },
|
|
1175
2426
|
hoverProvider: true,
|
|
2427
|
+
signatureHelpProvider: {
|
|
2428
|
+
triggerCharacters: ["(", ","],
|
|
2429
|
+
retriggerCharacters: [":", " "]
|
|
2430
|
+
},
|
|
1176
2431
|
definitionProvider: true,
|
|
1177
2432
|
documentLinkProvider: { resolveProvider: false },
|
|
1178
2433
|
documentSymbolProvider: true,
|
|
@@ -1184,7 +2439,12 @@ const startServer = (connection) => {
|
|
|
1184
2439
|
});
|
|
1185
2440
|
connection.onInitialized(() => {
|
|
1186
2441
|
if (!canWatchFiles) return;
|
|
1187
|
-
connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [
|
|
2442
|
+
connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [
|
|
2443
|
+
{ globPattern: "**/*.kiln" },
|
|
2444
|
+
{ globPattern: "**/kiln.json" },
|
|
2445
|
+
{ globPattern: "**/*.{ts,tsx,mts,cts}" },
|
|
2446
|
+
{ globPattern: "**/tsconfig*.json" }
|
|
2447
|
+
] });
|
|
1188
2448
|
});
|
|
1189
2449
|
/** The project a file belongs to: the nearest `kiln.json` inside its workspace folder, or the folder itself. */
|
|
1190
2450
|
const projectFor = async (path) => {
|
|
@@ -1227,6 +2487,7 @@ const startServer = (connection) => {
|
|
|
1227
2487
|
const { project, name } = open;
|
|
1228
2488
|
const version = document.version;
|
|
1229
2489
|
const diagnostics = await diagnose(project, name, document.getText()).catch(() => []);
|
|
2490
|
+
const warnings = await argumentWarnings(open).catch(() => []);
|
|
1230
2491
|
if (documents.get(document.uri)?.version !== version) return;
|
|
1231
2492
|
connection.sendDiagnostics({
|
|
1232
2493
|
uri: document.uri,
|
|
@@ -1240,7 +2501,16 @@ const startServer = (connection) => {
|
|
|
1240
2501
|
code: diagnostic.code,
|
|
1241
2502
|
source: "kiln",
|
|
1242
2503
|
message: diagnostic.message
|
|
1243
|
-
}))
|
|
2504
|
+
})).concat(warnings.map((warning) => ({
|
|
2505
|
+
range: {
|
|
2506
|
+
start: document.positionAt(warning.start),
|
|
2507
|
+
end: document.positionAt(warning.end)
|
|
2508
|
+
},
|
|
2509
|
+
severity: DiagnosticSeverity.Warning,
|
|
2510
|
+
code: warning.code,
|
|
2511
|
+
source: "kiln",
|
|
2512
|
+
message: warning.message
|
|
2513
|
+
})))
|
|
1244
2514
|
});
|
|
1245
2515
|
};
|
|
1246
2516
|
/** Every open template in a project sees an edit to any of them, since one template's props can break another. */
|
|
@@ -1266,9 +2536,22 @@ const startServer = (connection) => {
|
|
|
1266
2536
|
open.project.kiln.invalidate(open.name);
|
|
1267
2537
|
await checkProject(open.project);
|
|
1268
2538
|
});
|
|
2539
|
+
/** Closes every open project, so the next request opens them again from their settings. */
|
|
2540
|
+
const closeProjects = async () => {
|
|
2541
|
+
const open = [...projects.values()];
|
|
2542
|
+
projects.clear();
|
|
2543
|
+
for (const project of open) (await project).close();
|
|
2544
|
+
};
|
|
2545
|
+
connection.onShutdown(closeProjects);
|
|
1269
2546
|
connection.onDidChangeWatchedFiles(async ({ changes }) => {
|
|
1270
|
-
|
|
1271
|
-
|
|
2547
|
+
const isSettings = (uri) => uri.endsWith("/kiln.json") || /\/tsconfig[^/]*\.json$/.test(uri);
|
|
2548
|
+
const typescriptChanges = changes.flatMap((change) => change.uri.startsWith("file:") && /\.[cm]?tsx?$/.test(change.uri) ? [{
|
|
2549
|
+
path: fileURLToPath(change.uri),
|
|
2550
|
+
kind: change.type === FileChangeType.Created ? "created" : change.type === FileChangeType.Deleted ? "deleted" : "changed"
|
|
2551
|
+
}] : []);
|
|
2552
|
+
if (typescriptChanges.length > 0) for (const project of projects.values()) (await project).typescriptChanged(typescriptChanges);
|
|
2553
|
+
if (changes.some((change) => isSettings(change.uri))) {
|
|
2554
|
+
await closeProjects();
|
|
1272
2555
|
for (const document of documents.all()) {
|
|
1273
2556
|
const open = await openTemplate(document);
|
|
1274
2557
|
if (open?.name) {
|
|
@@ -1277,7 +2560,7 @@ const startServer = (connection) => {
|
|
|
1277
2560
|
}
|
|
1278
2561
|
}
|
|
1279
2562
|
} else for (const change of changes) {
|
|
1280
|
-
if (!change.uri.startsWith("file:")) continue;
|
|
2563
|
+
if (!change.uri.startsWith("file:") || !change.uri.endsWith(".kiln")) continue;
|
|
1281
2564
|
const path = fileURLToPath(change.uri);
|
|
1282
2565
|
const project = await projectFor(path);
|
|
1283
2566
|
const name = nameOf(project, path);
|
|
@@ -1293,7 +2576,15 @@ const startServer = (connection) => {
|
|
|
1293
2576
|
};
|
|
1294
2577
|
connection.onCompletion(withTemplate([], (open, params) => complete(open, open.document.offsetAt(params.position))));
|
|
1295
2578
|
connection.onHover(withTemplate(null, (open, params) => hover(open, open.document.offsetAt(params.position))));
|
|
1296
|
-
connection.
|
|
2579
|
+
connection.onSignatureHelp(withTemplate(null, (open, params) => signatureHelp(open, open.document.offsetAt(params.position))));
|
|
2580
|
+
connection.onDefinition(withTemplate(null, async (open, params) => {
|
|
2581
|
+
const link = await definition(open, open.document.offsetAt(params.position));
|
|
2582
|
+
if (!link || definitionLinks) return link && [link];
|
|
2583
|
+
return {
|
|
2584
|
+
uri: link.targetUri,
|
|
2585
|
+
range: link.targetSelectionRange
|
|
2586
|
+
};
|
|
2587
|
+
}));
|
|
1297
2588
|
connection.onDocumentLinks(withTemplate([], (open) => links(open)));
|
|
1298
2589
|
connection.onDocumentSymbol(withTemplate([], (open) => symbols(open.document)));
|
|
1299
2590
|
/** Formats a template, or part of one, as edits. Templates with syntax errors are left alone. */
|