@514labs/moose-lsp 1.4.0 → 1.4.1

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 +1 @@
1
- {"version":3,"sources":["../src/pythonSqlExtractor.ts"],"sourcesContent":["import * as path from 'node:path';\nimport {\n Language,\n Parser,\n type Point,\n type Node as SyntaxNode,\n} from 'web-tree-sitter';\nimport type { SqlLocation } from './sqlLocations';\n\nlet parser: Parser | null = null;\n\n/**\n * Initialize the web-tree-sitter parser with the Python grammar.\n * Must be called before any extraction functions.\n */\nexport async function initParser(): Promise<void> {\n if (parser) return;\n\n await Parser.init();\n const p = new Parser();\n\n // Load the Python WASM grammar from dist/ (copied during build)\n const wasmPath = path.join(__dirname, 'tree-sitter-python.wasm');\n const Python = await Language.load(wasmPath);\n p.setLanguage(Python);\n parser = p;\n}\n\nfunction getParser(): Parser {\n if (!parser) {\n throw new Error(\n 'Parser not initialized. Call initParser() before using extraction functions.',\n );\n }\n return parser;\n}\n\n/**\n * Check if the file imports from moose_lib.\n * This is used to filter out sql() calls that aren't from moose-lib.\n */\nfunction fileImportsMooseLib(rootNode: SyntaxNode): boolean {\n for (const child of rootNode.children) {\n if (!child) continue;\n // Handle: import moose_lib\n if (child.type === 'import_statement') {\n const moduleName = child.childForFieldName('name');\n if (moduleName?.text === 'moose_lib') {\n return true;\n }\n // Handle: import moose_lib.sql\n if (\n moduleName?.type === 'dotted_name' &&\n moduleName.text.startsWith('moose_lib')\n ) {\n return true;\n }\n }\n\n // Handle: from moose_lib import sql\n // Handle: from moose_lib.sql import sql\n if (child.type === 'import_from_statement') {\n const moduleName = child.childForFieldName('module_name');\n if (\n moduleName?.text === 'moose_lib' ||\n moduleName?.text?.startsWith('moose_lib.')\n ) {\n return true;\n }\n }\n }\n\n return false;\n}\n\n/**\n * Check if a function call is a `sql()` call from moose-lib.\n * We look for calls where the function name is 'sql'.\n */\nfunction isSqlFunctionCall(node: SyntaxNode): boolean {\n if (node.type !== 'call') return false;\n\n const functionNode = node.childForFieldName('function');\n if (!functionNode) return false;\n\n // Direct call: sql(\"...\")\n if (functionNode.type === 'identifier' && functionNode.text === 'sql') {\n return true;\n }\n\n // Attribute access: moose_lib.sql(\"...\") or similar\n if (functionNode.type === 'attribute') {\n const attribute = functionNode.childForFieldName('attribute');\n if (attribute?.text === 'sql') {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Extract the SQL string from a sql() function call arguments.\n * Handles both regular strings and f-strings.\n */\nfunction extractSqlFromCall(callNode: SyntaxNode): {\n text: string;\n startPosition: Point;\n endPosition: Point;\n} | null {\n const arguments_ = callNode.childForFieldName('arguments');\n if (!arguments_) return null;\n\n // Get the first argument (the SQL string)\n for (const child of arguments_.children) {\n if (!child) continue;\n if (\n child.type === 'string' ||\n child.type === 'concatenated_string' ||\n child.type === 'formatted_string'\n ) {\n const result = extractStringContent(child);\n if (result) {\n return {\n text: result,\n startPosition: child.startPosition,\n endPosition: child.endPosition,\n };\n }\n }\n }\n\n return null;\n}\n\n/**\n * Extract text content from a string node (regular string or f-string).\n * Converts f-string interpolations to ${...} placeholders.\n */\nfunction extractStringContent(node: SyntaxNode): string | null {\n const text = node.text;\n const isFString = /^f['\"]/.test(text);\n\n if (node.type === 'string') {\n if (isFString) {\n // F-string: f\"SELECT {col} FROM users\"\n return extractFStringContent(text);\n }\n // Regular string: \"SELECT * FROM users\"\n // Remove quotes and extract content\n return extractQuotedStringContent(text);\n }\n\n if (node.type === 'formatted_string') {\n // F-string: f\"SELECT {col} FROM users\"\n return extractFStringContent(text);\n }\n\n if (node.type === 'concatenated_string') {\n // Handle concatenated strings: \"SELECT \" \"* FROM users\"\n let result = '';\n for (const child of node.children) {\n if (!child) continue;\n if (child.type === 'string' || child.type === 'formatted_string') {\n const content = extractStringContent(child);\n if (content !== null) {\n result += content;\n }\n }\n }\n return result || null;\n }\n\n return null;\n}\n\n/**\n * Extract content from a quoted string, removing the quotes.\n */\nfunction extractQuotedStringContent(text: string): string | null {\n // Handle triple-quoted strings\n if (\n text.startsWith('\"\"\"') ||\n text.startsWith(\"'''\") ||\n text.startsWith('f\"\"\"') ||\n text.startsWith(\"f'''\") ||\n text.startsWith('r\"\"\"') ||\n text.startsWith(\"r'''\")\n ) {\n const prefixLen = text.startsWith('f') || text.startsWith('r') ? 4 : 3;\n return text.slice(prefixLen, -3);\n }\n\n // Handle single/double quoted strings\n if (\n text.startsWith('\"') ||\n text.startsWith(\"'\") ||\n text.startsWith('f\"') ||\n text.startsWith(\"f'\") ||\n text.startsWith('r\"') ||\n text.startsWith(\"r'\")\n ) {\n const prefixLen = text.startsWith('f') || text.startsWith('r') ? 2 : 1;\n return text.slice(prefixLen, -1);\n }\n\n return text;\n}\n\n/**\n * Extract content from an f-string text, converting interpolations to ${...}.\n * Works with raw text (e.g., f\"SELECT {col} FROM users\").\n */\nfunction extractFStringContent(text: string): string {\n // Remove the f-string prefix (f\", f', f\"\"\", f''')\n let content: string;\n if (text.startsWith('f\"\"\"') || text.startsWith(\"f'''\")) {\n content = text.slice(4, -3);\n } else if (text.startsWith('f\"') || text.startsWith(\"f'\")) {\n content = text.slice(2, -1);\n } else {\n content = text;\n }\n\n // Replace balanced {expr} with ${...} using depth tracking\n // This handles nested braces like {func({1, 2})} correctly\n let result = '';\n let i = 0;\n while (i < content.length) {\n if (content[i] === '{') {\n if (content[i + 1] === '{') {\n // Escaped {{ → literal {\n result += '{';\n i += 2;\n } else {\n // Find matching closing brace with depth tracking\n let depth = 1;\n let j = i + 1;\n while (j < content.length && depth > 0) {\n if (content[j] === '{') depth++;\n else if (content[j] === '}') depth--;\n j++;\n }\n result += '${...}';\n i = j;\n }\n } else if (content[i] === '}' && content[i + 1] === '}') {\n // Escaped }} → literal }\n result += '}';\n i += 2;\n } else {\n result += content[i];\n i++;\n }\n }\n return result;\n}\n\n/**\n * Check if a string node contains SQL-like content.\n * Used to identify f-strings that might contain SQL even outside sql() calls.\n */\nfunction looksLikeSql(text: string): boolean {\n const sqlKeywords = [\n 'SELECT',\n 'INSERT',\n 'UPDATE',\n 'DELETE',\n 'CREATE',\n 'DROP',\n 'ALTER',\n 'FROM',\n 'WHERE',\n 'JOIN',\n 'TABLE',\n 'INDEX',\n 'VIEW',\n ];\n\n const upperText = text.toUpperCase();\n return sqlKeywords.some((keyword) => upperText.includes(keyword));\n}\n\n/**\n * Check if a formatted string contains :col format specifier.\n * This is a moose-lib specific pattern for SQL columns.\n */\nfunction hasColFormatSpecifier(node: SyntaxNode): boolean {\n const text = node.text;\n // Look for patterns like {var:col} or {Model.field:col}\n return /:col\\s*\\}/.test(text);\n}\n\n/**\n * Extract SQL location from a node.\n */\nfunction createSqlLocation(\n filePath: string,\n text: string,\n startPosition: Point,\n endPosition: Point,\n): SqlLocation {\n return {\n id: `${filePath}:${startPosition.row + 1}:${startPosition.column + 1}`,\n file: filePath,\n line: startPosition.row + 1, // 1-based\n column: startPosition.column + 1, // 1-based\n endLine: endPosition.row + 1,\n endColumn: endPosition.column + 1,\n templateText: text,\n };\n}\n\n/**\n * Extract all SQL locations from a Python source file.\n * Finds:\n * 1. sql() function calls with string arguments\n * 2. F-strings with :col format specifiers (moose-lib pattern)\n */\nexport function extractPythonSqlLocations(\n sourceCode: string,\n filePath: string,\n): SqlLocation[] {\n const locations: SqlLocation[] = [];\n\n const tree = getParser().parse(sourceCode);\n if (!tree) return locations;\n\n // Only process sql() calls if the file imports from moose_lib\n const hasMooseImport = fileImportsMooseLib(tree.rootNode);\n\n function visit(node: SyntaxNode): void {\n // Check for sql() function calls (only if file imports moose_lib)\n if (hasMooseImport && isSqlFunctionCall(node)) {\n const sqlContent = extractSqlFromCall(node);\n if (sqlContent) {\n locations.push(\n createSqlLocation(\n filePath,\n sqlContent.text,\n sqlContent.startPosition,\n sqlContent.endPosition,\n ),\n );\n }\n }\n\n // Check for f-strings with :col format specifier (moose-lib SQL pattern)\n // tree-sitter-python may use 'string' or 'formatted_string' for f-strings\n const isFString =\n node.type === 'formatted_string' ||\n (node.type === 'string' && /^f['\"]/.test(node.text));\n\n if (isFString && hasColFormatSpecifier(node)) {\n const content = extractFStringContent(node.text);\n if (content && looksLikeSql(content)) {\n locations.push(\n createSqlLocation(\n filePath,\n content,\n node.startPosition,\n node.endPosition,\n ),\n );\n }\n }\n\n // Recursively visit children\n for (const child of node.children) {\n if (child) visit(child);\n }\n }\n\n visit(tree.rootNode);\n return locations;\n}\n\n/**\n * Extract SQL locations from all provided Python files.\n * Used for initial scan of the project.\n */\nexport function extractAllPythonSqlLocations(\n files: Array<{ path: string; content: string }>,\n): SqlLocation[] {\n const allLocations: SqlLocation[] = [];\n\n for (const file of files) {\n const locations = extractPythonSqlLocations(file.content, file.path);\n allLocations.push(...locations);\n }\n\n return allLocations;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/pythonSqlExtractor.ts"],"sourcesContent":["import * as path from 'node:path';\nimport {\n Language,\n Parser,\n type Point,\n type Node as SyntaxNode,\n} from 'web-tree-sitter';\nimport type { SqlLocation } from './sqlLocations';\n\nlet parser: Parser | null = null;\n\n/**\n * Initialize the web-tree-sitter parser with the Python grammar.\n * Must be called before any extraction functions.\n */\nexport async function initParser(): Promise<void> {\n if (parser) return;\n\n await Parser.init();\n const p = new Parser();\n\n // Load the Python WASM grammar from dist/ (copied during build)\n const wasmPath = path.join(__dirname, 'tree-sitter-python.wasm');\n const Python = await Language.load(wasmPath);\n p.setLanguage(Python);\n parser = p;\n}\n\nfunction getParser(): Parser {\n if (!parser) {\n throw new Error(\n 'Parser not initialized. Call initParser() before using extraction functions.',\n );\n }\n return parser;\n}\n\n/**\n * Check if the file imports from moose_lib.\n * This is used to filter out sql() calls that aren't from moose-lib.\n */\nfunction fileImportsMooseLib(rootNode: SyntaxNode): boolean {\n for (const child of rootNode.children) {\n if (!child) continue;\n // Handle: import moose_lib\n if (child.type === 'import_statement') {\n const moduleName = child.childForFieldName('name');\n if (moduleName?.text === 'moose_lib') {\n return true;\n }\n // Handle: import moose_lib.sql\n if (\n moduleName?.type === 'dotted_name' &&\n moduleName.text.startsWith('moose_lib')\n ) {\n return true;\n }\n }\n\n // Handle: from moose_lib import sql\n // Handle: from moose_lib.sql import sql\n if (child.type === 'import_from_statement') {\n const moduleName = child.childForFieldName('module_name');\n if (\n moduleName?.text === 'moose_lib' ||\n moduleName?.text?.startsWith('moose_lib.')\n ) {\n return true;\n }\n }\n }\n\n return false;\n}\n\n/**\n * Check if a function call is a `sql()` call from moose-lib.\n * We look for calls where the function name is 'sql'.\n */\nfunction isSqlFunctionCall(node: SyntaxNode): boolean {\n if (node.type !== 'call') return false;\n\n const functionNode = node.childForFieldName('function');\n if (!functionNode) return false;\n\n // Direct call: sql(\"...\")\n if (functionNode.type === 'identifier' && functionNode.text === 'sql') {\n return true;\n }\n\n // Attribute access: moose_lib.sql(\"...\") or similar\n if (functionNode.type === 'attribute') {\n const attribute = functionNode.childForFieldName('attribute');\n if (attribute?.text === 'sql') {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Extract the SQL string from a sql() function call arguments.\n * Handles both regular strings and f-strings.\n */\nfunction extractSqlFromCall(callNode: SyntaxNode): {\n text: string;\n startPosition: Point;\n endPosition: Point;\n} | null {\n const arguments_ = callNode.childForFieldName('arguments');\n if (!arguments_) return null;\n\n // Get the first argument (the SQL string)\n for (const child of arguments_.children) {\n if (!child) continue;\n if (\n child.type === 'string' ||\n child.type === 'concatenated_string' ||\n child.type === 'formatted_string'\n ) {\n const result = extractStringContent(child);\n if (result) {\n return {\n text: result,\n startPosition: child.startPosition,\n endPosition: child.endPosition,\n };\n }\n }\n }\n\n return null;\n}\n\n/**\n * Extract text content from a string node (regular string or f-string).\n * Converts f-string interpolations to ${...} placeholders.\n */\nfunction extractStringContent(node: SyntaxNode): string | null {\n const text = node.text;\n const isFString = /^f['\"]/.test(text);\n\n if (node.type === 'string') {\n if (isFString) {\n // F-string: f\"SELECT {col} FROM users\"\n return extractFStringContent(text);\n }\n // Regular string: \"SELECT * FROM users\"\n // Remove quotes and extract content\n return extractQuotedStringContent(text);\n }\n\n if (node.type === 'formatted_string') {\n // F-string: f\"SELECT {col} FROM users\"\n return extractFStringContent(text);\n }\n\n if (node.type === 'concatenated_string') {\n // Handle concatenated strings: \"SELECT \" \"* FROM users\"\n let result = '';\n for (const child of node.children) {\n if (!child) continue;\n if (child.type === 'string' || child.type === 'formatted_string') {\n const content = extractStringContent(child);\n if (content !== null) {\n result += content;\n }\n }\n }\n return result || null;\n }\n\n return null;\n}\n\n/**\n * Extract content from a quoted string, removing the quotes.\n */\nfunction extractQuotedStringContent(text: string): string | null {\n // Handle triple-quoted strings\n if (\n text.startsWith('\"\"\"') ||\n text.startsWith(\"'''\") ||\n text.startsWith('f\"\"\"') ||\n text.startsWith(\"f'''\") ||\n text.startsWith('r\"\"\"') ||\n text.startsWith(\"r'''\")\n ) {\n const prefixLen = text.startsWith('f') || text.startsWith('r') ? 4 : 3;\n return text.slice(prefixLen, -3);\n }\n\n // Handle single/double quoted strings\n if (\n text.startsWith('\"') ||\n text.startsWith(\"'\") ||\n text.startsWith('f\"') ||\n text.startsWith(\"f'\") ||\n text.startsWith('r\"') ||\n text.startsWith(\"r'\")\n ) {\n const prefixLen = text.startsWith('f') || text.startsWith('r') ? 2 : 1;\n return text.slice(prefixLen, -1);\n }\n\n return text;\n}\n\n/**\n * Extract content from an f-string text, converting interpolations to ${...}.\n * Works with raw text (e.g., f\"SELECT {col} FROM users\").\n */\nfunction extractFStringContent(text: string): string {\n // Remove the f-string prefix (f\", f', f\"\"\", f''')\n let content: string;\n if (text.startsWith('f\"\"\"') || text.startsWith(\"f'''\")) {\n content = text.slice(4, -3);\n } else if (text.startsWith('f\"') || text.startsWith(\"f'\")) {\n content = text.slice(2, -1);\n } else {\n content = text;\n }\n\n // Replace balanced {expr} with ${...} using depth tracking\n // This handles nested braces like {func({1, 2})} correctly\n let result = '';\n let i = 0;\n while (i < content.length) {\n if (content[i] === '{') {\n if (content[i + 1] === '{') {\n // Escaped {{ → literal {\n result += '{';\n i += 2;\n } else {\n // Find matching closing brace with depth tracking\n let depth = 1;\n let j = i + 1;\n while (j < content.length && depth > 0) {\n if (content[j] === '{') depth++;\n else if (content[j] === '}') depth--;\n j++;\n }\n result += '${...}';\n i = j;\n }\n } else if (content[i] === '}' && content[i + 1] === '}') {\n // Escaped }} → literal }\n result += '}';\n i += 2;\n } else {\n result += content[i];\n i++;\n }\n }\n return result;\n}\n\n/**\n * Check if a string node contains SQL-like content.\n * Used to identify f-strings that might contain SQL even outside sql() calls.\n */\nfunction looksLikeSql(text: string): boolean {\n const sqlKeywords = [\n 'SELECT',\n 'INSERT',\n 'UPDATE',\n 'DELETE',\n 'CREATE',\n 'DROP',\n 'ALTER',\n 'FROM',\n 'WHERE',\n 'JOIN',\n 'TABLE',\n 'INDEX',\n 'VIEW',\n ];\n\n const upperText = text.toUpperCase();\n return sqlKeywords.some((keyword) => upperText.includes(keyword));\n}\n\n/**\n * Check if a formatted string contains :col format specifier.\n * This is a moose-lib specific pattern for SQL columns.\n */\nfunction hasColFormatSpecifier(node: SyntaxNode): boolean {\n const text = node.text;\n // Look for patterns like {var:col} or {Model.field:col}\n return /:col\\s*\\}/.test(text);\n}\n\n/**\n * Extract SQL location from a node.\n */\nfunction createSqlLocation(\n filePath: string,\n text: string,\n startPosition: Point,\n endPosition: Point,\n): SqlLocation {\n return {\n id: `${filePath}:${startPosition.row + 1}:${startPosition.column + 1}`,\n file: filePath,\n line: startPosition.row + 1, // 1-based\n column: startPosition.column + 1, // 1-based\n endLine: endPosition.row + 1,\n endColumn: endPosition.column + 1,\n templateText: text,\n tagKind: 'statement',\n tagLine: startPosition.row + 1,\n tagColumn: startPosition.column + 1,\n tagEndColumn: startPosition.column + 1,\n };\n}\n\n/**\n * Extract all SQL locations from a Python source file.\n * Finds:\n * 1. sql() function calls with string arguments\n * 2. F-strings with :col format specifiers (moose-lib pattern)\n */\nexport function extractPythonSqlLocations(\n sourceCode: string,\n filePath: string,\n): SqlLocation[] {\n const locations: SqlLocation[] = [];\n\n const tree = getParser().parse(sourceCode);\n if (!tree) return locations;\n\n // Only process sql() calls if the file imports from moose_lib\n const hasMooseImport = fileImportsMooseLib(tree.rootNode);\n\n function visit(node: SyntaxNode): void {\n // Check for sql() function calls (only if file imports moose_lib)\n if (hasMooseImport && isSqlFunctionCall(node)) {\n const sqlContent = extractSqlFromCall(node);\n if (sqlContent) {\n locations.push(\n createSqlLocation(\n filePath,\n sqlContent.text,\n sqlContent.startPosition,\n sqlContent.endPosition,\n ),\n );\n }\n }\n\n // Check for f-strings with :col format specifier (moose-lib SQL pattern)\n // tree-sitter-python may use 'string' or 'formatted_string' for f-strings\n const isFString =\n node.type === 'formatted_string' ||\n (node.type === 'string' && /^f['\"]/.test(node.text));\n\n if (isFString && hasColFormatSpecifier(node)) {\n const content = extractFStringContent(node.text);\n if (content && looksLikeSql(content)) {\n locations.push(\n createSqlLocation(\n filePath,\n content,\n node.startPosition,\n node.endPosition,\n ),\n );\n }\n }\n\n // Recursively visit children\n for (const child of node.children) {\n if (child) visit(child);\n }\n }\n\n visit(tree.rootNode);\n return locations;\n}\n\n/**\n * Extract SQL locations from all provided Python files.\n * Used for initial scan of the project.\n */\nexport function extractAllPythonSqlLocations(\n files: Array<{ path: string; content: string }>,\n): SqlLocation[] {\n const allLocations: SqlLocation[] = [];\n\n for (const file of files) {\n const locations = extractPythonSqlLocations(file.content, file.path);\n allLocations.push(...locations);\n }\n\n return allLocations;\n}\n"],"mappings":"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","names":[]}
@@ -287,6 +287,21 @@ const combPrefix = sql\`SELECT sum\`;
287
287
 
288
288
  // Default context (empty SQL \u2014 triggers Default completions with all kinds)
289
289
  const defaultCtx = sql\`\`;
290
+
291
+ // sql.statement \u2014 valid SQL, should validate
292
+ const stmtValid = sql.statement\`SELECT count() FROM users\`;
293
+
294
+ // sql.statement \u2014 invalid SQL, should produce error
295
+ const stmtInvalid = sql.statement\`SELCT * FROM users\`;
296
+
297
+ // sql.fragment \u2014 invalid as standalone SQL, should NOT produce error
298
+ const frag = sql.fragment\`status = 'active' AND amount > 0\`;
299
+
300
+ // sql.fragment \u2014 hover target
301
+ const fragHover = sql.fragment\`toUInt32(id) > 0\`;
302
+
303
+ // sql.fragment \u2014 completions target
304
+ const fragComp = sql.fragment\`cou\`;
290
305
  `;
