@lucidkiln/language-server 1.0.0 → 1.1.0

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