@xano/xanoscript-language-server 11.8.3 → 11.8.5

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.
Files changed (58) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/cache/documentCache.js +58 -10
  3. package/lexer/db.js +9 -1
  4. package/lexer/security.js +16 -0
  5. package/onCompletion/onCompletion.js +61 -1
  6. package/onDefinition/onDefinition.js +150 -0
  7. package/onDefinition/onDefinition.spec.js +313 -0
  8. package/onDidChangeContent/onDidChangeContent.js +52 -5
  9. package/onHover/functions.md +28 -0
  10. package/package.json +1 -1
  11. package/parser/base_parser.js +61 -3
  12. package/parser/clauses/middlewareClause.js +16 -0
  13. package/parser/definitions/columnDefinition.js +5 -0
  14. package/parser/functions/api/apiCallFn.js +5 -3
  15. package/parser/functions/controls/functionCallFn.js +5 -3
  16. package/parser/functions/controls/functionRunFn.js +61 -5
  17. package/parser/functions/controls/taskCallFn.js +5 -3
  18. package/parser/functions/db/captureFieldName.js +63 -0
  19. package/parser/functions/db/dbAddFn.js +5 -3
  20. package/parser/functions/db/dbAddOrEditFn.js +13 -3
  21. package/parser/functions/db/dbBulkAddFn.js +5 -3
  22. package/parser/functions/db/dbBulkDeleteFn.js +5 -3
  23. package/parser/functions/db/dbBulkPatchFn.js +5 -3
  24. package/parser/functions/db/dbBulkUpdateFn.js +5 -3
  25. package/parser/functions/db/dbDelFn.js +10 -3
  26. package/parser/functions/db/dbEditFn.js +13 -3
  27. package/parser/functions/db/dbGetFn.js +10 -3
  28. package/parser/functions/db/dbHasFn.js +9 -3
  29. package/parser/functions/db/dbPatchFn.js +10 -3
  30. package/parser/functions/db/dbQueryFn.js +29 -3
  31. package/parser/functions/db/dbSchemaFn.js +5 -3
  32. package/parser/functions/db/dbTruncateFn.js +5 -3
  33. package/parser/functions/middlewareCallFn.js +3 -1
  34. package/parser/functions/security/register.js +19 -9
  35. package/parser/functions/security/securityCreateAuthTokenFn.js +22 -0
  36. package/parser/functions/security/securityJweDecodeLegacyFn.js +24 -0
  37. package/parser/functions/security/securityJweDecodeLegacyFn.spec.js +26 -0
  38. package/parser/functions/security/securityJweEncodeLegacyFn.js +24 -0
  39. package/parser/functions/security/securityJweEncodeLegacyFn.spec.js +25 -0
  40. package/parser/functions/securityFn.js +2 -0
  41. package/parser/functions/varFn.js +1 -1
  42. package/parser/generic/asVariable.js +2 -0
  43. package/parser/generic/assignableVariableAs.js +1 -0
  44. package/parser/generic/assignableVariableProperty.js +5 -2
  45. package/parser/table_trigger_parser.js +21 -0
  46. package/parser/table_trigger_parser.spec.js +29 -0
  47. package/parser/tests/variable_test/coverage_check.xs +293 -0
  48. package/parser/variableScanner.js +64 -0
  49. package/parser/variableValidator.js +44 -0
  50. package/parser/variableValidator.spec.js +179 -0
  51. package/server.js +164 -10
  52. package/utils.js +32 -0
  53. package/utils.spec.js +93 -1
  54. package/workspace/crossFileValidator.js +166 -0
  55. package/workspace/crossFileValidator.spec.js +654 -0
  56. package/workspace/referenceTracking.spec.js +420 -0
  57. package/workspace/workspaceIndex.js +149 -0
  58. package/workspace/workspaceIndex.spec.js +189 -0