291
306
  async function createTsFixture() {
292
307
  const tmpDir = await fs.mkdtemp(
@@ -329,11 +344,23 @@ async function createTsFixture() {
329
344
  );
330
345
  await fs.writeFile(
331
346
  path.join(mooseLibDir, "index.js"),
332
- 'module.exports.sql = function sql() { return ""; };\n'
347
+ `
348
+ const handler = function(strings, ...values) { return strings.join(''); };
349
+ handler.statement = handler;
350
+ handler.fragment = handler;
351
+ module.exports.sql = handler;
352
+ `
333
353
  );
334
354
  await fs.writeFile(
335
355
  path.join(mooseLibDir, "index.d.ts"),
336
- "export declare function sql(strings: TemplateStringsArray, ...values: unknown[]): string;\n"
356
+ `
357
+ interface SqlTemplateTag {
358
+ (strings: TemplateStringsArray, ...values: unknown[]): string;
359
+ statement(strings: TemplateStringsArray, ...values: unknown[]): string;
360
+ fragment(strings: TemplateStringsArray, ...values: unknown[]): string;
361
+ }
362
+ export declare const sql: SqlTemplateTag;
363
+ `
337
364
  );
338
365
  const appDir = path.join(tmpDir, "app");
339
366
  await fs.mkdir(appDir, { recursive: true });
@@ -392,13 +419,13 @@ async function createTsFixtureWithDockerCompose(chVersion) {
392
419
  (l) => l.includes("const valid")
393
420
  );
394
421
  const diagnostics = await client.waitForDiagnostics(tsFileUri());
395
- const onValidLine = diagnostics.filter(
396
- (d) => d.range.start.line === validLine
422
+ const errorsOnValidLine = diagnostics.filter(
423
+ (d) => d.range.start.line === validLine && d.severity === 1
397
424
  );
398
425
  import_node_assert.default.strictEqual(
399
- onValidLine.length,
426
+ errorsOnValidLine.length,
400
427
  0,
401
- `Expected no diagnostics on the valid SQL line, got: ${JSON.stringify(onValidLine)}`
428
+ `Expected no error diagnostics on the valid SQL line, got: ${JSON.stringify(errorsOnValidLine)}`
402
429
  );
403
430
  });
404
431
  (0, import_node_test.test)("invalid SQL produces an error diagnostic", async () => {
@@ -406,14 +433,14 @@ async function createTsFixtureWithDockerCompose(chVersion) {
406
433
  const invalidLine = TS_TEST_FILE_CONTENT.split("\n").findIndex(
407
434
  (l) => l.includes("const invalid")
408
435
  );
409
- const onInvalidLine = diagnostics.filter(
410
- (d) => d.range.start.line === invalidLine
436
+ const errorsOnInvalidLine = diagnostics.filter(
437
+ (d) => d.range.start.line === invalidLine && d.severity === 1
411
438
  );
412
439
  import_node_assert.default.ok(
413
- onInvalidLine.length > 0,
414
- `Expected at least one diagnostic on the invalid SQL line (${invalidLine})`
440
+ errorsOnInvalidLine.length > 0,
441
+ `Expected at least one error diagnostic on the invalid SQL line (${invalidLine})`
415
442
  );
416
- const diag = onInvalidLine[0];
443
+ const diag = errorsOnInvalidLine[0];
417
444
  import_node_assert.default.strictEqual(diag.severity, 1, "Severity should be Error (1)");
418
445
  import_node_assert.default.strictEqual(diag.source, "moose-sql");
419
446
  });
@@ -432,18 +459,18 @@ async function createTsFixtureWithDockerCompose(chVersion) {
432
459
  let lastDiagnostics = [];
433
460
  while (Date.now() < deadline) {
434
461
  lastDiagnostics = await client.waitForFreshDiagnostics(tsFileUri());
435
- const onFixedLine2 = lastDiagnostics.filter(
436
- (d) => d.range.start.line === invalidLine && d.source === "moose-sql"
462
+ const errorsOnFixedLine2 = lastDiagnostics.filter(
463
+ (d) => d.range.start.line === invalidLine && d.source === "moose-sql" && d.severity === 1
437
464
  );
438
- if (onFixedLine2.length === 0) break;
465
+ if (errorsOnFixedLine2.length === 0) break;
439
466
  }
440
- const onFixedLine = lastDiagnostics.filter(
441
- (d) => d.range.start.line === invalidLine && d.source === "moose-sql"
467
+ const errorsOnFixedLine = lastDiagnostics.filter(
468
+ (d) => d.range.start.line === invalidLine && d.source === "moose-sql" && d.severity === 1
442
469
  );
443
470
  import_node_assert.default.strictEqual(
444
- onFixedLine.length,
471
+ errorsOnFixedLine.length,
445
472
  0,
446
- `Expected no diagnostic on the fixed line (${invalidLine}), got: ${JSON.stringify(onFixedLine)}`
473
+ `Expected no error diagnostic on the fixed line (${invalidLine}), got: ${JSON.stringify(errorsOnFixedLine)}`
447
474
  );
448
475
  client.changeDocument(tsFileUri(), 3, TS_TEST_FILE_CONTENT);
449
476
  await new Promise((r) => setTimeout(r, 100));
@@ -825,6 +852,178 @@ async function createTsFixtureWithDockerCompose(chVersion) {
825
852
  `Should include "ORDER BY" keyword, got sample: ${labels.slice(0, 30).join(", ")}`
826
853
  );
827
854
  });
855
+ (0, import_node_test.test)("sql.statement with valid SQL produces no error diagnostics", async () => {
856
+ const diagnostics = await client.waitForDiagnostics(tsFileUri());
857
+ const stmtValidLine = TS_TEST_FILE_CONTENT.split("\n").findIndex(
858
+ (l) => l.includes("const stmtValid")
859
+ );
860
+ const errors = diagnostics.filter(
861
+ (d) => d.range.start.line === stmtValidLine && d.severity === 1
862
+ );
863
+ import_node_assert.default.strictEqual(
864
+ errors.length,
865
+ 0,
866
+ `Expected no error diagnostics on valid sql.statement line, got: ${JSON.stringify(errors)}`
867
+ );
868
+ });
869
+ (0, import_node_test.test)("sql.statement with invalid SQL produces error diagnostic", async () => {
870
+ const diagnostics = await client.waitForDiagnostics(tsFileUri());
871
+ const stmtInvalidLine = TS_TEST_FILE_CONTENT.split("\n").findIndex(
872
+ (l) => l.includes("const stmtInvalid")
873
+ );
874
+ const errors = diagnostics.filter(
875
+ (d) => d.range.start.line === stmtInvalidLine && d.severity === 1
876
+ );
877
+ import_node_assert.default.ok(
878
+ errors.length > 0,
879
+ `Expected error diagnostic on invalid sql.statement line (${stmtInvalidLine})`
880
+ );
881
+ import_node_assert.default.strictEqual(errors[0].source, "moose-sql");
882
+ });
883
+ (0, import_node_test.test)("sql.fragment does NOT produce validation errors", async () => {
884
+ const diagnostics = await client.waitForDiagnostics(tsFileUri());
885
+ const fragLine = TS_TEST_FILE_CONTENT.split("\n").findIndex(
886
+ (l) => l.includes("const frag =")
887
+ );
888
+ const errors = diagnostics.filter(
889
+ (d) => d.range.start.line === fragLine && d.severity === 1
890
+ );
891
+ import_node_assert.default.strictEqual(
892
+ errors.length,
893
+ 0,
894
+ `Expected no error diagnostics on sql.fragment line, got: ${JSON.stringify(errors)}`
895
+ );
896
+ });
897
+ (0, import_node_test.test)("hover works inside sql.fragment", async () => {
898
+ const fragHoverLine = TS_TEST_FILE_CONTENT.split("\n").findIndex(
899
+ (l) => l.includes("const fragHover")
900
+ );
901
+ const lineText = TS_TEST_FILE_CONTENT.split("\n")[fragHoverLine];
902
+ const toUInt32Idx = lineText.indexOf("toUInt32");
903
+ import_node_assert.default.ok(toUInt32Idx !== -1, "Should find toUInt32 in fragHover line");
904
+ const response = await client.request("textDocument/hover", {
905
+ textDocument: { uri: tsFileUri() },
906
+ position: { line: fragHoverLine, character: toUInt32Idx + 1 }
907
+ });
908
+ import_node_assert.default.ok(
909
+ response.result,
910
+ "Hover should return a result for toUInt32 inside fragment"
911
+ );
912
+ const hover = response.result;
913
+ import_node_assert.default.ok(
914
+ hover.contents.value.length > 0,
915
+ "Hover content should not be empty"
916
+ );
917
+ });
918
+ (0, import_node_test.test)("completions work inside sql.fragment", async () => {
919
+ const pos = cursorAfter(
920
+ TS_TEST_FILE_CONTENT,
921
+ "fragComp = sql.fragment`cou"
922
+ );
923
+ const response = await client.request("textDocument/completion", {
924
+ textDocument: { uri: tsFileUri() },
925
+ position: pos
926
+ });
927
+ const items = response.result;
928
+ import_node_assert.default.ok(Array.isArray(items), "Result should be an array");
929
+ import_node_assert.default.ok(items.length > 0, "Should have completion items inside fragment");
930
+ const labels = items.map((i) => i.label.toLowerCase());
931
+ import_node_assert.default.ok(
932
+ labels.some((l) => l.startsWith("count")),
933
+ `Should include count* completions inside fragment, got: ${labels.slice(0, 10).join(", ")}`
934
+ );
935
+ });
936
+ (0, import_node_test.test)("bare sql tag produces deprecation hint diagnostic", async () => {
937
+ const diagnostics = await client.waitForDiagnostics(tsFileUri());
938
+ const validLine = TS_TEST_FILE_CONTENT.split("\n").findIndex(
939
+ (l) => l.includes("const valid = sql`")
940
+ );
941
+ const hints = diagnostics.filter(
942
+ (d) => d.range.start.line === validLine && d.severity === 4
943
+ // Hint
944
+ );
945
+ import_node_assert.default.ok(
946
+ hints.length > 0,
947
+ `Expected deprecation hint on bare sql line (${validLine}), got diagnostics: ${JSON.stringify(
948
+ diagnostics.filter((d) => d.range.start.line === validLine)
949
+ )}`
950
+ );
951
+ import_node_assert.default.ok(
952
+ hints[0].message.includes("deprecated"),
953
+ "Hint message should mention deprecated"
954
+ );
955
+ });
956
+ (0, import_node_test.test)("sql.statement does NOT produce deprecation hint", async () => {
957
+ const diagnostics = await client.waitForDiagnostics(tsFileUri());
958
+ const stmtValidLine = TS_TEST_FILE_CONTENT.split("\n").findIndex(
959
+ (l) => l.includes("const stmtValid")
960
+ );
961
+ const hints = diagnostics.filter(
962
+ (d) => d.range.start.line === stmtValidLine && d.severity === 4
963
+ );
964
+ import_node_assert.default.strictEqual(
965
+ hints.length,
966
+ 0,
967
+ `Expected no deprecation hint on sql.statement line, got: ${JSON.stringify(hints)}`
968
+ );
969
+ });
970
+ (0, import_node_test.test)("sql.fragment does NOT produce deprecation hint", async () => {
971
+ const diagnostics = await client.waitForDiagnostics(tsFileUri());
972
+ const fragLine = TS_TEST_FILE_CONTENT.split("\n").findIndex(
973
+ (l) => l.includes("const frag =")
974
+ );
975
+ const hints = diagnostics.filter(
976
+ (d) => d.range.start.line === fragLine && d.severity === 4
977
+ );
978
+ import_node_assert.default.strictEqual(
979
+ hints.length,
980
+ 0,
981
+ `Expected no deprecation hint on sql.fragment line, got: ${JSON.stringify(hints)}`
982
+ );
983
+ });
984
+ (0, import_node_test.test)("code action offers quick fixes for bare sql deprecation", async () => {
985
+ const diagnostics = await client.waitForDiagnostics(tsFileUri());
986
+ const validLine = TS_TEST_FILE_CONTENT.split("\n").findIndex(
987
+ (l) => l.includes("const valid = sql`")
988
+ );
989
+ const hints = diagnostics.filter(
990
+ (d) => d.range.start.line === validLine && d.severity === 4
991
+ );
992
+ import_node_assert.default.ok(
993
+ hints.length > 0,
994
+ "Should have deprecation hint to pass as context"
995
+ );
996
+ const response = await client.request("textDocument/codeAction", {
997
+ textDocument: { uri: tsFileUri() },
998
+ range: {
999
+ start: { line: validLine, character: 15 },
1000
+ end: { line: validLine, character: 15 }
1001
+ },
1002
+ context: { diagnostics: hints }
1003
+ });
1004
+ const actions = response.result;
1005
+ import_node_assert.default.ok(Array.isArray(actions), "Code actions should be an array");
1006
+ const statementAction = actions.find(
1007
+ (a) => a.title.includes("sql.statement")
1008
+ );
1009
+ import_node_assert.default.ok(statementAction, "Should offer Convert to sql.statement action");
1010
+ import_node_assert.default.ok(
1011
+ statementAction.edit?.changes?.[tsFileUri()]?.some(
1012
+ (e) => e.newText === "sql.statement"
1013
+ ),
1014
+ "Statement action should replace with sql.statement"
1015
+ );
1016
+ const fragmentAction = actions.find(
1017
+ (a) => a.title.includes("sql.fragment")
1018
+ );
1019
+ import_node_assert.default.ok(fragmentAction, "Should offer Convert to sql.fragment action");
1020
+ import_node_assert.default.ok(
1021
+ fragmentAction.edit?.changes?.[tsFileUri()]?.some(
1022
+ (e) => e.newText === "sql.fragment"
1023
+ ),
1024
+ "Fragment action should replace with sql.fragment"
1025
+ );
1026
+ });
828
1027
  });
829
1028
  (0, import_node_test.describe)("Snippet support toggle", () => {
830
1029
  (0, import_node_test.test)("snippets enabled: function completion has snippet format", async () => {
@@ -939,16 +1138,13 @@ async function createTsFixtureWithDockerCompose(chVersion) {
939
1138
  const pyUri = `file://${path.join(tmpDir, "app", "test.py")}`;
940
1139
  client.openDocument(pyUri, "python", PY_TEST_FILE_CONTENT);
941
1140
  const diagnostics = await client.waitForDiagnostics(pyUri);
1141
+ const errors = diagnostics.filter((d) => d.severity === 1);
942
1142
  import_node_assert.default.ok(
943
- diagnostics.length > 0,
944
- `Expected diagnostics for invalid Python SQL, got none`
945
- );
946
- import_node_assert.default.strictEqual(
947
- diagnostics[0].severity,
948
- 1,
949
- "Should be Error severity"
1143
+ errors.length > 0,
1144
+ `Expected error diagnostics for invalid Python SQL, got none`
950
1145
  );
951
- import_node_assert.default.strictEqual(diagnostics[0].source, "moose-sql");
1146
+ import_node_assert.default.strictEqual(errors[0].severity, 1, "Should be Error severity");
1147
+ import_node_assert.default.strictEqual(errors[0].source, "moose-sql");
952
1148
  } finally {
953
1149
  client.close();
954
1150
  await fs.rm(tmpDir, { recursive: true, force: true });