@xano/xanoscript-language-server 11.9.0 → 11.10.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.
@@ -0,0 +1,11 @@
1
+ ---
2
+ name: no-git-interactions
3
+ description: User does not want Claude to interact with git (no commits, no staging, no git commands)
4
+ type: feedback
5
+ ---
6
+
7
+ Do not interact with git — no commits, no staging, no git commands. Focus purely on implementation and testing.
8
+
9
+ **Why:** User manages their own git workflow.
10
+
11
+ **How to apply:** Skip all git-related steps in plans. When dispatching subagents, explicitly tell them not to run any git commands. Remove commit steps from task instructions.
@@ -2,18 +2,13 @@
2
2
  "permissions": {
3
3
  "allow": [
4
4
  "Bash(npm test:*)",
5
- "Bash(node -e:*)",
6
- "Bash(node --input-type=module -e:*)",
7
5
  "Bash(git checkout:*)",
8
6
  "Bash(ls:*)",
9
7
  "Bash(npm run lint:*)",
10
8
  "Bash(grep:*)",
11
- "Bash(node --input-type=module:*)",
12
9
  "Bash(find:*)",
13
10
  "Read(//tmp/**)",
14
11
  "Bash(git stash:*)",
15
- "Bash(node:*)",
16
- "Bash(for:*)",
17
12
  "Bash(do)",
18
13
  "Bash(if ! grep -q \"guidFieldAttribute\" \"$f\")",
19
14
  "Bash(then)",
@@ -1,4 +1,5 @@
1
1
  import { lexDocument } from "../lexer/lexer.js";
2
+ import { multidocParser } from "../parser/multidoc.js";
2
3
  import { xanoscriptParser } from "../parser/parser.js";
3
4
  import { getSchemeFromContent } from "../utils.js";
4
5
 
@@ -34,14 +35,30 @@ class DocumentCache {
34
35
  cached.version === version &&
35
36
  cached.textLength === textLength
36
37
  ) {
37
- // Re-lex (cheap) to provide tokens; return cached parser state
38
38
  return {
39
- lexResult: lexDocument(text),
39
+ lexResult: cached.scheme === "multidoc" ? { tokens: [] } : lexDocument(text),
40
40
  parser: cached.parserState,
41
41
  scheme: cached.scheme,
42
42
  };
43
43
  }
44
44
 
45
+ // Multidoc detection — split on \n---\n separator
46
+ if (text.includes("\n---\n")) {
47
+ const parserState = multidocParser(text);
48
+ const cacheEntry = {
49
+ version,
50
+ textLength,
51
+ parserState,
52
+ scheme: "multidoc",
53
+ };
54
+ this.cache.set(uri, cacheEntry);
55
+ return {
56
+ lexResult: { tokens: [] },
57
+ parser: parserState,
58
+ scheme: "multidoc",
59
+ };
60
+ }
61
+
45
62
  // Parse and cache a snapshot of the parser state
46
63
  const scheme = getSchemeFromContent(text);
47
64
  const lexResult = lexDocument(text);
@@ -73,6 +73,10 @@ export function onDidChangeContent(params, connection, features = {}) {
73
73
  const text = document.getText();
74
74
 
75
75
  try {
76
+ // Capture previous segment count before getOrParse overwrites the cache
77
+ const prevCached = documentCache.cache.get(document.uri);
78
+ const prevSegmentCount = prevCached?.parserState?.segmentCount ?? 0;
79
+
76
80
  // Parse the XanoScript file using cache
77
81
  const { lexResult, parser, scheme } = documentCache.getOrParse(
78
82
  document.uri,
@@ -80,8 +84,28 @@ export function onDidChangeContent(params, connection, features = {}) {
80
84
  text,
81
85
  );
82
86
 
83
- // Update workspace index with already-parsed data (no re-parse needed)
84
- workspaceIndex.addParsed(document.uri, text, parser.__symbolTable);
87
+ // Update workspace index
88
+ // Clean up old fragment URIs from previous parse
89
+ for (let i = 0; i < prevSegmentCount; i++) {
90
+ workspaceIndex.removeFile(`${document.uri}#${i}`);
91
+ }
92
+ if (parser.isMultidoc) {
93
+ // Remove the base URI in case document switched from single to multidoc
94
+ workspaceIndex.removeFile(document.uri);
95
+ // Index each segment with fragment URIs
96
+ if (parser.segments) {
97
+ for (let i = 0; i < parser.segments.length; i++) {
98
+ const seg = parser.segments[i];
99
+ workspaceIndex.addParsed(
100
+ `${document.uri}#${i}`,
101
+ seg.text,
102
+ seg.symbolTable,
103
+ );
104
+ }
105
+ }
106
+ } else {
107
+ workspaceIndex.addParsed(document.uri, text, parser.__symbolTable);
108
+ }
85
109
 
86
110
  for (const error of parser.errors) {
87
111
  console.error(
@@ -92,8 +116,8 @@ export function onDidChangeContent(params, connection, features = {}) {
92
116
  // Create diagnostics in a single pass
93
117
  const diagnostics = createDiagnostics(parser, document);
94
118
 
95
- // Run cross-file validation and append as warnings
96
- if (features.crossFileValidation !== false && parser.__symbolTable?.references) {
119
+ // Run cross-file validation (skip for multidoc — already done internally)
120
+ if (!parser.isMultidoc && features.crossFileValidation !== false && parser.__symbolTable?.references) {
97
121
  const crossFileWarnings = crossFileValidate(
98
122
  parser.__symbolTable.references,
99
123
  workspaceIndex,
@@ -110,8 +134,8 @@ export function onDidChangeContent(params, connection, features = {}) {
110
134
  }
111
135
  }
112
136
 
113
- // Run variable validation and append warnings/hints
114
- if (features.variableValidation !== false && parser.__symbolTable?.varDeclarations) {
137
+ // Run variable validation (skip for multidoc — already done per-segment internally)
138
+ if (!parser.isMultidoc && features.variableValidation !== false && parser.__symbolTable?.varDeclarations) {
115
139
  const varResult = validateVariables(
116
140
  parser.__symbolTable,
117
141
  lexResult.tokens,
@@ -1,4 +1,6 @@
1
1
  import { documentCache } from "../cache/documentCache.js";
2
+ import { lexDocument } from "../lexer/lexer.js";
3
+ import { findSegmentAtOffset } from "../parser/multidoc.js";
2
4
 
3
5
  /**
4
6
  * Binary search to find token index at the given offset.
@@ -54,9 +56,50 @@ export function onHoverDocument(params, documents, hoverProviders = []) {
54
56
  text,
55
57
  );
56
58
 
59
+ let tokens;
60
+ let hoverParser;
61
+ let tokenOffsetAdjustment = 0;
62
+
63
+ if (parser.isMultidoc) {
64
+ // For multidoc, find the segment at the cursor position and lex it
65
+ const match = findSegmentAtOffset(parser, offset);
66
+ if (!match) return null;
67
+
68
+ const segLexResult = lexDocument(match.segment.text);
69
+ if (segLexResult.errors.length > 0) return null;
70
+
71
+ tokens = segLexResult.tokens;
72
+ hoverParser = match.segment.parser;
73
+ tokenOffsetAdjustment = match.segment.globalOffset;
74
+
75
+ // Find token using segment-local offset
76
+ const tokenIdx = findTokenAtOffset(tokens, match.localOffset);
77
+ if (tokenIdx === -1) return null;
78
+
79
+ const messageProvider = hoverProviders.find((provider) =>
80
+ provider.isMatch(tokenIdx, tokens, hoverParser),
81
+ );
82
+
83
+ if (messageProvider) {
84
+ return {
85
+ contents: {
86
+ kind: "markdown",
87
+ value: messageProvider.render(tokenIdx, tokens, hoverParser),
88
+ },
89
+ range: {
90
+ // Convert segment-local token offsets to global document positions
91
+ start: document.positionAt(tokens[tokenIdx].startOffset + tokenOffsetAdjustment),
92
+ end: document.positionAt(tokens[tokenIdx].endOffset + 1 + tokenOffsetAdjustment),
93
+ },
94
+ };
95
+ }
96
+
97
+ return null;
98
+ }
99
+
57
100
  if (lexResult.errors.length > 0) return null;
58
101
 
59
- const tokens = lexResult.tokens;
102
+ tokens = lexResult.tokens;
60
103
 
61
104
  // Find the token under the cursor using binary search (O(log n))
62
105
  const tokenIdx = findTokenAtOffset(tokens, offset);
@@ -452,6 +452,180 @@ describe("onHoverDocument", () => {
452
452
  expect(result).to.have.property("range");
453
453
  });
454
454
 
455
+ it("should return hover info for var in second segment of multidoc", () => {
456
+ const code = `function foo {
457
+ input {
458
+ }
459
+
460
+ stack {
461
+ }
462
+
463
+ response = null
464
+ }
465
+ ---
466
+ query test verb=GET {
467
+ stack {
468
+ var $result {
469
+ value = "hello"
470
+ }
471
+ }
472
+ }`;
473
+
474
+ const document = TextDocument.create(
475
+ "file://test-multidoc.xs",
476
+ "xanoscript",
477
+ 1,
478
+ code
479
+ );
480
+ const params = {
481
+ textDocument: { uri: "file://test-multidoc.xs" },
482
+ position: { line: 12, character: 4 }, // Position on "var" in second segment
483
+ };
484
+
485
+ const result = onHoverDocument(
486
+ params,
487
+ { get: () => document },
488
+ hoverProviders
489
+ );
490
+
491
+ expect(result).to.not.be.null;
492
+ expect(result.contents.value).to.include("Create a variable");
493
+ expect(result).to.have.property("range");
494
+ // Range should be in the global document, not segment-local
495
+ expect(result.range.start.line).to.be.at.least(10);
496
+ });
497
+
498
+ it("should return hover info for input variable in multidoc segment", () => {
499
+ const code = `function foo {
500
+ input {
501
+ }
502
+
503
+ stack {
504
+ }
505
+
506
+ response = null
507
+ }
508
+ ---
509
+ query test verb=GET {
510
+ input {
511
+ text username
512
+ }
513
+
514
+ stack {
515
+ var $result {
516
+ value = $input.username
517
+ }
518
+ }
519
+
520
+ response = $result
521
+ }`;
522
+
523
+ const document = TextDocument.create(
524
+ "file://test-multidoc-input.xs",
525
+ "xanoscript",
526
+ 1,
527
+ code
528
+ );
529
+ // "username" after $input. in the second segment
530
+ const params = {
531
+ textDocument: { uri: "file://test-multidoc-input.xs" },
532
+ position: { line: 17, character: 20 },
533
+ };
534
+
535
+ const result = onHoverDocument(
536
+ params,
537
+ { get: () => document },
538
+ hoverProviders
539
+ );
540
+
541
+ if (result) {
542
+ expect(result.contents.value).to.include("username");
543
+ expect(result.range.start.line).to.be.at.least(10);
544
+ }
545
+ });
546
+
547
+ it("should return hover info for first segment of multidoc", () => {
548
+ const code = `query test verb=GET {
549
+ stack {
550
+ var $result {
551
+ value = "hello"
552
+ }
553
+ }
554
+ }
555
+ ---
556
+ function bar {
557
+ input {
558
+ }
559
+
560
+ stack {
561
+ }
562
+
563
+ response = null
564
+ }`;
565
+
566
+ const document = TextDocument.create(
567
+ "file://test-multidoc-first.xs",
568
+ "xanoscript",
569
+ 1,
570
+ code
571
+ );
572
+ const params = {
573
+ textDocument: { uri: "file://test-multidoc-first.xs" },
574
+ position: { line: 2, character: 4 }, // "var" in first segment
575
+ };
576
+
577
+ const result = onHoverDocument(
578
+ params,
579
+ { get: () => document },
580
+ hoverProviders
581
+ );
582
+
583
+ expect(result).to.not.be.null;
584
+ expect(result.contents.value).to.include("Create a variable");
585
+ expect(result.range.start.line).to.equal(2);
586
+ });
587
+
588
+ it("should return null for hover on separator line in multidoc", () => {
589
+ const code = `function foo {
590
+ input {
591
+ }
592
+
593
+ stack {
594
+ }
595
+
596
+ response = null
597
+ }
598
+ ---
599
+ function bar {
600
+ input {
601
+ }
602
+
603
+ stack {
604
+ }
605
+
606
+ response = null
607
+ }`;
608
+
609
+ const document = TextDocument.create(
610
+ "file://test-multidoc-sep.xs",
611
+ "xanoscript",
612
+ 1,
613
+ code
614
+ );
615
+ const params = {
616
+ textDocument: { uri: "file://test-multidoc-sep.xs" },
617
+ position: { line: 9, character: 1 }, // On the "---" separator line
618
+ };
619
+
620
+ const result = onHoverDocument(
621
+ params,
622
+ { get: () => document },
623
+ hoverProviders
624
+ );
625
+
626
+ expect(result).to.be.null;
627
+ });
628
+
455
629
  it("should return hover info for query filter tokens in search context", () => {
456
630
  const code = `query books verb=GET {
457
631
  where = $db.embedding.vector|inner_product:$input.query_vector
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xano/xanoscript-language-server",
3
- "version": "11.9.0",
3
+ "version": "11.10.0",
4
4
  "description": "Language Server Protocol implementation for XanoScript",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -83,6 +83,13 @@ describe("dbQueryFn", () => {
83
83
  expect(parser.errors).to.be.empty;
84
84
  });
85
85
 
86
+ it("dbQueryFn accepts a search expression with dynamic field", () => {
87
+ const parser = parse(`query user {
88
+ where = $db.my_table["$search value"] search? $input.values
89
+ } as $user`);
90
+ expect(parser.errors).to.be.empty;
91
+ });
92
+
86
93
  it("dbQueryFn accepts sub addon", () => {
87
94
  const parser = parse(`query user {
88
95
  where = $db.array_columns @> $db.array_columns.id
@@ -98,6 +98,7 @@ const OperatorAcceptingOptionalIfNull = [
98
98
  JsonNotOverlapsToken,
99
99
  JsonNotRegexToken,
100
100
  JsonOverlapsToken,
101
+ JsonSearchToken,
101
102
  ];
102
103
 
103
104
  /**
@@ -0,0 +1,273 @@
1
+ import { lexDocument } from "../lexer/lexer.js";
2
+ import { crossFileValidate } from "../workspace/crossFileValidator.js";
3
+ import { WorkspaceIndex } from "../workspace/workspaceIndex.js";
4
+ import { xanoscriptParser } from "./parser.js";
5
+ import { validateVariables } from "./variableValidator.js";
6
+
7
+ const SEPARATOR = "\n---\n";
8
+ const SEPARATOR_LENGTH = 5;
9
+
10
+ /**
11
+ * Find the segment that contains the given global offset.
12
+ * @param {Object} parserResult - The multidoc parser result (must have segments array)
13
+ * @param {number} offset - Global character offset in the full document
14
+ * @returns {{ segment: Object, localOffset: number } | null}
15
+ */
16
+ export function findSegmentAtOffset(parserResult, offset) {
17
+ if (!parserResult?.segments) return null;
18
+
19
+ for (const segment of parserResult.segments) {
20
+ const segEnd = segment.globalOffset + segment.text.length;
21
+ if (offset >= segment.globalOffset && offset < segEnd) {
22
+ return {
23
+ segment,
24
+ localOffset: offset - segment.globalOffset,
25
+ };
26
+ }
27
+ }
28
+
29
+ // Offset might be at the very end of the last segment
30
+ const last = parserResult.segments[parserResult.segments.length - 1];
31
+ if (last && offset === last.globalOffset + last.text.length) {
32
+ return {
33
+ segment: last,
34
+ localOffset: offset - last.globalOffset,
35
+ };
36
+ }
37
+
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * Snapshot parser state into a plain object.
43
+ * Must be called immediately after xanoscriptParser() before parsing next segment,
44
+ * because the parser is a singleton that gets mutated on each call.
45
+ * @param {import('./base_parser.js').XanoBaseParser} parser
46
+ * @returns {Object} Plain object snapshot
47
+ */
48
+ function snapshotParser(parser) {
49
+ return {
50
+ errors: parser.errors.map((e) => ({
51
+ message: e.message,
52
+ token: e.token
53
+ ? { startOffset: e.token.startOffset, endOffset: e.token.endOffset }
54
+ : null,
55
+ })),
56
+ warnings: parser.warnings.map((w) => ({
57
+ message: w.message,
58
+ token: w.token
59
+ ? { startOffset: w.token.startOffset, endOffset: w.token.endOffset }
60
+ : null,
61
+ })),
62
+ informations: parser.informations.map((i) => ({
63
+ message: i.message,
64
+ token: i.token
65
+ ? { startOffset: i.token.startOffset, endOffset: i.token.endOffset }
66
+ : null,
67
+ })),
68
+ hints: parser.hints.map((h) => ({
69
+ message: h.message,
70
+ token: h.token
71
+ ? { startOffset: h.token.startOffset, endOffset: h.token.endOffset }
72
+ : null,
73
+ })),
74
+ __symbolTable: parser.__symbolTable
75
+ ? {
76
+ input: { ...parser.__symbolTable.input },
77
+ var: { ...parser.__symbolTable.var },
78
+ auth: { ...parser.__symbolTable.auth },
79
+ env: { ...parser.__symbolTable.env },
80
+ references: parser.__symbolTable.references.map((r) => ({
81
+ ...r,
82
+ args: r.args ? { ...r.args } : undefined,
83
+ dataKeys: r.dataKeys ? [...r.dataKeys] : undefined,
84
+ })),
85
+ varDeclarations: parser.__symbolTable.varDeclarations.map((d) => ({
86
+ ...d,
87
+ })),
88
+ }
89
+ : null,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Apply a global offset to all position-bearing fields in a parser snapshot.
95
+ * @param {Object} snapshot - The parser state snapshot
96
+ * @param {number} globalOffset - The character offset to add
97
+ */
98
+ function offsetResult(snapshot, globalOffset) {
99
+ if (globalOffset === 0) return;
100
+
101
+ const offsetToken = (token) => {
102
+ if (!token) return;
103
+ token.startOffset += globalOffset;
104
+ token.endOffset += globalOffset;
105
+ };
106
+
107
+ for (const err of snapshot.errors) offsetToken(err.token);
108
+ for (const warn of snapshot.warnings) offsetToken(warn.token);
109
+ for (const info of snapshot.informations) offsetToken(info.token);
110
+ for (const hint of snapshot.hints) offsetToken(hint.token);
111
+
112
+ if (!snapshot.__symbolTable) return;
113
+
114
+ for (const ref of snapshot.__symbolTable.references) {
115
+ ref.startOffset += globalOffset;
116
+ ref.endOffset += globalOffset;
117
+ if (ref.fieldNameStartOffset != null) {
118
+ ref.fieldNameStartOffset += globalOffset;
119
+ ref.fieldNameEndOffset += globalOffset;
120
+ }
121
+ if (ref.args) {
122
+ for (const arg of Object.values(ref.args)) {
123
+ if (arg && arg.startOffset != null) {
124
+ arg.startOffset += globalOffset;
125
+ arg.endOffset += globalOffset;
126
+ }
127
+ }
128
+ }
129
+ if (ref.dataKeys) {
130
+ for (const dk of ref.dataKeys) {
131
+ if (dk.startOffset != null) {
132
+ dk.startOffset += globalOffset;
133
+ dk.endOffset += globalOffset;
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ for (const decl of snapshot.__symbolTable.varDeclarations) {
140
+ if (decl.startOffset != null) {
141
+ decl.startOffset += globalOffset;
142
+ decl.endOffset += globalOffset;
143
+ }
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Parse a multidoc or single-doc XanoScript text.
149
+ * Detects `\n---\n` separator. If absent, delegates to xanoscriptParser as-is.
150
+ * If present, splits, parses each segment, offsets positions, and merges results.
151
+ * @param {string} text - The full document text
152
+ * @returns {Object} Merged parser result
153
+ */
154
+ export function multidocParser(text) {
155
+ if (!text.includes(SEPARATOR)) {
156
+ // Single document — delegate as-is
157
+ const parser = xanoscriptParser(text);
158
+ return snapshotParser(parser);
159
+ }
160
+
161
+ const segments = text.split(SEPARATOR);
162
+ const results = [];
163
+ let globalOffset = 0;
164
+
165
+ for (const segmentText of segments) {
166
+ const parser = xanoscriptParser(segmentText);
167
+ const snapshot = snapshotParser(parser);
168
+
169
+ // Variable validation with segment-local offsets and tokens
170
+ const segLexResult = lexDocument(segmentText);
171
+ if (snapshot.__symbolTable?.varDeclarations) {
172
+ const varResult = validateVariables(
173
+ snapshot.__symbolTable,
174
+ segLexResult.tokens,
175
+ );
176
+ for (const warning of varResult.warnings) {
177
+ snapshot.warnings.push({
178
+ message: warning.message,
179
+ token: {
180
+ startOffset: warning.startOffset,
181
+ endOffset: warning.endOffset,
182
+ },
183
+ });
184
+ }
185
+ for (const hint of varResult.hints) {
186
+ snapshot.hints.push({
187
+ message: hint.message,
188
+ token: {
189
+ startOffset: hint.startOffset,
190
+ endOffset: hint.endOffset,
191
+ },
192
+ });
193
+ }
194
+ }
195
+
196
+ // Save a copy of the segment's symbol table before offsetting (for workspace indexing and hover)
197
+ const segmentSymbolTable = snapshot.__symbolTable
198
+ ? { input: { ...snapshot.__symbolTable.input } }
199
+ : null;
200
+ // Deep-copy the snapshot before offsetResult mutates it (needed for hover providers)
201
+ const localSnapshot = JSON.parse(JSON.stringify(snapshot));
202
+ results.push({ snapshot, localSnapshot, segmentText, segmentSymbolTable, globalOffset });
203
+ globalOffset += segmentText.length + SEPARATOR_LENGTH;
204
+ }
205
+
206
+ // For now, merge without offsetting (next task)
207
+ const merged = {
208
+ errors: [],
209
+ warnings: [],
210
+ informations: [],
211
+ hints: [],
212
+ __symbolTable: {
213
+ input: {},
214
+ var: {},
215
+ auth: {},
216
+ env: {},
217
+ references: [],
218
+ varDeclarations: [],
219
+ },
220
+ isMultidoc: true,
221
+ segmentCount: segments.length,
222
+ };
223
+
224
+ for (const { snapshot, globalOffset } of results) {
225
+ offsetResult(snapshot, globalOffset);
226
+ merged.errors.push(...snapshot.errors);
227
+ merged.warnings.push(...snapshot.warnings);
228
+ merged.informations.push(...snapshot.informations);
229
+ merged.hints.push(...snapshot.hints);
230
+ if (snapshot.__symbolTable) {
231
+ merged.__symbolTable.references.push(
232
+ ...snapshot.__symbolTable.references,
233
+ );
234
+ merged.__symbolTable.varDeclarations.push(
235
+ ...snapshot.__symbolTable.varDeclarations,
236
+ );
237
+ }
238
+ }
239
+
240
+ // Build local workspace index from all segments for cross-reference validation
241
+ const localIndex = new WorkspaceIndex();
242
+ for (let i = 0; i < results.length; i++) {
243
+ const { segmentText, segmentSymbolTable } = results[i];
244
+ localIndex.addParsed(`multidoc#${i}`, segmentText, segmentSymbolTable);
245
+ }
246
+
247
+ // Validate references against local index only (self-contained)
248
+ if (merged.__symbolTable.references.length > 0) {
249
+ const crossRefWarnings = crossFileValidate(
250
+ merged.__symbolTable.references,
251
+ localIndex,
252
+ );
253
+ for (const warning of crossRefWarnings) {
254
+ merged.warnings.push({
255
+ message: warning.message,
256
+ token: {
257
+ startOffset: warning.startOffset,
258
+ endOffset: warning.endOffset,
259
+ },
260
+ });
261
+ }
262
+ }
263
+
264
+ // Store segment info for workspace indexing and hover support
265
+ merged.segments = results.map(({ segmentText, segmentSymbolTable, localSnapshot, globalOffset }) => ({
266
+ text: segmentText,
267
+ symbolTable: segmentSymbolTable,
268
+ parser: localSnapshot,
269
+ globalOffset,
270
+ }));
271
+
272
+ return merged;
273
+ }