@@ -0,0 +1,64 @@
1
+ import { LongFormVariable,ShortFormVariable } from "../lexer/variables.js";
2
+ import { BLACKLISTED_VARIABLE_NAMES } from "./generic/assignableVariableProperty.js";
3
+
4
+ const SHORT_FORM = ShortFormVariable.name;
5
+ const LONG_FORM = LongFormVariable.name;
6
+
7
+ const BUILTIN_NAMES = new Set(BLACKLISTED_VARIABLE_NAMES.map((n) => `$${n}`));
8
+ // Also skip $$ (temporary variable)
9
+ BUILTIN_NAMES.add("$$");
10
+
11
+ /**
12
+ * Scan the token stream for variable usages (ShortFormVariable and LongFormVariable tokens).
13
+ * Excludes tokens at declaration offsets and built-in variable names.
14
+ * @param {import('chevrotain').IToken[]} tokens - The token stream
15
+ * @param {Array<{name: string, startOffset: number | null}>} declarations - Known declaration sites
16
+ * @returns {Array<{name: string, startOffset: number, endOffset: number}>}
17
+ */
18
+ export function scanVariableUsages(tokens, declarations) {
19
+ // Build set of declaration offsets to skip
20
+ const declOffsets = new Set();
21
+ for (const decl of declarations) {
22
+ if (decl.startOffset != null) {
23
+ declOffsets.add(decl.startOffset);
24
+ }
25
+ }
26
+
27
+ const usages = [];
28
+
29
+ for (let i = 0; i < tokens.length; i++) {
30
+ const token = tokens[i];
31
+ const typeName = token.tokenType.name;
32
+
33
+ if (typeName === SHORT_FORM) {
34
+ // Skip if this is a declaration site
35
+ if (declOffsets.has(token.startOffset)) continue;
36
+ // Skip built-in variable names
37
+ if (BUILTIN_NAMES.has(token.image)) continue;
38
+
39
+ usages.push({
40
+ name: token.image,
41
+ startOffset: token.startOffset,
42
+ endOffset: token.endOffset,
43
+ });
44
+ } else if (typeName === LONG_FORM) {
45
+ // $var.foo → resolve to $foo
46
+ // Next tokens should be Dot + Identifier
47
+ if (i + 2 < tokens.length) {
48
+ const identToken = tokens[i + 2];
49
+ const name = `$${identToken.image}`;
50
+
51
+ // Skip if this is a declaration site (check the identifier token)
52
+ if (declOffsets.has(identToken.startOffset)) continue;
53
+
54
+ usages.push({
55
+ name,
56
+ startOffset: token.startOffset,
57
+ endOffset: identToken.endOffset,
58
+ });
59
+ }
60
+ }
61
+ }
62
+
63
+ return usages;
64
+ }
@@ -0,0 +1,44 @@
1
+ import { scanVariableUsages } from "./variableScanner.js";
2
+
3
+ /**
4
+ * Validate variable declarations vs usages.
5
+ * Returns undefined-variable warnings and unused-variable hints.
6
+ * @param {Object} symbolTable - The parser's __symbolTable
7
+ * @param {import('chevrotain').IToken[]} tokens - The token stream from parser.input
8
+ * @returns {{ warnings: Array<{message: string, startOffset: number, endOffset: number}>, hints: Array<{message: string, startOffset: number, endOffset: number}> }}
9
+ */
10
+ export function validateVariables(symbolTable, tokens) {
11
+ const warnings = [];
12
+ const hints = [];
13
+
14
+ const declarations = symbolTable.varDeclarations || [];
15
+ const declaredNames = new Set(declarations.map((d) => d.name));
16
+
17
+ const usages = scanVariableUsages(tokens, declarations);
18
+ const usedNames = new Set(usages.map((u) => u.name));
19
+
20
+ // Undefined: used but not declared
21
+ for (const usage of usages) {
22
+ if (!declaredNames.has(usage.name)) {
23
+ warnings.push({
24
+ message: `Unknown variable '${usage.name}'`,
25
+ startOffset: usage.startOffset,
26
+ endOffset: usage.endOffset,
27
+ });
28
+ }
29
+ }
30
+
31
+ // Unused: declared but never used
32
+ for (const decl of declarations) {
33
+ if (decl.startOffset == null) continue;
34
+ if (!usedNames.has(decl.name)) {
35
+ hints.push({
36
+ message: `Variable '${decl.name}' is declared but never used`,
37
+ startOffset: decl.startOffset,
38
+ endOffset: decl.endOffset,
39
+ });
40
+ }
41
+ }
42
+
43
+ return { warnings, hints };
44
+ }
@@ -0,0 +1,179 @@
1
+ import { expect } from "chai";
2
+ import { readFileSync } from "fs";
3
+ import { before, describe, it } from "mocha";
4
+ import { lexDocument } from "../lexer/lexer.js";
5
+ import { xanoscriptParser } from "./parser.js";
6
+ import { scanVariableUsages } from "./variableScanner.js";
7
+ import { validateVariables } from "./variableValidator.js";
8
+
9
+ describe("variable tracking - coverage_check.xs", () => {
10
+ let varDeclarations;
11
+ let usages;
12
+ let result;
13
+ let text;
14
+
15
+ before(() => {
16
+ text = readFileSync(
17
+ new URL("./tests/variable_test/coverage_check.xs", import.meta.url),
18
+ "utf8",
19
+ );
20
+ const lexResult = lexDocument(text);
21
+ const parser = xanoscriptParser(text);
22
+ expect(parser.errors).to.be.empty;
23
+ varDeclarations = [...parser.__symbolTable.varDeclarations];
24
+ usages = scanVariableUsages(lexResult.tokens, varDeclarations);
25
+ result = validateVariables(parser.__symbolTable, lexResult.tokens);
26
+ });
27
+
28
+ function lineOf(offset) {
29
+ return text.substring(0, offset).split("\n").length;
30
+ }
31
+
32
+ function declLines(name) {
33
+ return varDeclarations
34
+ .filter((d) => d.name === name)
35
+ .map((d) => lineOf(d.startOffset));
36
+ }
37
+
38
+ function usageLines(name) {
39
+ return usages.filter((u) => u.name === name).map((u) => lineOf(u.startOffset));
40
+ }
41
+
42
+ // --- Declarations: each variable should be declared exactly once ---
43
+
44
+ it("$vehicle declared once at line 26 via 'as $vehicle'", () => {
45
+ expect(declLines("$vehicle")).to.deep.equal([26]);
46
+ });
47
+
48
+ it("$unused_variable declared once at line 35 via 'var'", () => {
49
+ expect(declLines("$unused_variable")).to.deep.equal([35]);
50
+ });
51
+
52
+ it("$vehicle_response declared once at line 40 via 'var'", () => {
53
+ expect(declLines("$vehicle_response")).to.deep.equal([40]);
54
+ });
55
+
56
+ it("$policy declared once at line 63 via 'as $policy'", () => {
57
+ expect(declLines("$policy")).to.deep.equal([63]);
58
+ });
59
+
60
+ it("$policy_response declared once at line 66 via 'var' (NOT again at line 87 via var.update)", () => {
61
+ expect(declLines("$policy_response")).to.deep.equal([66]);
62
+ });
63
+
64
+ it("$coverages_response declared once at line 70 via 'var'", () => {
65
+ expect(declLines("$coverages_response")).to.deep.equal([70]);
66
+ });
67
+
68
+ it("$claims_summary declared once at line 74 via 'var' (NOT again at line 188 via var.update)", () => {
69
+ expect(declLines("$claims_summary")).to.deep.equal([74]);
70
+ });
71
+
72
+ it("$policy_coverages declared once at line 105 via 'as'", () => {
73
+ expect(declLines("$policy_coverages")).to.deep.equal([105]);
74
+ });
75
+
76
+ it("$pc declared once at line 109 via 'each as $pc'", () => {
77
+ expect(declLines("$pc")).to.deep.equal([109]);
78
+ });
79
+
80
+ it("$ctype declared once at line 113 via 'as $ctype'", () => {
81
+ expect(declLines("$ctype")).to.deep.equal([113]);
82
+ });
83
+
84
+ it("$coverage_item declared once at line 116 via 'var'", () => {
85
+ expect(declLines("$coverage_item")).to.deep.equal([116]);
86
+ });
87
+
88
+ it("$all_claims declared once at line 142 via 'as'", () => {
89
+ expect(declLines("$all_claims")).to.deep.equal([142]);
90
+ });
91
+
92
+ it("$total_claims_count declared once at line 145 via 'var'", () => {
93
+ expect(declLines("$total_claims_count")).to.deep.equal([145]);
94
+ });
95
+
96
+ it("$two_years_ago declared once at line 150 via 'var'", () => {
97
+ expect(declLines("$two_years_ago")).to.deep.equal([150]);
98
+ });
99
+
100
+ it("$recent_claims_list declared once at line 155 via 'var'", () => {
101
+ expect(declLines("$recent_claims_list")).to.deep.equal([155]);
102
+ });
103
+
104
+ it("$claim declared once at line 160 via 'each as $claim'", () => {
105
+ expect(declLines("$claim")).to.deep.equal([160]);
106
+ });
107
+
108
+ it("$claim_date_ts declared once at line 162 via 'var'", () => {
109
+ expect(declLines("$claim_date_ts")).to.deep.equal([162]);
110
+ });
111
+
112
+ it("$recent_claim_item declared once at line 169 via 'var'", () => {
113
+ expect(declLines("$recent_claim_item")).to.deep.equal([169]);
114
+ });
115
+
116
+ it("$queried_by declared once at line 202 via 'var' (NOT again at line 217 via var.update)", () => {
117
+ expect(declLines("$queried_by")).to.deep.equal([202]);
118
+ });
119
+
120
+ it("$shop declared once at line 212 via 'as'", () => {
121
+ expect(declLines("$shop")).to.deep.equal([212]);
122
+ });
123
+
124
+ it("$result declared once at line 230 via 'var' (NOT again at line 242 via var.update)", () => {
125
+ expect(declLines("$result")).to.deep.equal([230]);
126
+ });
127
+
128
+ // --- Usages: var.update targets count as usages ---
129
+
130
+ it("$vehicle used at lines 29, 42-46, 57", () => {
131
+ expect(usageLines("$vehicle")).to.deep.equal([29, 42, 43, 44, 45, 46, 57]);
132
+ });
133
+
134
+ it("$policy_response used at line 87 (var.update) and 233", () => {
135
+ expect(usageLines("$policy_response")).to.include(87);
136
+ expect(usageLines("$policy_response")).to.include(233);
137
+ });
138
+
139
+ it("$claims_summary used at line 188 (var.update) and 235", () => {
140
+ expect(usageLines("$claims_summary")).to.include(188);
141
+ expect(usageLines("$claims_summary")).to.include(235);
142
+ });
143
+
144
+ it("$queried_by used at line 217 (var.update), 241, 243", () => {
145
+ expect(usageLines("$queried_by")).to.include(217);
146
+ expect(usageLines("$queried_by")).to.include(241);
147
+ expect(usageLines("$queried_by")).to.include(243);
148
+ });
149
+
150
+ it("$result used at line 242 (var.update), 243, 251", () => {
151
+ expect(usageLines("$result")).to.include(242);
152
+ expect(usageLines("$result")).to.include(243);
153
+ expect(usageLines("$result")).to.include(251);
154
+ });
155
+
156
+ it("$coverages_response used at lines 127 and 234", () => {
157
+ expect(usageLines("$coverages_response")).to.include(127);
158
+ expect(usageLines("$coverages_response")).to.include(234);
159
+ });
160
+
161
+ it("$pc used at lines 112, 120-122", () => {
162
+ expect(usageLines("$pc")).to.deep.equal([112, 120, 121, 122]);
163
+ });
164
+
165
+ it("$claim used at lines 163, 171-175", () => {
166
+ expect(usageLines("$claim")).to.deep.equal([163, 171, 172, 173, 174, 175]);
167
+ });
168
+
169
+ // --- Validation results ---
170
+
171
+ it("should produce zero undefined-variable warnings", () => {
172
+ expect(result.warnings).to.be.empty;
173
+ });
174
+
175
+ it("should flag only $unused_variable as unused", () => {
176
+ expect(result.hints).to.have.lengthOf(1);
177
+ expect(result.hints[0].message).to.include("$unused_variable");
178
+ });
179
+ });
package/server.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { readdirSync, readFileSync } from "fs";
2
+ import { extname, join } from "path";
3
+ import { setImmediate } from "timers";
4
+ import { fileURLToPath, pathToFileURL } from "url";
1
5
  import {
2
6
  createConnection,
3
7
  ProposedFeatures,
@@ -5,11 +9,16 @@ import {
5
9
  TextDocuments,
6
10
  } from "vscode-languageserver/node.js";
7
11
  import { TextDocument } from "vscode-languageserver-textdocument";
12
+ import { documentCache } from "./cache/documentCache.js";
8
13
  import { onCompletion } from "./onCompletion/onCompletion.js";
14
+ import { findDefinition } from "./onDefinition/onDefinition.js";
9
15
  import { onDidChangeContent } from "./onDidChangeContent/onDidChangeContent.js";
10
16
  import { onHover } from "./onHover/onHover.js";
11
17
  import { onSemanticCheck } from "./onSemanticCheck/onSemanticCheck.js";
12
18
  import { TOKEN_TYPES } from "./onSemanticCheck/tokens.js";
19
+ import { xanoscriptParser } from "./parser/parser.js";
20
+ import { getSchemeFromContent } from "./utils.js";
21
+ import { workspaceIndex } from "./workspace/workspaceIndex.js";
13
22
 
14
23
  // Create a connection to the VS Code client
15
24
  const connection = createConnection(ProposedFeatures.all);
@@ -18,40 +27,185 @@ const connection = createConnection(ProposedFeatures.all);
18
27
  export const documents = new TextDocuments(TextDocument);
19
28
 
20
29
  // Initialize the server
21
- connection.onInitialize(() => {
30
+ connection.onInitialize((params) => {
22
31
  connection.console.log("XanoScript Language Server initialized");
23
32
 
33
+ // Scan workspace for .xs files to populate the index
34
+ if (params.workspaceFolders) {
35
+ for (const folder of params.workspaceFolders) {
36
+ scanWorkspaceFolder(folder.uri);
37
+ }
38
+ } else if (params.rootUri) {
39
+ scanWorkspaceFolder(params.rootUri);
40
+ }
41
+
24
42
  return {
25
43
  capabilities: {
26
44
  completionProvider: {
27
- resolveProvider: false, // We won't implement resolve for now (additional details for completions)
28
- triggerCharacters: [".", ":", "$", "|"], // Trigger completion on '.', ':', '$', '|' (e.g., for $variables and filters)
45
+ resolveProvider: false,
46
+ triggerCharacters: [".", ":", "$", "|"],
29
47
  },
30
- textDocumentSync: 1, // Full sync mode: server receives entire document content on change
48
+ textDocumentSync: 1,
31
49
  semanticTokensProvider: {
32
50
  documentSelector: [{ language: "xanoscript" }],
33
51
  legend: {
34
52
  tokenTypes: TOKEN_TYPES,
35
- tokenModifiers: [], // Optional, like 'static' or 'deprecated'
53
+ tokenModifiers: [],
54
+ },
55
+ full: true,
56
+ },
57
+ hoverProvider: true,
58
+ definitionProvider: true,
59
+ workspace: {
60
+ workspaceFolders: {
61
+ supported: true,
36
62
  },
37
- full: true, // Support full document highlighting
38
63
  },
39
- hoverProvider: true, // Enable hover support
40
64
  },
41
65
  };
42
66
  });
43
67
 
68
+ /**
69
+ * Recursively scan a folder for .xs files and add them to the workspace index.
70
+ * Uses lightweight regex-only indexing (no parsing) and yields to the event loop
71
+ * in batches to avoid blocking and allow GC to run.
72
+ * @param {string} folderUri - file:// URI of the folder
73
+ */
74
+ function scanWorkspaceFolder(folderUri) {
75
+ try {
76
+ const folderPath = fileURLToPath(folderUri);
77
+ const filePaths = collectXsFiles(folderPath);
78
+ indexFilesInBatches(filePaths);
79
+ } catch (err) {
80
+ connection.console.error(`Error scanning workspace: ${err.message}`);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Collect all .xs file paths recursively (fast, no parsing).
86
+ */
87
+ function collectXsFiles(dirPath) {
88
+ const result = [];
89
+ const stack = [dirPath];
90
+ while (stack.length > 0) {
91
+ const dir = stack.pop();
92
+ let entries;
93
+ try {
94
+ entries = readdirSync(dir, { withFileTypes: true });
95
+ } catch {
96
+ continue;
97
+ }
98
+ for (const entry of entries) {
99
+ const fullPath = join(dir, entry.name);
100
+ if (
101
+ entry.isDirectory() &&
102
+ !entry.name.startsWith(".") &&
103
+ entry.name !== "node_modules"
104
+ ) {
105
+ stack.push(fullPath);
106
+ } else if (entry.isFile() && extname(entry.name) === ".xs") {
107
+ result.push(fullPath);
108
+ }
109
+ }
110
+ }
111
+ return result;
112
+ }
113
+
114
+ /** Number of files to index per event-loop tick */
115
+ const SCAN_BATCH_SIZE = 50;
116
+
117
+ /**
118
+ * Index files in batches, yielding to the event loop between batches
119
+ * so GC can run and the server stays responsive.
120
+ */
121
+ function indexFilesInBatches(filePaths) {
122
+ let offset = 0;
123
+ function processBatch() {
124
+ const end = Math.min(offset + SCAN_BATCH_SIZE, filePaths.length);
125
+ for (let i = offset; i < end; i++) {
126
+ try {
127
+ const content = readFileSync(filePaths[i], "utf-8");
128
+ const uri = pathToFileURL(filePaths[i]).toString();
129
+ workspaceIndex.addFile(uri, content);
130
+ } catch {
131
+ // Skip unreadable files
132
+ }
133
+ }
134
+ offset = end;
135
+ if (offset < filePaths.length) {
136
+ setImmediate(processBatch);
137
+ }
138
+ }
139
+ if (filePaths.length > 0) {
140
+ processBatch();
141
+ }
142
+ }
143
+
144
+ connection.onDidChangeWatchedFiles((params) => {
145
+ for (const change of params.changes) {
146
+ if (!change.uri.endsWith(".xs")) continue;
147
+ switch (change.type) {
148
+ case 3: // Deleted
149
+ workspaceIndex.removeFile(change.uri);
150
+ break;
151
+ case 1: // Created
152
+ case 2: // Changed (from external editor)
153
+ try {
154
+ const filePath = fileURLToPath(change.uri);
155
+ const content = readFileSync(filePath, "utf-8");
156
+ const scheme = getSchemeFromContent(content);
157
+ const parser = xanoscriptParser(content, scheme);
158
+ workspaceIndex.addParsed(change.uri, content, parser.__symbolTable);
159
+ } catch {
160
+ // Skip unreadable
161
+ }
162
+ break;
163
+ }
164
+ }
165
+ });
166
+
44
167
  connection.onHover((params) => onHover(params, documents));
45
168
  connection.onCompletion((params) => onCompletion(params, documents));
169
+ connection.onDefinition((params) => {
170
+ const document = documents.get(params.textDocument.uri);
171
+ if (!document) return null;
172
+
173
+ const text = document.getText();
174
+ const offset = document.offsetAt(params.position);
175
+ const cached = documentCache.getOrParse(
176
+ document.uri,
177
+ document.version,
178
+ text,
179
+ );
180
+ const symbolTable = cached.parser?.__symbolTable;
181
+ const result = findDefinition(text, offset, workspaceIndex, symbolTable);
182
+
183
+ if (!result) return null;
184
+
185
+ // Same-file variable definition (has offset)
186
+ if (result.offset != null) {
187
+ const pos = document.positionAt(result.offset);
188
+ return {
189
+ uri: document.uri,
190
+ range: { start: pos, end: pos },
191
+ };
192
+ }
193
+
194
+ // Cross-file definition (has uri)
195
+ return {
196
+ uri: result.uri,
197
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
198
+ };
199
+ });
46
200
  connection.onRequest("textDocument/semanticTokens/full", (params) =>
47
201
  onSemanticCheck(params, documents, SemanticTokensBuilder),
48
202
  );
49
203
  documents.onDidChangeContent((params) =>
50
204
  onDidChangeContent(params, connection),
51
205
  );
52
- connection.onDidOpenTextDocument((params) => {
53
- console.log("Document opened:", params.textDocument.uri);
54
- // Existing handler logic
206
+ documents.onDidClose((params) => {
207
+ const uri = params.document.uri;
208
+ documentCache.invalidate(uri);
55
209
  });
56
210
 
57
211
  // Bind the document manager to the connection and start listening
package/utils.js CHANGED
@@ -54,3 +54,35 @@ export function getSchemeFromContent(source) {
54
54
  const firstWord = source.match(firstWordRegex)?.[1];
55
55
  return schemeByFirstWord[firstWord] || "cfn";
56
56
  }
57
+
58
+ // Reverse map: scheme codes back to user-facing object type keywords
59
+ const typeByScheme = {
60
+ cfn: "function",
61
+ api: "query",
62
+ db: "table",
63
+ // All other schemes map 1:1 (addon, agent, task, etc.)
64
+ };
65
+
66
+ // Extends firstWordRegex to also capture the name (quoted or bare identifier)
67
+ const objectInfoRegex = /^(?:\s|\/\/[^\n]*\n)*(\w+)\s+(?:"([^"]+)"|(\w+))/;
68
+
69
+ /**
70
+ * Extract the object type and name from XanoScript file content.
71
+ * Reuses schemeByFirstWord for type detection, extends the regex to capture the name.
72
+ * @param {string} source - File content
73
+ * @returns {{ type: string, name: string } | null}
74
+ */
75
+ export function getObjectInfoFromContent(source) {
76
+ const match = source.match(objectInfoRegex);
77
+ if (!match) return null;
78
+
79
+ const firstWord = match[1];
80
+ const scheme = schemeByFirstWord[firstWord];
81
+ if (!scheme) return null;
82
+
83
+ const name = match[2] ?? match[3];
84
+ if (!name) return null;
85
+
86
+ const type = typeByScheme[scheme] ?? scheme;
87
+ return { type, name };
88
+ }
package/utils.spec.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { expect } from "chai";
2
2
  import { describe, it } from "mocha";
3
- import { getSchemeFromContent, Iterator } from "./utils.js";
3
+ import {
4
+ getObjectInfoFromContent,
5
+ getSchemeFromContent,
6
+ Iterator,
7
+ } from "./utils.js";
4
8
 
5
9
  describe("getSchemeFromContent", () => {
6
10
  describe("basic keyword detection", () => {
@@ -242,6 +246,94 @@ table products {
242
246
  });
243
247
  });
244
248
 
249
+ describe("getObjectInfoFromContent", () => {
250
+ it("should extract function with identifier name", () => {
251
+ const result = getObjectInfoFromContent(
252
+ "function my_func {\n stack {\n }\n}"
253
+ );
254
+ expect(result).to.deep.equal({ type: "function", name: "my_func" });
255
+ });
256
+
257
+ it("should extract function with string literal name", () => {
258
+ const result = getObjectInfoFromContent('function "my func" {\n}');
259
+ expect(result).to.deep.equal({ type: "function", name: "my func" });
260
+ });
261
+
262
+ it("should extract table", () => {
263
+ const result = getObjectInfoFromContent(
264
+ "table users {\n schema {\n }\n}"
265
+ );
266
+ expect(result).to.deep.equal({ type: "table", name: "users" });
267
+ });
268
+
269
+ it("should extract query with verb", () => {
270
+ const result = getObjectInfoFromContent('query "/users" GET {\n}');
271
+ expect(result).to.deep.equal({ type: "query", name: "/users" });
272
+ });
273
+
274
+ it("should extract task", () => {
275
+ const result = getObjectInfoFromContent('task "daily_cleanup" {\n}');
276
+ expect(result).to.deep.equal({ type: "task", name: "daily_cleanup" });
277
+ });
278
+
279
+ it("should extract api_group", () => {
280
+ const result = getObjectInfoFromContent('api_group "users" {\n}');
281
+ expect(result).to.deep.equal({ type: "api_group", name: "users" });
282
+ });
283
+
284
+ it("should handle leading comments", () => {
285
+ const result = getObjectInfoFromContent(
286
+ "// my function\nfunction my_func {\n}"
287
+ );
288
+ expect(result).to.deep.equal({ type: "function", name: "my_func" });
289
+ });
290
+
291
+ it("should handle leading whitespace and multiple comments", () => {
292
+ const result = getObjectInfoFromContent(
293
+ "\n// comment 1\n// comment 2\ntable users {\n}"
294
+ );
295
+ expect(result).to.deep.equal({ type: "table", name: "users" });
296
+ });
297
+
298
+ it("should return null for empty content", () => {
299
+ expect(getObjectInfoFromContent("")).to.be.null;
300
+ });
301
+
302
+ it("should return null for content with no name token", () => {
303
+ expect(getObjectInfoFromContent("function")).to.be.null;
304
+ });
305
+
306
+ it("should extract workflow_test", () => {
307
+ const result = getObjectInfoFromContent(
308
+ 'workflow_test "login flow" {\n}'
309
+ );
310
+ expect(result).to.deep.equal({
311
+ type: "workflow_test",
312
+ name: "login flow",
313
+ });
314
+ });
315
+
316
+ it("should extract table_trigger", () => {
317
+ const result = getObjectInfoFromContent(
318
+ 'table_trigger "on_user_create" {\n}'
319
+ );
320
+ expect(result).to.deep.equal({
321
+ type: "table_trigger",
322
+ name: "on_user_create",
323
+ });
324
+ });
325
+
326
+ it("should extract middleware", () => {
327
+ const result = getObjectInfoFromContent('middleware "auth_check" {\n}');
328
+ expect(result).to.deep.equal({ type: "middleware", name: "auth_check" });
329
+ });
330
+
331
+ it("should extract addon", () => {
332
+ const result = getObjectInfoFromContent('addon "my_addon" {\n}');
333
+ expect(result).to.deep.equal({ type: "addon", name: "my_addon" });
334
+ });
335
+ });
336
+
245
337
  describe("Iterator", () => {
246
338
  describe("basic functionality", () => {
247
339
  it("should iterate through items", () => {