@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 CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { DiagnosticSeverity, DidChangeWatchedFilesNotification, ProposedFeatures, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node";
3
- import { dirname, isAbsolute, join, relative, sep } from "node:path";
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 lines up with the outlet's indentation.\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).",
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
- /** Names a template sets or brings into scope before `offset`: loop variables, sets, macros and imports. */
837
- const localNames = (text, offset) => {
838
- const before = text.slice(0, offset);
839
- const names = /* @__PURE__ */ new Set();
840
- for (const match of before.matchAll(/\{%-?\s*for\s+(\w+)(?:\s*,\s*(\w+))?\s+in\b/g)) {
841
- names.add(match[1]);
842
- if (match[2]) names.add(match[2]);
843
- }
844
- for (const match of before.matchAll(/\{%-?\s*(?:set|macro)\s+(\w+)/g)) names.add(match[1]);
845
- for (const match of before.matchAll(/\{%-?\s*import\s+["'][^"']*["']\s+as\s+(\w+)/g)) names.add(match[1]);
846
- for (const match of before.matchAll(/\{%-?\s*from\s+["'][^"']*["']\s+import\s+([^%]*)%\}/g)) for (const part of match[1].split(",")) {
847
- const alias = /(\w+)\s*$/.exec(part.trim())?.[1];
848
- if (alias) names.add(alias);
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 [...names];
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
- ...localNames(text, offset),
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) => `\`\`\`kiln\n${code}\n\`\`\``;
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
- if (!symbol) return enclosingDelimiter(text, offset) ? null : htmlHover(document, offset);
959
- const range = {
960
- start: document.positionAt(symbol.start),
961
- end: document.positionAt(symbol.end)
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 analysis = name ? await analyse(project, name, text) : null;
999
- const prop = analysis?.props.find((item) => item.name === symbol.name);
1000
- if (prop) value = sections(codeBlock(describeProp(prop)), "A prop of this template.");
1001
- else if (symbol.name === analysis?.rest) value = sections(codeBlock(`...${symbol.name}`), "The variables passed to this template that it doesn't declare.");
1002
- else if (project.manifest?.globals.includes(symbol.name)) value = "A global your project provides.";
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 fileLocation = (project, name, line = 0, character = 0) => ({
1028
- uri: pathToFileURL(pathOf(project, name)).href,
1029
- range: {
1030
- start: {
1031
- line,
1032
- character
1033
- },
1034
- end: {
1035
- line,
1036
- character
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) return null;
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
- if (symbol.kind === "template") {
1055
- const target = resolveName(symbol.name, name ?? void 0);
1056
- return target ? fileLocation(project, target) : null;
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 fileLocation(project, target, position.line, position.character);
2140
+ return inTemplate(target, position.line, position.character);
1079
2141
  }
1080
2142
  }
1081
- return null;
2143
+ return inTypeScript("function");
1082
2144
  }
1083
- const tag = /\{%-?\s*block\s+$/.exec(text.slice(Math.max(0, symbol.start - 40), symbol.start));
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 fileLocation(project, parent, position.line, position.character);
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: [{ globPattern: "**/*.kiln" }, { globPattern: "**/kiln.json" }] });
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
- if (changes.some((change) => change.uri.endsWith("/kiln.json"))) {
1271
- projects.clear();
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.onDefinition(withTemplate(null, (open, params) => definition(open, open.document.offsetAt(params.position))));
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. */