@forinda/kickjs-view-language-server 0.1.2 → 0.3.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @forinda/kickjs-view-language-server v0.1.2
2
+ * @forinda/kickjs-view-language-server v0.3.0
3
3
  *
4
4
  * Copyright (c) Felix Orinda
5
5
  *
@@ -32,7 +32,7 @@ const EMBEDDED = {
32
32
  ...ALL,
33
33
  format: false
34
34
  };
35
- const KEYWORD$1 = /^@(else\s+if|if|for|switch|case|slot|layout|assets|verbatim|let|input|else|empty|default)(?![\w$-])/;
35
+ const KEYWORD$1 = /^@(else\s+if|if|for|switch|case|slot|error|layout|assets|verbatim|let|input|else|empty|default)(?![\w$-])/;
36
36
  const snapshotOf = (text) => ({
37
37
  getText: (start, end) => text.slice(start, end),
38
38
  getLength: () => text.length,
@@ -53,6 +53,48 @@ function closeParen$1(s, i) {
53
53
  }
54
54
  return s.length;
55
55
  }
56
+ /**
57
+ * Where an `@let` value from `from` ends: its `;` outside quotes, as the
58
+ * parser reads it; the line's end while it's unterminated, so typing one
59
+ * doesn't swallow the lines below.
60
+ */
61
+ function letEnd(s, from) {
62
+ let quote = "";
63
+ for (let j = from; j < s.length; j++) {
64
+ const c = s[j];
65
+ if (c === "\n") return {
66
+ at: j,
67
+ closed: false
68
+ };
69
+ if (quote) {
70
+ if (c === "\\") j++;
71
+ else if (c === quote) quote = "";
72
+ } else if (c === "'" || c === "\"") quote = c;
73
+ else if (c === ";") return {
74
+ at: j,
75
+ closed: true
76
+ };
77
+ }
78
+ return {
79
+ at: s.length,
80
+ closed: false
81
+ };
82
+ }
83
+ /** The end of `@verbatim { … }` from `brace` (at `{`): after its `}`, quoted braces skipped as the parser does. */
84
+ function verbatimEnd(s, brace) {
85
+ let depth = 0;
86
+ let quote = "";
87
+ for (let k = brace; k < s.length; k++) {
88
+ const c = s[k];
89
+ if (quote) {
90
+ if (c === "\\") k++;
91
+ else if (c === quote) quote = "";
92
+ } else if (c === "'" || c === "\"" || c === "`") quote = c;
93
+ else if (c === "{") depth++;
94
+ else if (c === "}" && --depth === 0) return k + 1;
95
+ }
96
+ return s.length;
97
+ }
56
98
  /** The end of a `{{ … }}` starting at `i`; the text's end when unclosed. */
57
99
  function closeInterp(s, i) {
58
100
  let quote = "";
@@ -108,9 +150,9 @@ function scan(s) {
108
150
  const kw = m[1].replace(/\s+/g, " ");
109
151
  let j = i + m[0].length;
110
152
  if (kw === "let" || kw === "input") {
111
- const semi = kw === "let" ? s.indexOf(";", j) : -1;
112
153
  const eol = s.indexOf("\n", j);
113
- const stop = semi >= 0 && (eol < 0 || semi < eol) ? semi + 1 : eol < 0 ? s.length : eol;
154
+ const end = kw === "let" ? letEnd(s, j) : void 0;
155
+ const stop = end ? end.at + (end.closed ? 1 : 0) : eol < 0 ? s.length : eol;
114
156
  out.blank.push([i, stop]);
115
157
  i = stop;
116
158
  continue;
@@ -118,11 +160,7 @@ function scan(s) {
118
160
  if (kw === "verbatim") {
119
161
  const b = skipWs(j);
120
162
  if (s[b] === "{") {
121
- let d = 0;
122
- let k = b;
123
- for (; k < s.length; k++) if (s[k] === "{") d++;
124
- else if (s[k] === "}" && --d === 0) break;
125
- const stop = Math.min(k + 1, s.length);
163
+ const stop = verbatimEnd(s, b);
126
164
  out.blank.push([i, stop]);
127
165
  i = stop;
128
166
  continue;
@@ -205,7 +243,10 @@ function tag(s, i, out) {
205
243
  k = e - 1;
206
244
  }
207
245
  if (name === "style") out.styles.push([j, end]);
208
- else if (type !== "application/json" && type !== "application/ld+json") out.scripts.push([j, end]);
246
+ else if (type !== "application/json" && type !== "application/ld+json") out.scripts.push({
247
+ range: [j, end],
248
+ ts: /\sclient(?=[\s/>=]|$)/i.test(s.slice(i, j))
249
+ });
209
250
  return end;
210
251
  }
211
252
  function embedded(id, languageId, text, [from, to]) {
@@ -273,16 +314,42 @@ var KickVirtualCode = class {
273
314
  mappings: htmlMappings
274
315
  },
275
316
  ...styles.map((r, n) => embedded(`css_${n}`, "css", text, r)),
276
- ...scripts.map((r, n) => embedded(`js_${n}`, "javascript", text, r))
317
+ ...scripts.map((c, n) => embedded(`js_${n}`, c.ts ? "typescript" : "javascript", text, c.range))
277
318
  ];
278
319
  }
279
320
  };
321
+ /** TS / JS: a code only the kick service plugin reads, for `ctx.render('…')` names. */
322
+ const SCRIPT_IDS = /* @__PURE__ */ new Set([
323
+ "typescript",
324
+ "typescriptreact",
325
+ "javascript",
326
+ "javascriptreact"
327
+ ]);
328
+ const SCRIPT_FILE = /\.(?:[cm]?[jt]s|[jt]sx)$/;
329
+ var RenderCallsCode = class {
330
+ id = "root";
331
+ languageId = "kick-render";
332
+ snapshot;
333
+ mappings;
334
+ embeddedCodes = [];
335
+ constructor(text) {
336
+ this.snapshot = snapshotOf(text);
337
+ this.mappings = [{
338
+ sourceOffsets: [0],
339
+ generatedOffsets: [0],
340
+ lengths: [text.length],
341
+ data: EMBEDDED
342
+ }];
343
+ }
344
+ };
280
345
  const kickLanguagePlugin = {
281
346
  getLanguageId(uri) {
282
347
  return uri.path.endsWith(".kick") ? "kick" : void 0;
283
348
  },
284
349
  createVirtualCode(uri, languageId, snapshot) {
285
- return languageId === "kick" || uri.path.endsWith(".kick") ? new KickVirtualCode(snapshot.getText(0, snapshot.getLength())) : void 0;
350
+ const text = snapshot.getText(0, snapshot.getLength());
351
+ if (languageId === "kick" || uri.path.endsWith(".kick")) return new KickVirtualCode(text);
352
+ if (SCRIPT_IDS.has(languageId) || SCRIPT_FILE.test(uri.path)) return new RenderCallsCode(text);
286
353
  }
287
354
  };
288
355
  //#endregion
@@ -295,7 +362,7 @@ const LOOP_VARS = [
295
362
  "$odd",
296
363
  "$count"
297
364
  ];
298
- const IDENT = /[A-Za-z_$][\w$]*/;
365
+ const IDENT$1 = /[A-Za-z_$][\w$]*/;
299
366
  /** The end of `(`…`)` from `i` (at `(`), or -1 when it doesn't close before `stop`. */
300
367
  function closeParen(s, i, stop) {
301
368
  let depth = 0;
@@ -323,6 +390,7 @@ function headerNames(kw, header) {
323
390
  }
324
391
  return [...names.filter(Boolean), ...LOOP_VARS];
325
392
  }
393
+ if (kw === "error") return ["$message"];
326
394
  if (kw === "if" || kw === "else if") {
327
395
  const as = /;\s*as\s+([\w$]+)/.exec(header);
328
396
  return as ? [as[1]] : [];
@@ -349,12 +417,12 @@ function scopeAt(s, stop) {
349
417
  continue;
350
418
  }
351
419
  if (c === "@" && !/[\w@.-]/.test(s[i - 1] ?? "")) {
352
- const m = /^@(else\s+if|for|if|switch|case|slot|else|empty|default|let|input)(?![\w$-])/.exec(s.slice(i, i + 16));
420
+ const m = /^@(else\s+if|for|if|switch|case|slot|error|else|empty|default|let|input)(?![\w$-])/.exec(s.slice(i, i + 16));
353
421
  if (m) {
354
422
  const kw = m[1].replace(/\s+/g, " ");
355
423
  let j = i + m[0].length;
356
424
  if (kw === "input") {
357
- const name = IDENT.exec(s.slice(j, j + 64));
425
+ const name = IDENT$1.exec(s.slice(j, j + 64));
358
426
  if (name) inputs.push(name[0]);
359
427
  i = j;
360
428
  continue;
@@ -462,8 +530,14 @@ const KEYWORD = 14;
462
530
  const FUNCTION = 3;
463
531
  const CLASS = 7;
464
532
  const PROPERTY = 10;
465
- const FILE = 17;
533
+ const FILE$1 = 17;
466
534
  const VARIABLE = 6;
535
+ /** Request-wide names every template has (see `@forinda/kickjs-view`'s form state). */
536
+ const FORM_STATE = [
537
+ "$errors",
538
+ "$old",
539
+ "$flash"
540
+ ];
467
541
  const KEYWORDS = {
468
542
  if: "if (${1:condition}) {\n $0\n}",
469
543
  "else if": "else if (${1:condition}) {\n $0\n}",
@@ -477,6 +551,7 @@ const KEYWORDS = {
477
551
  input: "input ${1:name}",
478
552
  layout: "layout('$1')",
479
553
  slot: "slot('${1:name}') {\n $0\n}",
554
+ error: "error('${1:field}') {\n $0\n}",
480
555
  assets: "assets('$1')",
481
556
  verbatim: "verbatim {\n $0\n}"
482
557
  };
@@ -489,11 +564,19 @@ function componentOf(ws, tag) {
489
564
  function layoutSlots(ws, name) {
490
565
  const out = /* @__PURE__ */ new Set();
491
566
  const seen = /* @__PURE__ */ new Set();
492
- let layout = name === void 0 ? void 0 : ws.astOf(name)?.layout;
493
- while (typeof layout === "string" && !seen.has(layout)) {
567
+ const chain = [];
568
+ let layout = name === void 0 ? void 0 : ws.layoutOf(name);
569
+ while (layout !== void 0 && !seen.has(layout)) {
494
570
  seen.add(layout);
495
- const ast = ws.astOf(layout);
496
- if (!ast) break;
571
+ chain.push(layout);
572
+ layout = ws.layoutOf(layout);
573
+ }
574
+ const base = ws.baseLayout();
575
+ const page = name === void 0 ? void 0 : ws.astOf(name);
576
+ if (base !== void 0 && name !== void 0 && page?.layout !== null && !name.startsWith(`${ws.componentsDir()}/`) && name !== base && !seen.has(base)) chain.push(base);
577
+ for (const l of chain) {
578
+ const ast = ws.astOf(l);
579
+ if (!ast) continue;
497
580
  const stack = [ast.body];
498
581
  while (stack.length) for (const n of stack.pop()) if (n.k === "slot-outlet") {
499
582
  if (n.name) out.add(n.name);
@@ -503,7 +586,6 @@ function layoutSlots(ws, name) {
503
586
  else if (n.k === "for") stack.push(n.body, n.empty ?? []);
504
587
  else if (n.k === "switch") stack.push(...n.cases.map((c) => c.body), n.default ?? []);
505
588
  else if (n.k === "slot-fill") stack.push(n.body);
506
- layout = ast.layout;
507
589
  }
508
590
  return [...out];
509
591
  }
@@ -562,14 +644,14 @@ function completions(ws, uri, text, offset) {
562
644
  const dir = ws.componentsDir();
563
645
  return ws.names().filter((n) => !n.startsWith(`${dir}/`)).map((label) => ({
564
646
  label,
565
- kind: FILE
647
+ kind: FILE$1
566
648
  }));
567
649
  }
568
650
  case "slot-name": return layoutSlots(ws, ws.nameOf(uri)).map((label) => ({
569
651
  label,
570
652
  kind: PROPERTY
571
653
  }));
572
- case "expr": return [...new Set(c.scope)].map((label) => ({
654
+ case "expr": return [.../* @__PURE__ */ new Set([...c.scope, ...FORM_STATE])].map((label) => ({
573
655
  label,
574
656
  kind: VARIABLE
575
657
  }));
@@ -631,7 +713,7 @@ function posOf(text, offset) {
631
713
  }
632
714
  //#endregion
633
715
  //#region src/plugin/definition.ts
634
- const TOP = {
716
+ const TOP$1 = {
635
717
  start: {
636
718
  line: 0,
637
719
  character: 0
@@ -648,7 +730,7 @@ function definition(ws, _uri, text, offset) {
648
730
  start: posOf(text, start),
649
731
  end: posOf(text, end)
650
732
  };
651
- const link = (targetUri, targetRange = TOP) => ({
733
+ const link = (targetUri, targetRange = TOP$1) => ({
652
734
  originSelectionRange: origin,
653
735
  targetUri,
654
736
  targetRange,
@@ -678,6 +760,75 @@ function definition(ws, _uri, text, offset) {
678
760
  return null;
679
761
  }
680
762
  //#endregion
763
+ //#region src/plugin/render-calls.ts
764
+ /** A call's template name as typed so far, up to the cursor. */
765
+ const OPEN_CALL = /\b(?:ctx|views)\.render\(\s*(['"`])([^'"`\n]*)$/;
766
+ /** Every call with a literal name. */
767
+ const CALLS = /\b(?:ctx|views)\.render\(\s*(['"`])([^'"`\n]*)\1/g;
768
+ const FILE = 17;
769
+ /** Template names a page can render: everything but components. */
770
+ function renderable(ws) {
771
+ const dir = `${ws.componentsDir()}/`;
772
+ return ws.names().filter((n) => !n.startsWith(dir));
773
+ }
774
+ function renderCompletions(ws, text, offset) {
775
+ const m = OPEN_CALL.exec(text.slice(0, offset));
776
+ if (!m) return void 0;
777
+ const range = {
778
+ start: posOf(text, offset - m[2].length),
779
+ end: posOf(text, offset)
780
+ };
781
+ return renderable(ws).map((name) => ({
782
+ label: name,
783
+ kind: FILE,
784
+ textEdit: {
785
+ range,
786
+ newText: name
787
+ }
788
+ }));
789
+ }
790
+ /** Calls whose name is a plain literal (not a template string with `${…}`). */
791
+ function calls(text) {
792
+ return [...text.matchAll(CALLS)].filter((m) => !m[2].includes("${")).map((m) => ({
793
+ name: m[2],
794
+ offset: m.index + m[0].length - m[2].length - 1
795
+ }));
796
+ }
797
+ function renderDefinition(ws, text, offset) {
798
+ const call = calls(text).find((c) => offset >= c.offset && offset <= c.offset + c.name.length);
799
+ if (!call || !ws.astOf(call.name)) return null;
800
+ const top = {
801
+ start: {
802
+ line: 0,
803
+ character: 0
804
+ },
805
+ end: {
806
+ line: 0,
807
+ character: 0
808
+ }
809
+ };
810
+ return {
811
+ originSelectionRange: {
812
+ start: posOf(text, call.offset),
813
+ end: posOf(text, call.offset + call.name.length)
814
+ },
815
+ targetUri: ws.uriOf(call.name),
816
+ targetRange: top,
817
+ targetSelectionRange: top
818
+ };
819
+ }
820
+ function renderDiagnostics(ws, text) {
821
+ return calls(text).filter((c) => !ws.astOf(c.name)).map((c) => ({
822
+ range: {
823
+ start: posOf(text, c.offset),
824
+ end: posOf(text, c.offset + c.name.length)
825
+ },
826
+ severity: 2,
827
+ source: "kick",
828
+ message: `template '${c.name}' not found`
829
+ }));
830
+ }
831
+ //#endregion
681
832
  //#region src/plugin/diagnostics.ts
682
833
  /** The `.kick` file a root virtual-code document belongs to. */
683
834
  function sourceUri(context, uri) {
@@ -692,6 +843,7 @@ function createDiagnosticsPlugin(ws) {
692
843
  } },
693
844
  create(context) {
694
845
  return { provideDiagnostics(document) {
846
+ if (document.languageId === "kick-render") return renderDiagnostics(ws, document.getText());
695
847
  if (document.languageId !== "kick") return void 0;
696
848
  const uri = sourceUri(context, document.uri);
697
849
  ws.sync(uri, document.getText());
@@ -718,6 +870,421 @@ function formatEdits(view, text, file, tabSize) {
718
870
  }];
719
871
  }
720
872
  //#endregion
873
+ //#region src/plugin/tokens.ts
874
+ /**
875
+ * Semantic tokens for `.kick` templates, so every LSP editor highlights the
876
+ * template syntax, not only VS Code with its TextMate grammar. Found by a
877
+ * lexical scan, like the language plugin's: the buffer being typed in rarely
878
+ * parses.
879
+ */
880
+ /** The legend, in order: standard types, so themes colour them. */
881
+ const TOKEN_TYPES = [
882
+ "keyword",
883
+ "comment",
884
+ "operator",
885
+ "function",
886
+ "variable",
887
+ "property",
888
+ "string",
889
+ "number",
890
+ "class",
891
+ "parameter"
892
+ ];
893
+ const LITERALS = /* @__PURE__ */ new Set([
894
+ "true",
895
+ "false",
896
+ "null",
897
+ "undefined"
898
+ ]);
899
+ /** Words with a meaning in block headers: `@for (x of xs; track x.id; let i = $index)`, `@if (a; as b)`, `@layout(none)`. */
900
+ const HEADER_WORDS = /* @__PURE__ */ new Set([
901
+ "of",
902
+ "track",
903
+ "let",
904
+ "as",
905
+ "none"
906
+ ]);
907
+ function kickTokens(s, isComponent) {
908
+ const out = [];
909
+ const push = (offset, length, type) => {
910
+ if (length > 0) out.push({
911
+ offset,
912
+ length,
913
+ type
914
+ });
915
+ };
916
+ /** An expression in `[from, to)`. */
917
+ const expr = (from, to, header) => {
918
+ let prev = "";
919
+ for (let i = from; i < to;) {
920
+ const c = s[i];
921
+ if (/\s/.test(c)) {
922
+ i++;
923
+ continue;
924
+ }
925
+ if (c === "'" || c === "\"" || c === "`") {
926
+ let j = i + 1;
927
+ while (j < to && s[j] !== c) j += s[j] === "\\" ? 2 : 1;
928
+ j = Math.min(j + 1, to);
929
+ push(i, j - i, "string");
930
+ prev = "lit";
931
+ i = j;
932
+ continue;
933
+ }
934
+ const num = /^\d+(?:\.\d+)?/.exec(s.slice(i, Math.min(to, i + 32)));
935
+ if (num) {
936
+ push(i, num[0].length, "number");
937
+ prev = "lit";
938
+ i += num[0].length;
939
+ continue;
940
+ }
941
+ const word = /^[A-Za-z_$][\w$]*/.exec(s.slice(i, Math.min(to, i + 128)));
942
+ if (word) {
943
+ const w = word[0];
944
+ const type = prev === "." ? "property" : prev === "|" ? "function" : LITERALS.has(w) || header && HEADER_WORDS.has(w) ? "keyword" : "variable";
945
+ push(i, w.length, type);
946
+ prev = "word";
947
+ i += w.length;
948
+ continue;
949
+ }
950
+ if (c === "|" && s[i + 1] === "|") {
951
+ prev = "||";
952
+ i += 2;
953
+ continue;
954
+ }
955
+ if (c === "|") push(i, 1, "operator");
956
+ prev = c === "?" && s[i + 1] === "." ? "." : c;
957
+ i += c === "?" && s[i + 1] === "." ? 2 : 1;
958
+ }
959
+ };
960
+ /** `{{ … }}` from `i` (at `{{`); returns its end. */
961
+ const interp = (i, limit) => {
962
+ const end = Math.min(closeInterp(s, i), limit);
963
+ const closed = s.startsWith("}}", end - 2) && end - 2 >= i + 2;
964
+ push(i, 2, "operator");
965
+ expr(i + 2, closed ? end - 2 : end, false);
966
+ if (closed) push(end - 2, 2, "operator");
967
+ return end;
968
+ };
969
+ /** One tag from `i` (at `<`); returns where scanning continues. */
970
+ const tag = (i) => {
971
+ const m = /^<(\/?)([A-Za-z][\w:.-]*)/.exec(s.slice(i, i + 64));
972
+ const name = m[2].toLowerCase();
973
+ if (isComponent(name)) push(i + 1 + m[1].length, m[2].length, "class");
974
+ let j = i + m[0].length;
975
+ while (j < s.length && s[j] !== ">") {
976
+ const binding = /^\[([^\]\s]+)\]\s*=\s*(["'])/.exec(s.slice(j, j + 128));
977
+ if (binding) {
978
+ push(j + 1, binding[1].length, "property");
979
+ const from = j + binding[0].length;
980
+ const close = s.indexOf(binding[2], from);
981
+ const stop = close < 0 ? s.length : close;
982
+ expr(from, stop, false);
983
+ j = stop + 1;
984
+ continue;
985
+ }
986
+ const q = s[j];
987
+ if (q === "\"" || q === "'") {
988
+ const close = s.indexOf(q, j + 1);
989
+ const stop = close < 0 ? s.length : close;
990
+ for (let k = j + 1; k < stop; k++) if (s.startsWith("{{", k)) k = interp(k, stop) - 1;
991
+ j = stop + 1;
992
+ continue;
993
+ }
994
+ j++;
995
+ }
996
+ j = Math.min(j + 1, s.length);
997
+ if (m[1] || name !== "script" && name !== "style" || s[j - 2] === "/") return j;
998
+ const close = new RegExp(`</${name}(?=[\\t\\n\\f\\r />])`, "i").exec(s.slice(j));
999
+ const end = close ? j + close.index : s.length;
1000
+ for (let k = j; k < end; k++) if (s.startsWith("{{", k)) k = interp(k, end) - 1;
1001
+ return end;
1002
+ };
1003
+ let i = 0;
1004
+ while (i < s.length) {
1005
+ const c = s[i];
1006
+ if (s.startsWith("{{--", i)) {
1007
+ const end = s.indexOf("--}}", i + 4);
1008
+ const stop = end < 0 ? s.length : end + 4;
1009
+ push(i, stop - i, "comment");
1010
+ i = stop;
1011
+ continue;
1012
+ }
1013
+ if (s.startsWith("{{", i)) {
1014
+ i = interp(i, s.length);
1015
+ continue;
1016
+ }
1017
+ if (s.startsWith("<!--", i)) {
1018
+ const end = s.indexOf("-->", i + 4);
1019
+ i = end < 0 ? s.length : end + 3;
1020
+ continue;
1021
+ }
1022
+ if (c === "<" && /^<\/?[A-Za-z]/.test(s.slice(i, i + 3))) {
1023
+ i = tag(i);
1024
+ continue;
1025
+ }
1026
+ if (c === "@" && !/[\w@.-]/.test(s[i - 1] ?? "")) {
1027
+ const m = KEYWORD$1.exec(s.slice(i, i + 16));
1028
+ if (m) {
1029
+ const kw = m[1].replace(/\s+/g, " ");
1030
+ push(i, m[0].length, "keyword");
1031
+ let j = i + m[0].length;
1032
+ if (kw === "input" || kw === "let") {
1033
+ const decl = /^\s+([\w$]+)/.exec(s.slice(j, j + 64));
1034
+ if (decl) push(j + decl[0].length - decl[1].length, decl[1].length, kw === "input" ? "parameter" : "variable");
1035
+ const eol = s.indexOf("\n", j);
1036
+ const stop = kw === "let" ? letEnd(s, j).at : eol < 0 ? s.length : eol;
1037
+ const eq = s.indexOf("=", j);
1038
+ if (eq >= 0 && eq < stop) expr(eq + 1, stop, false);
1039
+ i = stop;
1040
+ continue;
1041
+ }
1042
+ while (j < s.length && /\s/.test(s[j])) j++;
1043
+ if (kw === "verbatim" && s[j] === "{") {
1044
+ i = verbatimEnd(s, j);
1045
+ continue;
1046
+ }
1047
+ if (s[j] === "(") {
1048
+ const end = closeParen$1(s, j);
1049
+ expr(j + 1, s[end - 1] === ")" ? end - 1 : end, true);
1050
+ i = end;
1051
+ continue;
1052
+ }
1053
+ i = i + m[0].length;
1054
+ continue;
1055
+ }
1056
+ }
1057
+ i++;
1058
+ }
1059
+ return out;
1060
+ }
1061
+ //#endregion
1062
+ //#region src/plugin/references.ts
1063
+ const TOP = {
1064
+ start: {
1065
+ line: 0,
1066
+ character: 0
1067
+ },
1068
+ end: {
1069
+ line: 0,
1070
+ character: 0
1071
+ }
1072
+ };
1073
+ const IDENT = /^[A-Za-z_$][\w$]*$/;
1074
+ const LAYOUT = /@layout\s*\(\s*(['"])([^'"]*)\1/g;
1075
+ const rangeOf = (text, offset, length) => ({
1076
+ start: posOf(text, offset),
1077
+ end: posOf(text, offset + length)
1078
+ });
1079
+ /** The shared name under the cursor, with where it sits in `text`. */
1080
+ function targetAt(ws, text, offset) {
1081
+ const t = kickTokens(text, (tag) => componentOf(ws, tag) !== void 0).find((k) => offset >= k.offset && offset <= k.offset + k.length);
1082
+ if (!t) return void 0;
1083
+ const word = text.slice(t.offset, t.offset + t.length);
1084
+ if (t.type === "class") {
1085
+ const name = componentOf(ws, word.toLowerCase());
1086
+ return name === void 0 ? void 0 : {
1087
+ target: {
1088
+ kind: "component",
1089
+ name,
1090
+ tag: word.toLowerCase()
1091
+ },
1092
+ ...t
1093
+ };
1094
+ }
1095
+ if (t.type === "function") return {
1096
+ target: {
1097
+ kind: "pipe",
1098
+ name: word
1099
+ },
1100
+ ...t
1101
+ };
1102
+ if (t.type === "string" && /@layout\s*\(\s*$/.test(text.slice(0, t.offset))) {
1103
+ const name = word.slice(1, -1);
1104
+ return ws.astOf(name) ? {
1105
+ target: {
1106
+ kind: "layout",
1107
+ name
1108
+ },
1109
+ offset: t.offset + 1,
1110
+ length: name.length
1111
+ } : void 0;
1112
+ }
1113
+ }
1114
+ /** Every use of `target` in the templates. */
1115
+ function usesOf(ws, target) {
1116
+ const out = [];
1117
+ for (const name of ws.names()) {
1118
+ const text = ws.textOf(name);
1119
+ if (text === void 0) continue;
1120
+ const uri = ws.uriOf(name);
1121
+ if (target.kind === "layout") {
1122
+ for (const m of text.matchAll(LAYOUT)) if (m[2] === target.name) out.push({
1123
+ uri,
1124
+ text,
1125
+ offset: m.index + m[0].length - m[2].length - 1,
1126
+ length: m[2].length
1127
+ });
1128
+ continue;
1129
+ }
1130
+ const want = target.kind === "component" ? "class" : "function";
1131
+ const match = target.kind === "component" ? target.tag : target.name;
1132
+ for (const t of kickTokens(text, (tag) => target.kind === "component" && tag === target.tag)) if (t.type === want && text.slice(t.offset, t.offset + t.length).toLowerCase() === match.toLowerCase()) out.push({
1133
+ uri,
1134
+ text,
1135
+ ...t
1136
+ });
1137
+ }
1138
+ return out;
1139
+ }
1140
+ /** Where a pipe is declared in `pipes.ts`; only the forms the server can read. */
1141
+ function pipeDeclaration(ws, name) {
1142
+ const decl = ws.pipeDeclaration(name);
1143
+ if (!decl) return void 0;
1144
+ const start = {
1145
+ line: decl.line,
1146
+ character: decl.column
1147
+ };
1148
+ return {
1149
+ uri: URI.file(ws.pipesFile()).toString(),
1150
+ range: {
1151
+ start,
1152
+ end: {
1153
+ line: decl.line,
1154
+ character: decl.column + name.length
1155
+ }
1156
+ }
1157
+ };
1158
+ }
1159
+ function references(ws, text, offset, includeDeclaration) {
1160
+ const at = targetAt(ws, text, offset);
1161
+ if (!at) return [];
1162
+ const { target } = at;
1163
+ const locations = usesOf(ws, target).map((u) => ({
1164
+ uri: u.uri,
1165
+ range: rangeOf(u.text, u.offset, u.length)
1166
+ }));
1167
+ if (includeDeclaration) {
1168
+ if (target.kind === "pipe") {
1169
+ const decl = pipeDeclaration(ws, target.name);
1170
+ if (decl) locations.push(decl);
1171
+ } else locations.push({
1172
+ uri: ws.uriOf(target.name),
1173
+ range: TOP
1174
+ });
1175
+ }
1176
+ return locations;
1177
+ }
1178
+ /** What F2 renames here: a pipe, a component tag or a layout name. */
1179
+ function renameRange(ws, text, offset) {
1180
+ const at = targetAt(ws, text, offset);
1181
+ if (!at) return void 0;
1182
+ if (at.target.kind === "pipe" && !pipeDeclaration(ws, at.target.name)) return { message: `Rename ${at.target.name} in your pipes file; it isn't declared in a form the editor can rename.` };
1183
+ return rangeOf(text, at.offset, at.length);
1184
+ }
1185
+ const TAG = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
1186
+ /** The component file for `tag`, kept in the old one's folder when the tag still starts with it. */
1187
+ function componentNameFor(ws, oldName, tag) {
1188
+ if (!TAG.test(tag)) return void 0;
1189
+ const dir = ws.componentsDir();
1190
+ const folders = oldName.slice(dir.length + 1).split("/").slice(0, -1);
1191
+ const prefix = folders.join("-");
1192
+ const name = prefix && tag.startsWith(`${prefix}-`) ? `${dir}/${folders.join("/")}/${tag.slice(prefix.length + 1)}` : `${dir}/${tag}`;
1193
+ return ws.view.componentTag(name, dir) === tag ? name : void 0;
1194
+ }
1195
+ /** Every use of `target` replaced with `newText`, then the template's file renamed. */
1196
+ function renameTemplate(ws, target, newText, newName) {
1197
+ if (target.kind === "pipe") return void 0;
1198
+ if (ws.view.templateName(newName) !== newName || ws.astOf(newName)) return void 0;
1199
+ const byUri = /* @__PURE__ */ new Map();
1200
+ for (const u of usesOf(ws, target)) {
1201
+ const edits = byUri.get(u.uri) ?? [];
1202
+ edits.push({
1203
+ range: rangeOf(u.text, u.offset, u.length),
1204
+ newText
1205
+ });
1206
+ byUri.set(u.uri, edits);
1207
+ }
1208
+ return { documentChanges: [...[...byUri].map(([uri, edits]) => ({
1209
+ textDocument: {
1210
+ uri,
1211
+ version: null
1212
+ },
1213
+ edits
1214
+ })), {
1215
+ kind: "rename",
1216
+ oldUri: ws.uriOf(target.name),
1217
+ newUri: ws.uriOf(newName)
1218
+ }] };
1219
+ }
1220
+ /** A pipe renamed in every template and in `pipes.ts`; a component or layout, file included. */
1221
+ function renameEdits(ws, text, offset, newName) {
1222
+ const at = targetAt(ws, text, offset);
1223
+ if (!at) return void 0;
1224
+ const { target } = at;
1225
+ if (target.kind === "component") {
1226
+ const name = componentNameFor(ws, target.name, newName);
1227
+ return name === void 0 ? void 0 : renameTemplate(ws, target, newName, name);
1228
+ }
1229
+ if (target.kind === "layout") return renameTemplate(ws, target, newName, newName);
1230
+ if (!IDENT.test(newName) || ws.project().pipes.has(newName)) return void 0;
1231
+ const decl = pipeDeclaration(ws, target.name);
1232
+ if (!decl) return void 0;
1233
+ const changes = {};
1234
+ const add = (uri, range) => (changes[uri] ??= []).push({
1235
+ range,
1236
+ newText: newName
1237
+ });
1238
+ for (const u of usesOf(ws, target)) add(u.uri, rangeOf(u.text, u.offset, u.length));
1239
+ add(decl.uri, decl.range);
1240
+ return { changes };
1241
+ }
1242
+ /**
1243
+ * Edits that keep uses pointing at renamed templates: a component's tags, a
1244
+ * layout's `@layout('…')`, a page's `ctx.render('…')`. `oldUri` / `newUri`
1245
+ * can be a file or a folder.
1246
+ */
1247
+ async function fileRenameEdits(ws, oldUri, newUri) {
1248
+ const from = ws.relOf(oldUri)?.replace(/\.kick$/, "");
1249
+ const to = ws.relOf(newUri)?.replace(/\.kick$/, "");
1250
+ if (from === void 0 || to === void 0 || from === to) return void 0;
1251
+ const renamed = /* @__PURE__ */ new Map();
1252
+ for (const name of ws.names()) if (name === from) renamed.set(name, to);
1253
+ else if (name.startsWith(`${from}/`)) renamed.set(name, to + name.slice(from.length));
1254
+ if (!renamed.size) return void 0;
1255
+ const changes = {};
1256
+ const dir = ws.componentsDir();
1257
+ for (const [oldName, newName] of renamed) {
1258
+ const comp = oldName.startsWith(`${dir}/`);
1259
+ const newTag = newName.startsWith(`${dir}/`) ? ws.view.componentTag(newName, dir) : void 0;
1260
+ const targets = [[{
1261
+ kind: "layout",
1262
+ name: oldName
1263
+ }, newName]];
1264
+ if (comp && newTag) targets.push([{
1265
+ kind: "component",
1266
+ name: oldName,
1267
+ tag: ws.view.componentTag(oldName, dir)
1268
+ }, newTag]);
1269
+ for (const [target, newText] of targets) for (const u of usesOf(ws, target)) (changes[u.uri] ??= []).push({
1270
+ range: rangeOf(u.text, u.offset, u.length),
1271
+ newText
1272
+ });
1273
+ }
1274
+ const pages = [...renamed].filter(([n]) => !n.startsWith(`${dir}/`));
1275
+ if (pages.length) {
1276
+ const to = new Map(pages);
1277
+ for (const script of await ws.scripts()) for (const c of calls(script.text)) {
1278
+ const newName = to.get(c.name);
1279
+ if (newName !== void 0) (changes[script.uri] ??= []).push({
1280
+ range: rangeOf(script.text, c.offset, c.name.length),
1281
+ newText: newName
1282
+ });
1283
+ }
1284
+ }
1285
+ return Object.keys(changes).length ? { changes } : void 0;
1286
+ }
1287
+ //#endregion
721
1288
  //#region src/plugin/index.ts
722
1289
  function createKickServicePlugin(ws) {
723
1290
  const diagnostics = createDiagnosticsPlugin(ws);
@@ -735,7 +1302,14 @@ function createKickServicePlugin(ws) {
735
1302
  ] },
736
1303
  hoverProvider: true,
737
1304
  definitionProvider: true,
738
- documentFormattingProvider: true
1305
+ documentFormattingProvider: true,
1306
+ semanticTokensProvider: { legend: {
1307
+ tokenTypes: [...TOKEN_TYPES],
1308
+ tokenModifiers: []
1309
+ } },
1310
+ referencesProvider: true,
1311
+ renameProvider: { prepareProvider: true },
1312
+ fileRenameEditsProvider: true
739
1313
  },
740
1314
  create(context) {
741
1315
  /** The source file's uri and text, with the workspace synced to the buffer. */
@@ -752,6 +1326,13 @@ function createKickServicePlugin(ws) {
752
1326
  return {
753
1327
  ...diagnostics.create(context),
754
1328
  provideCompletionItems(document, position) {
1329
+ if (document.languageId === "kick-render") {
1330
+ const items = renderCompletions(ws, document.getText(), document.offsetAt(position));
1331
+ return items && {
1332
+ isIncomplete: false,
1333
+ items
1334
+ };
1335
+ }
755
1336
  const d = open(document);
756
1337
  if (!d) return void 0;
757
1338
  const items = completions(ws, d.uri, d.text, document.offsetAt(position));
@@ -765,10 +1346,51 @@ function createKickServicePlugin(ws) {
765
1346
  return d ? hover(ws, d.uri, d.text, document.offsetAt(position)) ?? void 0 : void 0;
766
1347
  },
767
1348
  provideDefinition(document, position) {
1349
+ if (document.languageId === "kick-render") {
1350
+ const link = renderDefinition(ws, document.getText(), document.offsetAt(position));
1351
+ return link ? [link] : void 0;
1352
+ }
768
1353
  const d = open(document);
769
1354
  const link = d && definition(ws, d.uri, d.text, document.offsetAt(position));
770
1355
  return link ? [link] : void 0;
771
1356
  },
1357
+ provideReferences(document, position, context) {
1358
+ const d = open(document);
1359
+ return d ? references(ws, d.text, document.offsetAt(position), context.includeDeclaration) : void 0;
1360
+ },
1361
+ provideRenameRange(document, position) {
1362
+ const d = open(document);
1363
+ return d ? renameRange(ws, d.text, document.offsetAt(position)) : void 0;
1364
+ },
1365
+ provideRenameEdits(document, position, newName) {
1366
+ const d = open(document);
1367
+ return d ? renameEdits(ws, d.text, document.offsetAt(position), newName) : void 0;
1368
+ },
1369
+ provideFileRenameEdits(oldUri, newUri) {
1370
+ return fileRenameEdits(ws, oldUri.toString(), newUri.toString());
1371
+ },
1372
+ provideDocumentSemanticTokens(document, range, legend) {
1373
+ const d = open(document);
1374
+ if (!d) return void 0;
1375
+ const tokens = [];
1376
+ for (const t of kickTokens(d.text, (tag) => componentOf(ws, tag) !== void 0)) {
1377
+ const type = legend.tokenTypes.indexOf(t.type);
1378
+ if (type < 0) continue;
1379
+ let at = t.offset;
1380
+ for (const part of d.text.slice(t.offset, t.offset + t.length).split("\n")) {
1381
+ const p = document.positionAt(at);
1382
+ if (part && p.line >= range.start.line && p.line <= range.end.line) tokens.push([
1383
+ p.line,
1384
+ p.character,
1385
+ part.length,
1386
+ type,
1387
+ 0
1388
+ ]);
1389
+ at += part.length + 1;
1390
+ }
1391
+ }
1392
+ return tokens;
1393
+ },
772
1394
  provideDocumentFormattingEdits(document, _range, options) {
773
1395
  const d = open(document);
774
1396
  if (!d) return void 0;
@@ -952,6 +1574,11 @@ var Workspace = class {
952
1574
  if (rel.startsWith("..") || !rel.endsWith(".kick")) return void 0;
953
1575
  return this.opts.view.templateName(rel.slice(0, -5));
954
1576
  }
1577
+ /** `uri`'s path inside the views folder (`layouts/main.kick`, `layouts`); `undefined` outside it. */
1578
+ relOf(uri) {
1579
+ const rel = relative(this.opts.viewsDir, URI.parse(uri).fsPath).split(sep).join(posix.sep);
1580
+ return rel.startsWith("..") || isAbsolute(rel) ? void 0 : rel;
1581
+ }
955
1582
  nameOf(uri) {
956
1583
  return this.nameOfPath(URI.parse(uri).fsPath);
957
1584
  }
@@ -1069,6 +1696,7 @@ var Workspace = class {
1069
1696
  }
1070
1697
  /** Templates that use `name` as a component or a layout — a layout even after it's gone. */
1071
1698
  dependentsOf(name) {
1699
+ if (this.configuredLayouts().has(name)) return new Set(this.names().filter((n) => n !== name));
1072
1700
  const out = /* @__PURE__ */ new Set();
1073
1701
  const tag = name.startsWith(`${this.opts.componentsDir}/`) ? this.opts.view.componentTag(name, this.opts.componentsDir) : void 0;
1074
1702
  for (const [n, e] of this.entries) {
@@ -1091,9 +1719,38 @@ var Workspace = class {
1091
1719
  return {
1092
1720
  templates: new Map([...this.entries].map(([n, e]) => [n, e.ast])),
1093
1721
  pipes: new Set(this.pipeNames()),
1094
- componentsDir: this.opts.componentsDir
1722
+ componentsDir: this.opts.componentsDir,
1723
+ layouts: this.opts.layout,
1724
+ baseLayout: this.opts.baseLayout
1095
1725
  };
1096
1726
  }
1727
+ /** Templates that are layouts by configuration: the mapping's targets and the base layout. */
1728
+ configuredLayouts() {
1729
+ const { layout, baseLayout } = this.opts;
1730
+ const out = new Set(typeof layout === "string" ? [layout] : Object.values(layout ?? {}));
1731
+ if (baseLayout) out.add(baseLayout);
1732
+ return out;
1733
+ }
1734
+ scripts() {
1735
+ return this.opts.scripts?.() ?? Promise.resolve([]);
1736
+ }
1737
+ baseLayout() {
1738
+ return this.opts.baseLayout;
1739
+ }
1740
+ /**
1741
+ * The layout `name` renders in: its own `@layout`, else the configured
1742
+ * mapping's longest matching folder prefix (pages only, as `check` does).
1743
+ */
1744
+ layoutOf(name) {
1745
+ const ast = this.astOf(name);
1746
+ if (!ast) return void 0;
1747
+ if (ast.layout !== void 0) return ast.layout ?? void 0;
1748
+ const { layout } = this.opts;
1749
+ if (layout === void 0 || name.startsWith(`${this.opts.componentsDir}/`) || this.configuredLayouts().has(name)) return void 0;
1750
+ if (typeof layout === "string") return layout;
1751
+ const hit = Object.keys(layout).toSorted((a, b) => b.length - a.length).find((p) => p === "" || name === p || name.startsWith(`${p}/`));
1752
+ return hit === void 0 ? void 0 : layout[hit];
1753
+ }
1097
1754
  /** Parse errors, else everything `check` reports — as LSP diagnostics. */
1098
1755
  diagnosticsFor(uri) {
1099
1756
  const name = this.nameOf(uri);
@@ -1126,6 +1783,47 @@ async function listKick(dir) {
1126
1783
  return [];
1127
1784
  }
1128
1785
  }
1786
+ const SKIP = /* @__PURE__ */ new Set([
1787
+ "node_modules",
1788
+ "dist",
1789
+ "build",
1790
+ "coverage",
1791
+ "out"
1792
+ ]);
1793
+ const SCRIPT = /\.(?:[cm]?[jt]s|[jt]sx)$/;
1794
+ /** TS / JS files under `root`, for `ctx.render('…')` names: no dependencies, build output, dot folders or `.d.ts`. */
1795
+ async function listScripts(root) {
1796
+ const out = [];
1797
+ const walk = async (dir) => {
1798
+ let entries;
1799
+ try {
1800
+ entries = await readdir(dir, { withFileTypes: true });
1801
+ } catch {
1802
+ return;
1803
+ }
1804
+ for (const e of entries) {
1805
+ if (e.name.startsWith(".") || SKIP.has(e.name)) continue;
1806
+ const p = join(dir, e.name);
1807
+ if (e.isDirectory()) await walk(p);
1808
+ else if (SCRIPT.test(e.name) && !e.name.endsWith(".d.ts")) out.push(p);
1809
+ }
1810
+ };
1811
+ await walk(root);
1812
+ return out;
1813
+ }
1814
+ /** `fn` over `items` with at most `limit` running at once, results in order. */
1815
+ async function mapLimit(items, limit, fn) {
1816
+ const out = Array.from({ length: items.length });
1817
+ let next = 0;
1818
+ const worker = async () => {
1819
+ while (next < items.length) {
1820
+ const i = next++;
1821
+ out[i] = await fn(items[i]);
1822
+ }
1823
+ };
1824
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
1825
+ return out;
1826
+ }
1129
1827
  /** What the server watches: every template, and the pipes file wherever it's configured. */
1130
1828
  function watchPatterns(root, pipesFile) {
1131
1829
  const patterns = ["**/*.kick", "**/pipes.{ts,js}"];
@@ -1155,14 +1853,23 @@ function startServer() {
1155
1853
  pipesFile,
1156
1854
  view,
1157
1855
  read: (p) => readFile(p, "utf8").catch(() => void 0),
1158
- list: listKick
1856
+ list: listKick,
1857
+ layout: opts.layout || void 0,
1858
+ baseLayout: opts.baseLayout || void 0,
1859
+ scripts: async () => mapLimit(await listScripts(root), 64, async (p) => {
1860
+ const uri = URI.file(p).toString();
1861
+ return {
1862
+ uri,
1863
+ text: server.documents.get(URI.parse(uri))?.getText() ?? await readFile(p, "utf8").catch(() => "")
1864
+ };
1865
+ })
1159
1866
  });
1160
1867
  await ws.load();
1161
1868
  return server.initialize(params, createSimpleProject([kickLanguagePlugin]), [
1869
+ createKickServicePlugin(ws),
1162
1870
  create$2(),
1163
1871
  create(),
1164
- create$1(),
1165
- createKickServicePlugin(ws)
1872
+ create$1()
1166
1873
  ]);
1167
1874
  });
1168
1875
  connection.onInitialized(() => {
@@ -1191,9 +1898,17 @@ function startServer() {
1191
1898
  const dir = `${ws.componentsDir()}/`;
1192
1899
  return ws.names().filter((n) => !n.startsWith(dir));
1193
1900
  });
1901
+ connection.onRequest("kick/renderDefinition", ({ text, offset }) => {
1902
+ const link = ws && renderDefinition(ws, text, offset);
1903
+ return link ? {
1904
+ uri: link.targetUri,
1905
+ range: link.originSelectionRange
1906
+ } : null;
1907
+ });
1908
+ connection.onRequest("kick/renderDiagnostics", ({ text }) => ws ? renderDiagnostics(ws, text) : []);
1194
1909
  connection.onShutdown(() => server.shutdown());
1195
1910
  }
1196
1911
  //#endregion
1197
1912
  export { KickVirtualCode as a, createKickServicePlugin as i, Workspace as n, kickLanguagePlugin as o, loadViewModule as r, startServer as t };
1198
1913
 
1199
- //# sourceMappingURL=server-CfzShU0v.js.map
1914
+ //# sourceMappingURL=server-Biz_hJF3.js.map