diff --git a/README.md b/README.md index 20b770a..3a38902 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ A CodeMirror extension for SQL linting and visual gutter indicators. ## Features -### SQL Linting - -- **Syntax Validation**: Real-time syntax validation with detailed error messages -- **Statement Recognition**: Supports SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, USE, and other SQL statements +- ⚡ **Real-time validation** - SQL syntax checking as you type with detailed error messages +- 🎨 **Visual gutter** - Color-coded statement indicators and error highlighting +- 💡 **Hover tooltips** - Schema info, keywords, and column details on hover +- 🔮 **CTE autocomplete** - Auto-complete support for CTEs ## Installation @@ -19,22 +19,48 @@ pnpm add @marimo-team/codemirror-sql ## Usage -### Basic SQL Extension +### Basic Setup ```ts -import { sqlExtension } from '@marimo-team/codemirror-sql'; -import { EditorView } from '@codemirror/view'; +import { sql, StandardSQL } from '@codemirror/lang-sql'; +import { basicSetup, EditorView } from 'codemirror'; +import { sqlExtension, cteCompletionSource } from '@marimo-team/codemirror-sql'; + +const schema = { + users: ["id", "name", "email", "active"], + posts: ["id", "title", "content", "user_id"] +}; -const view = new EditorView({ +const editor = new EditorView({ + doc: "SELECT * FROM users WHERE active = true", extensions: [ - sqlExtension({ - delay: 250, // Delay before running validation - enableStructureAnalysis: true, // Enable gutter markers for SQL expressions - enableGutterMarkers: true, // Show vertical bars in gutter - backgroundColor: "#3b82f6", // Blue for current statement - errorBackgroundColor: "#ef4444", // Red for invalid statements - hideWhenNotFocused: true, // Hide gutter when editor loses focus + basicSetup, + sql({ + dialect: StandardSQL, + schema: schema, + upperCaseKeywords: true }), + StandardSQL.language.data.of({ + autocomplete: cteCompletionSource, + }), + sqlExtension({ + linterConfig: { + delay: 250 // Validation delay in ms + }, + gutterConfig: { + backgroundColor: "#3b82f6", // Current statement color + errorBackgroundColor: "#ef4444", // Error highlight color + hideWhenNotFocused: true + }, + enableHover: true, + hoverConfig: { + schema: schema, + hoverTime: 300, + enableKeywords: true, + enableTables: true, + enableColumns: true + } + }) ], parent: document.querySelector('#editor') }); diff --git a/demo/index.html b/demo/index.html index 9c9c56b..5cc6199 100644 --- a/demo/index.html +++ b/demo/index.html @@ -22,12 +22,13 @@

A CodeMirror extension for SQL with real-time syntax validation and error diagnostics using node-sql-parser.

- +

SQL Editor with Diagnostics

Try typing invalid SQL syntax to see real-time error highlighting and messages. + Valid tables are: users, posts, orders, customers, categories

diff --git a/demo/index.ts b/demo/index.ts index c07af36..3147b7d 100644 --- a/demo/index.ts +++ b/demo/index.ts @@ -1,11 +1,16 @@ -import { sql } from "@codemirror/lang-sql"; +import { StandardSQL, sql } from "@codemirror/lang-sql"; import { basicSetup, EditorView } from "codemirror"; +import { cteCompletionSource } from "../src/sql/cte-completion-source.js"; import { sqlExtension } from "../src/sql/extension.js"; // Default SQL content for the demo const defaultSqlDoc = `-- Welcome to the SQL Editor Demo! -- Try editing the queries below to see real-time validation +WITH cte_name AS ( + SELECT * FROM users +) + -- Valid queries (no errors): SELECT id, name, email FROM users @@ -46,23 +51,91 @@ WHERE order_date >= '2024-01-01' ORDER BY total_amount DESC; `; +const schema = { + // Users table + users: ["id", "name", "email", "active", "status", "created_at", "updated_at", "profile_id"], + // Posts table + posts: [ + "id", + "title", + "content", + "user_id", + "published", + "created_at", + "updated_at", + "category_id", + ], + // Orders table + orders: [ + "id", + "customer_id", + "order_date", + "total_amount", + "status", + "shipping_address", + "created_at", + ], + // Customers table (additional example) + customers: ["id", "first_name", "last_name", "email", "phone", "address", "city", "country"], + // Categories table + categories: ["id", "name", "description", "parent_id"], +}; + let editor: EditorView; +const completionKindStyles = { + borderRadius: "4px", + padding: "2px 4px", + marginRight: "4px", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: "12px", + height: "12px", +}; + +const dialect = StandardSQL; + // Initialize the SQL editor function initializeEditor() { const extensions = [ basicSetup, EditorView.lineWrapping, - sql(), // SQL language support + sql({ + dialect: dialect, + // Example schema for autocomplete + schema: schema, + // Enable uppercase keywords for more traditional SQL style + upperCaseKeywords: true, + }), sqlExtension({ - // Our custom SQL extension with diagnostics and structure analysis - delay: 250, // Delay before running validation - enableStructureAnalysis: true, // Enable gutter markers for SQL expressions - enableGutterMarkers: true, // Show vertical bars in gutter - backgroundColor: "#3b82f6", // Blue for current statement - errorBackgroundColor: "#ef4444", // Red for invalid statements - hideWhenNotFocused: true, // Hide gutter when editor loses focus - // unfocusedOpacity: 0.1, // Alternative: set low opacity instead of hiding + // Linter extension configuration + linterConfig: { + delay: 250, // Delay before running validation + }, + + // Gutter extension configuration + gutterConfig: { + backgroundColor: "#3b82f6", // Blue for current statement + errorBackgroundColor: "#ef4444", // Red for invalid statements + hideWhenNotFocused: true, // Hide gutter when editor loses focus + }, + // Hover extension configuration + enableHover: true, // Enable hover tooltips + hoverConfig: { + schema: schema, // Use the same schema as autocomplete + hoverTime: 300, // 300ms hover delay + enableKeywords: true, // Show keyword information + enableTables: true, // Show table information + enableColumns: true, // Show column information + keywords: async () => { + const keywords = await import("../src/data/common-keywords.json"); + return keywords.default.keywords; + }, + }, + }), + dialect.language.data.of({ + autocomplete: cteCompletionSource, }), // Custom theme for better SQL editing EditorView.theme({ @@ -94,6 +167,39 @@ function initializeEditor() { color: "#dc2626", fontSize: "13px", }, + // Completion kind backgrounds + ".cm-completionIcon-keyword": { + backgroundColor: "#e0e7ff", // indigo-100 + ...completionKindStyles, + }, + ".cm-completionIcon-variable": { + backgroundColor: "#fef9c3", // yellow-100 + ...completionKindStyles, + }, + ".cm-completionIcon-property": { + backgroundColor: "#bbf7d0", // green-100 + ...completionKindStyles, + }, + ".cm-completionIcon-function": { + backgroundColor: "#bae6fd", // sky-100 + ...completionKindStyles, + }, + ".cm-completionIcon-class": { + backgroundColor: "#fbcfe8", // pink-100 + ...completionKindStyles, + }, + ".cm-completionIcon-constant": { + backgroundColor: "#fde68a", // amber-200 + ...completionKindStyles, + }, + ".cm-completionIcon-type": { + backgroundColor: "#ddd6fe", // violet-200 + ...completionKindStyles, + }, + ".cm-completionIcon-text": { + backgroundColor: "#f3f4f6", // gray-100 + ...completionKindStyles, + }, }), ]; diff --git a/package.json b/package.json index 7fe06d4..9c74b19 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@marimo-team/codemirror-sql", - "version": "0.2.2", + "version": "0.1.1", "publishConfig": { "access": "public" }, @@ -19,8 +19,7 @@ "demo": "vite build", "build": "tsc", "prepublishOnly": "pnpm run typecheck && pnpm run test && pnpm run build", - "release": "pnpm version", - "pre-commit": "lint-staged" + "release": "pnpm version" }, "keywords": [ "codemirror", @@ -38,15 +37,14 @@ "@codemirror/view": "^6.36.2", "@vitest/coverage-v8": "3.1.3", "codemirror": "^6.0.1", - "husky": "^9.1.7", "jsdom": "^26.0.0", - "lint-staged": "^15.4.3", "typescript": "^5.7.3", "vite": "^7.0.0", "vitest": "^3.0.5" }, "files": [ - "dist" + "dist", + "src/data" ], "exports": { "./package.json": "./package.json", @@ -55,7 +53,8 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" } - } + }, + "./data/common-keywords.json": "./src/data/common-keywords.json" }, "types": "./dist/index.d.ts", "type": "module", @@ -63,14 +62,8 @@ "node": "*" }, "module": "./dist/index.js", - "lint-staged": { - "*.{ts,tsx}": [ - "biome check --write", - "biome format --write", - "vitest related --run" - ] - }, "dependencies": { + "@codemirror/autocomplete": "^6.0.0", "@codemirror/lint": "^6.0.0", "node-sql-parser": "^5.3.10" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7726472..946a730 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@codemirror/autocomplete': + specifier: ^6.0.0 + version: 6.18.4 '@codemirror/lint': specifier: ^6.0.0 version: 6.8.5 @@ -33,15 +36,9 @@ importers: codemirror: specifier: ^6.0.1 version: 6.0.1 - husky: - specifier: ^9.1.7 - version: 9.1.7 jsdom: specifier: ^26.0.0 version: 26.1.0 - lint-staged: - specifier: ^15.4.3 - version: 15.5.2 typescript: specifier: ^5.7.3 version: 5.8.3 @@ -789,10 +786,6 @@ packages: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} - ansi-escapes@7.0.0: - resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} - engines: {node: '>=18'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -823,10 +816,6 @@ packages: brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -835,22 +824,10 @@ packages: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} - chalk@5.4.1: - resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} - cli-cursor@5.0.0: - resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} - engines: {node: '>=18'} - - cli-truncate@4.0.0: - resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} - engines: {node: '>=18'} - codemirror@6.0.1: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} @@ -861,13 +838,6 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} - - commander@13.1.0: - resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} - engines: {node: '>=18'} - crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} @@ -911,9 +881,6 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - emoji-regex@10.4.0: - resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} - emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -928,10 +895,6 @@ packages: resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} engines: {node: '>=0.12'} - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -948,13 +911,6 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - eventemitter3@5.0.1: - resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} - - execa@8.0.1: - resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} - engines: {node: '>=16.17'} - expect-type@1.2.1: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} @@ -975,10 +931,6 @@ packages: picomatch: optional: true - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - foreground-child@3.3.0: resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} engines: {node: '>=14'} @@ -988,14 +940,6 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - get-east-asian-width@1.3.0: - resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} - engines: {node: '>=18'} - - get-stream@8.0.1: - resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} - engines: {node: '>=16'} - glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -1023,15 +967,6 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - human-signals@5.0.0: - resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} - engines: {node: '>=16.17.0'} - - husky@9.1.7: - resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} - engines: {node: '>=18'} - hasBin: true - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1040,25 +975,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - is-fullwidth-code-point@4.0.0: - resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} - engines: {node: '>=12'} - - is-fullwidth-code-point@5.0.0: - resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} - engines: {node: '>=18'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-stream@3.0.0: - resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1090,23 +1009,6 @@ packages: canvas: optional: true - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - lint-staged@15.5.2: - resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==} - engines: {node: '>=18.12.0'} - hasBin: true - - listr2@8.3.3: - resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} - engines: {node: '>=18.0.0'} - - log-update@6.1.0: - resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} - engines: {node: '>=18'} - loupe@3.1.3: resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} @@ -1123,21 +1025,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mimic-fn@4.0.0: - resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} - engines: {node: '>=12'} - - mimic-function@5.0.1: - resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} - engines: {node: '>=18'} - minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -1158,21 +1045,9 @@ packages: resolution: {integrity: sha512-cf+iXXJ9Foz4hBIu+eNNeg207ac6XruA9I9DXEs+jCxeS9t/k9T0GZK8NZngPwkv+P26i3zNFj9jxJU2v3pJnw==} engines: {node: '>=8'} - npm-run-path@5.3.0: - resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} - onetime@6.0.0: - resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} - engines: {node: '>=12'} - - onetime@7.0.0: - resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} - engines: {node: '>=18'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -1183,10 +1058,6 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-key@4.0.0: - resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} - engines: {node: '>=12'} - path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -1201,10 +1072,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - picomatch@4.0.2: resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} engines: {node: '>=12'} @@ -1213,11 +1080,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} - engines: {node: '>=0.10'} - hasBin: true - postcss@8.5.3: resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} engines: {node: ^10 || ^12 || >=14} @@ -1230,13 +1092,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - restore-cursor@5.1.0: - resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} - engines: {node: '>=18'} - - rfdc@1.4.1: - resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.40.1: resolution: {integrity: sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -1277,14 +1132,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - slice-ansi@5.0.0: - resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} - engines: {node: '>=12'} - - slice-ansi@7.1.0: - resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} - engines: {node: '>=18'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1295,10 +1142,6 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} - string-argv@0.3.2: - resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} - engines: {node: '>=0.6.19'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1307,10 +1150,6 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} - string-width@7.2.0: - resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} - engines: {node: '>=18'} - strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -1319,10 +1158,6 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} - strip-final-newline@3.0.0: - resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} - engines: {node: '>=12'} - style-mod@4.1.2: resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} @@ -1370,10 +1205,6 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -1548,10 +1379,6 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - wrap-ansi@9.0.0: - resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} - engines: {node: '>=18'} - ws@8.18.1: resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} engines: {node: '>=10.0.0'} @@ -2102,10 +1929,6 @@ snapshots: agent-base@7.1.3: {} - ansi-escapes@7.0.0: - dependencies: - environment: 1.1.0 - ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -2126,10 +1949,6 @@ snapshots: dependencies: balanced-match: 1.0.2 - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - cac@6.7.14: {} chai@5.2.0: @@ -2140,19 +1959,8 @@ snapshots: loupe: 3.1.3 pathval: 2.0.0 - chalk@5.4.1: {} - check-error@2.1.1: {} - cli-cursor@5.0.0: - dependencies: - restore-cursor: 5.1.0 - - cli-truncate@4.0.0: - dependencies: - slice-ansi: 5.0.0 - string-width: 7.2.0 - codemirror@6.0.1: dependencies: '@codemirror/autocomplete': 6.18.4 @@ -2169,10 +1977,6 @@ snapshots: color-name@1.1.4: {} - colorette@2.0.20: {} - - commander@13.1.0: {} - crelt@1.0.6: {} cross-spawn@7.0.6: @@ -2205,8 +2009,6 @@ snapshots: eastasianwidth@0.2.0: {} - emoji-regex@10.4.0: {} - emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -2216,8 +2018,6 @@ snapshots: entities@6.0.0: {} - environment@1.1.0: {} - es-module-lexer@1.7.0: {} esbuild@0.25.3: @@ -2281,20 +2081,6 @@ snapshots: dependencies: '@types/estree': 1.0.7 - eventemitter3@5.0.1: {} - - execa@8.0.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 8.0.1 - human-signals: 5.0.0 - is-stream: 3.0.0 - merge-stream: 2.0.0 - npm-run-path: 5.3.0 - onetime: 6.0.0 - signal-exit: 4.1.0 - strip-final-newline: 3.0.0 - expect-type@1.2.1: {} fdir@6.4.4(picomatch@4.0.2): @@ -2305,10 +2091,6 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - foreground-child@3.3.0: dependencies: cross-spawn: 7.0.6 @@ -2317,10 +2099,6 @@ snapshots: fsevents@2.3.3: optional: true - get-east-asian-width@1.3.0: {} - - get-stream@8.0.1: {} - glob@10.4.5: dependencies: foreground-child: 3.3.0 @@ -2359,28 +2137,14 @@ snapshots: transitivePeerDependencies: - supports-color - human-signals@5.0.0: {} - - husky@9.1.7: {} - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 is-fullwidth-code-point@3.0.0: {} - is-fullwidth-code-point@4.0.0: {} - - is-fullwidth-code-point@5.0.0: - dependencies: - get-east-asian-width: 1.3.0 - - is-number@7.0.0: {} - is-potential-custom-element-name@1.0.1: {} - is-stream@3.0.0: {} - isexe@2.0.0: {} istanbul-lib-coverage@3.2.2: {} @@ -2437,40 +2201,6 @@ snapshots: - supports-color - utf-8-validate - lilconfig@3.1.3: {} - - lint-staged@15.5.2: - dependencies: - chalk: 5.4.1 - commander: 13.1.0 - debug: 4.4.1 - execa: 8.0.1 - lilconfig: 3.1.3 - listr2: 8.3.3 - micromatch: 4.0.8 - pidtree: 0.6.0 - string-argv: 0.3.2 - yaml: 2.8.0 - transitivePeerDependencies: - - supports-color - - listr2@8.3.3: - dependencies: - cli-truncate: 4.0.0 - colorette: 2.0.20 - eventemitter3: 5.0.1 - log-update: 6.1.0 - rfdc: 1.4.1 - wrap-ansi: 9.0.0 - - log-update@6.1.0: - dependencies: - ansi-escapes: 7.0.0 - cli-cursor: 5.0.0 - slice-ansi: 7.1.0 - strip-ansi: 7.1.0 - wrap-ansi: 9.0.0 - loupe@3.1.3: {} lru-cache@10.4.3: {} @@ -2489,17 +2219,6 @@ snapshots: dependencies: semver: 7.6.3 - merge-stream@2.0.0: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - mimic-fn@4.0.0: {} - - mimic-function@5.0.1: {} - minimatch@9.0.5: dependencies: brace-expansion: 2.0.1 @@ -2515,20 +2234,8 @@ snapshots: '@types/pegjs': 0.10.6 big-integer: 1.6.52 - npm-run-path@5.3.0: - dependencies: - path-key: 4.0.0 - nwsapi@2.2.20: {} - onetime@6.0.0: - dependencies: - mimic-fn: 4.0.0 - - onetime@7.0.0: - dependencies: - mimic-function: 5.0.1 - package-json-from-dist@1.0.1: {} parse5@7.3.0: @@ -2537,8 +2244,6 @@ snapshots: path-key@3.1.1: {} - path-key@4.0.0: {} - path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -2550,14 +2255,10 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} - picomatch@4.0.2: {} picomatch@4.0.3: {} - pidtree@0.6.0: {} - postcss@8.5.3: dependencies: nanoid: 3.3.11 @@ -2572,13 +2273,6 @@ snapshots: punycode@2.3.1: {} - restore-cursor@5.1.0: - dependencies: - onetime: 7.0.0 - signal-exit: 4.1.0 - - rfdc@1.4.1: {} - rollup@4.40.1: dependencies: '@types/estree': 1.0.7 @@ -2651,24 +2345,12 @@ snapshots: signal-exit@4.1.0: {} - slice-ansi@5.0.0: - dependencies: - ansi-styles: 6.2.1 - is-fullwidth-code-point: 4.0.0 - - slice-ansi@7.1.0: - dependencies: - ansi-styles: 6.2.1 - is-fullwidth-code-point: 5.0.0 - source-map-js@1.2.1: {} stackback@0.0.2: {} std-env@3.9.0: {} - string-argv@0.3.2: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -2681,12 +2363,6 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 - string-width@7.2.0: - dependencies: - emoji-regex: 10.4.0 - get-east-asian-width: 1.3.0 - strip-ansi: 7.1.0 - strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -2695,8 +2371,6 @@ snapshots: dependencies: ansi-regex: 6.1.0 - strip-final-newline@3.0.0: {} - style-mod@4.1.2: {} supports-color@7.2.0: @@ -2737,10 +2411,6 @@ snapshots: dependencies: tldts-core: 6.1.86 - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - tough-cookie@5.1.2: dependencies: tldts: 6.1.86 @@ -2885,16 +2555,11 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 - wrap-ansi@9.0.0: - dependencies: - ansi-styles: 6.2.1 - string-width: 7.2.0 - strip-ansi: 7.1.0 - ws@8.18.1: {} xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} - yaml@2.8.0: {} + yaml@2.8.0: + optional: true diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 3c70a42..9252db0 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -8,7 +8,10 @@ describe("index.ts exports", () => { [ "SqlParser", "SqlStructureAnalyzer", + "cteCompletionSource", "sqlExtension", + "sqlHover", + "sqlHoverTheme", "sqlLinter", "sqlStructureGutter", ] diff --git a/src/data/common-keywords.json b/src/data/common-keywords.json new file mode 100644 index 0000000..ec45170 --- /dev/null +++ b/src/data/common-keywords.json @@ -0,0 +1,399 @@ +{ + "keywords": { + "select": { + "description": "Retrieves data from one or more tables", + "syntax": "SELECT column1, column2, ... FROM table_name", + "example": "SELECT name, email FROM users WHERE active = true" + }, + "from": { + "description": "Specifies which table to select data from", + "syntax": "FROM table_name", + "example": "FROM users u JOIN orders o ON u.id = o.user_id" + }, + "where": { + "description": "Filters records based on specified conditions", + "syntax": "WHERE condition", + "example": "WHERE age > 18 AND status = 'active'" + }, + "join": { + "description": "Combines rows from two or more tables based on a related column", + "syntax": "JOIN table_name ON condition", + "example": "JOIN orders ON users.id = orders.user_id" + }, + "inner": { + "description": "Returns records that have matching values in both tables", + "syntax": "INNER JOIN table_name ON condition", + "example": "INNER JOIN orders ON users.id = orders.user_id" + }, + "left": { + "description": "Returns all records from the left table and matching records from the right", + "syntax": "LEFT JOIN table_name ON condition", + "example": "LEFT JOIN orders ON users.id = orders.user_id" + }, + "right": { + "description": "Returns all records from the right table and matching records from the left", + "syntax": "RIGHT JOIN table_name ON condition", + "example": "RIGHT JOIN users ON users.id = orders.user_id" + }, + "full": { + "description": "Returns all records when there is a match in either left or right table", + "syntax": "FULL OUTER JOIN table_name ON condition", + "example": "FULL OUTER JOIN orders ON users.id = orders.user_id" + }, + "outer": { + "description": "Used with FULL to return all records from both tables", + "syntax": "FULL OUTER JOIN table_name ON condition", + "example": "FULL OUTER JOIN orders ON users.id = orders.user_id" + }, + "cross": { + "description": "Returns the Cartesian product of both tables", + "syntax": "CROSS JOIN table_name", + "example": "CROSS JOIN colors" + }, + "order": { + "description": "Sorts the result set in ascending or descending order", + "syntax": "ORDER BY column_name [ASC|DESC]", + "example": "ORDER BY created_at DESC, name ASC" + }, + "by": { + "description": "Used with ORDER BY and GROUP BY clauses", + "syntax": "ORDER BY column_name or GROUP BY column_name", + "example": "ORDER BY name ASC or GROUP BY category" + }, + "group": { + "description": "Groups rows that have the same values into summary rows", + "syntax": "GROUP BY column_name", + "example": "GROUP BY category HAVING COUNT(*) > 5" + }, + "having": { + "description": "Filters groups based on specified conditions (used with GROUP BY)", + "syntax": "HAVING condition", + "example": "GROUP BY category HAVING COUNT(*) > 5" + }, + "insert": { + "description": "Adds new records to a table", + "syntax": "INSERT INTO table_name (columns) VALUES (values)", + "example": "INSERT INTO users (name, email) VALUES ('John', 'john@example.com')" + }, + "into": { + "description": "Specifies the target table for INSERT statements", + "syntax": "INSERT INTO table_name", + "example": "INSERT INTO users (name, email) VALUES ('John', 'john@example.com')" + }, + "values": { + "description": "Specifies the values to insert into a table", + "syntax": "VALUES (value1, value2, ...)", + "example": "VALUES ('John', 'john@example.com', true)" + }, + "update": { + "description": "Modifies existing records in a table", + "syntax": "UPDATE table_name SET column = value WHERE condition", + "example": "UPDATE users SET email = 'new@example.com' WHERE id = 1" + }, + "set": { + "description": "Specifies which columns to update and their new values", + "syntax": "SET column1 = value1, column2 = value2", + "example": "SET name = 'John', email = 'john@example.com'" + }, + "delete": { + "description": "Removes records from a table", + "syntax": "DELETE FROM table_name WHERE condition", + "example": "DELETE FROM users WHERE active = false" + }, + "create": { + "description": "Creates a new table, database, or other database object", + "syntax": "CREATE TABLE table_name (column definitions)", + "example": "CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100))" + }, + "table": { + "description": "Specifies a table in CREATE, ALTER, or DROP statements", + "syntax": "CREATE TABLE table_name or ALTER TABLE table_name", + "example": "CREATE TABLE users (id INT, name VARCHAR(100))" + }, + "drop": { + "description": "Deletes a table, database, or other database object", + "syntax": "DROP TABLE table_name", + "example": "DROP TABLE old_users" + }, + "alter": { + "description": "Modifies an existing database object", + "syntax": "ALTER TABLE table_name ADD/DROP/MODIFY column", + "example": "ALTER TABLE users ADD COLUMN phone VARCHAR(20)" + }, + "add": { + "description": "Adds a new column or constraint to a table", + "syntax": "ALTER TABLE table_name ADD column_name data_type", + "example": "ALTER TABLE users ADD phone VARCHAR(20)" + }, + "column": { + "description": "Specifies a column in table operations", + "syntax": "ADD COLUMN column_name or DROP COLUMN column_name", + "example": "ADD COLUMN created_at TIMESTAMP DEFAULT NOW()" + }, + "primary": { + "description": "Defines a primary key constraint", + "syntax": "PRIMARY KEY (column_name)", + "example": "CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100))" + }, + "key": { + "description": "Used with PRIMARY or FOREIGN to define constraints", + "syntax": "PRIMARY KEY or FOREIGN KEY", + "example": "PRIMARY KEY (id) or FOREIGN KEY (user_id) REFERENCES users(id)" + }, + "foreign": { + "description": "Defines a foreign key constraint", + "syntax": "FOREIGN KEY (column_name) REFERENCES table_name(column_name)", + "example": "FOREIGN KEY (user_id) REFERENCES users(id)" + }, + "references": { + "description": "Specifies the referenced table and column for foreign keys", + "syntax": "REFERENCES table_name(column_name)", + "example": "FOREIGN KEY (user_id) REFERENCES users(id)" + }, + "unique": { + "description": "Ensures all values in a column are unique", + "syntax": "UNIQUE (column_name)", + "example": "CREATE TABLE users (email VARCHAR(255) UNIQUE)" + }, + "constraint": { + "description": "Names a constraint for easier management", + "syntax": "CONSTRAINT constraint_name constraint_type", + "example": "CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id)" + }, + "check": { + "description": "Defines a condition that must be true for all rows", + "syntax": "CHECK (condition)", + "example": "CHECK (age >= 18)" + }, + "default": { + "description": "Specifies a default value for a column", + "syntax": "column_name data_type DEFAULT value", + "example": "created_at TIMESTAMP DEFAULT NOW()" + }, + "index": { + "description": "Creates an index to improve query performance", + "syntax": "CREATE INDEX index_name ON table_name (column_name)", + "example": "CREATE INDEX idx_user_email ON users (email)" + }, + "view": { + "description": "Creates a virtual table based on a SELECT statement", + "syntax": "CREATE VIEW view_name AS SELECT ...", + "example": "CREATE VIEW active_users AS SELECT * FROM users WHERE active = true" + }, + "limit": { + "description": "Restricts the number of records returned", + "syntax": "LIMIT number", + "example": "SELECT * FROM users LIMIT 10" + }, + "offset": { + "description": "Skips a specified number of rows before returning results", + "syntax": "OFFSET number", + "example": "SELECT * FROM users LIMIT 10 OFFSET 20" + }, + "top": { + "description": "Limits the number of records returned (SQL Server syntax)", + "syntax": "SELECT TOP number columns FROM table", + "example": "SELECT TOP 10 * FROM users" + }, + "fetch": { + "description": "Retrieves a specific number of rows (modern SQL standard)", + "syntax": "OFFSET number ROWS FETCH NEXT number ROWS ONLY", + "example": "OFFSET 10 ROWS FETCH NEXT 5 ROWS ONLY" + }, + "with": { + "description": "Defines a Common Table Expression (CTE)", + "syntax": "WITH cte_name AS (SELECT ...) SELECT ... FROM cte_name", + "example": "WITH user_stats AS (SELECT user_id, COUNT(*) FROM orders GROUP BY user_id) SELECT * FROM user_stats" + }, + "recursive": { + "description": "Creates a recursive CTE that can reference itself", + "syntax": "WITH RECURSIVE cte_name AS (...) SELECT ...", + "example": "WITH RECURSIVE tree AS (SELECT id, parent_id FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.parent_id FROM categories c JOIN tree t ON c.parent_id = t.id) SELECT * FROM tree" + }, + "distinct": { + "description": "Returns only unique values", + "syntax": "SELECT DISTINCT column_name FROM table_name", + "example": "SELECT DISTINCT category FROM products" + }, + "count": { + "description": "Returns the number of rows that match a condition", + "syntax": "COUNT(*) or COUNT(column_name)", + "example": "SELECT COUNT(*) FROM users WHERE active = true" + }, + "sum": { + "description": "Returns the sum of numeric values", + "syntax": "SUM(column_name)", + "example": "SELECT SUM(price) FROM orders WHERE status = 'completed'" + }, + "avg": { + "description": "Returns the average value of numeric values", + "syntax": "AVG(column_name)", + "example": "SELECT AVG(age) FROM users" + }, + "max": { + "description": "Returns the maximum value", + "syntax": "MAX(column_name)", + "example": "SELECT MAX(price) FROM products" + }, + "min": { + "description": "Returns the minimum value", + "syntax": "MIN(column_name)", + "example": "SELECT MIN(price) FROM products" + }, + "as": { + "description": "Creates an alias for a column or table", + "syntax": "column_name AS alias_name or table_name AS alias_name", + "example": "SELECT name AS customer_name FROM users AS u" + }, + "on": { + "description": "Specifies the join condition between tables", + "syntax": "JOIN table_name ON condition", + "example": "JOIN orders ON users.id = orders.user_id" + }, + "and": { + "description": "Combines multiple conditions with logical AND", + "syntax": "WHERE condition1 AND condition2", + "example": "WHERE age > 18 AND status = 'active'" + }, + "or": { + "description": "Combines multiple conditions with logical OR", + "syntax": "WHERE condition1 OR condition2", + "example": "WHERE category = 'electronics' OR category = 'books'" + }, + "not": { + "description": "Negates a condition", + "syntax": "WHERE NOT condition", + "example": "WHERE NOT status = 'inactive'" + }, + "null": { + "description": "Represents a missing or unknown value", + "syntax": "column_name IS NULL or column_name IS NOT NULL", + "example": "WHERE email IS NOT NULL" + }, + "is": { + "description": "Used to test for NULL values or boolean conditions", + "syntax": "column_name IS NULL or column_name IS NOT NULL", + "example": "WHERE deleted_at IS NULL" + }, + "in": { + "description": "Checks if a value matches any value in a list", + "syntax": "column_name IN (value1, value2, ...)", + "example": "WHERE status IN ('active', 'pending', 'approved')" + }, + "between": { + "description": "Selects values within a range", + "syntax": "column_name BETWEEN value1 AND value2", + "example": "WHERE age BETWEEN 18 AND 65" + }, + "like": { + "description": "Searches for a pattern in a column", + "syntax": "column_name LIKE pattern", + "example": "WHERE name LIKE 'John%' (starts with 'John')" + }, + "exists": { + "description": "Tests whether a subquery returns any rows", + "syntax": "WHERE EXISTS (subquery)", + "example": "WHERE EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)" + }, + "any": { + "description": "Compares a value to any value returned by a subquery", + "syntax": "column_name operator ANY (subquery)", + "example": "WHERE price > ANY (SELECT price FROM products WHERE category = 'electronics')" + }, + "all": { + "description": "Compares a value to all values returned by a subquery", + "syntax": "column_name operator ALL (subquery)", + "example": "WHERE price > ALL (SELECT price FROM products WHERE category = 'books')" + }, + "some": { + "description": "Synonym for ANY - compares a value to some values in a subquery", + "syntax": "column_name operator SOME (subquery)", + "example": "WHERE price > SOME (SELECT price FROM products WHERE category = 'electronics')" + }, + "union": { + "description": "Combines the result sets of two or more SELECT statements", + "syntax": "SELECT ... UNION SELECT ...", + "example": "SELECT name FROM customers UNION SELECT name FROM suppliers" + }, + "intersect": { + "description": "Returns rows that are in both result sets", + "syntax": "SELECT ... INTERSECT SELECT ...", + "example": "SELECT customer_id FROM orders INTERSECT SELECT customer_id FROM returns" + }, + "except": { + "description": "Returns rows from the first query that are not in the second", + "syntax": "SELECT ... EXCEPT SELECT ...", + "example": "SELECT customer_id FROM customers EXCEPT SELECT customer_id FROM blacklist" + }, + "case": { + "description": "Provides conditional logic in SQL queries", + "syntax": "CASE WHEN condition THEN result ELSE result END", + "example": "CASE WHEN age < 18 THEN 'Minor' ELSE 'Adult' END" + }, + "when": { + "description": "Specifies conditions in CASE statements", + "syntax": "CASE WHEN condition THEN result", + "example": "CASE WHEN score >= 90 THEN 'A' WHEN score >= 80 THEN 'B' END" + }, + "then": { + "description": "Specifies the result for a WHEN condition", + "syntax": "WHEN condition THEN result", + "example": "WHEN age < 18 THEN 'Minor'" + }, + "else": { + "description": "Specifies the default result in CASE statements", + "syntax": "CASE WHEN condition THEN result ELSE default_result END", + "example": "CASE WHEN score >= 60 THEN 'Pass' ELSE 'Fail' END" + }, + "end": { + "description": "Terminates a CASE statement", + "syntax": "CASE WHEN condition THEN result END", + "example": "CASE WHEN age < 18 THEN 'Minor' ELSE 'Adult' END" + }, + "over": { + "description": "Defines a window for window functions", + "syntax": "window_function() OVER (PARTITION BY ... ORDER BY ...)", + "example": "ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC)" + }, + "partition": { + "description": "Divides the result set into groups for window functions", + "syntax": "OVER (PARTITION BY column_name)", + "example": "SUM(salary) OVER (PARTITION BY department)" + }, + "row_number": { + "description": "Assigns a unique sequential integer to each row", + "syntax": "ROW_NUMBER() OVER (ORDER BY column_name)", + "example": "ROW_NUMBER() OVER (ORDER BY created_at DESC)" + }, + "rank": { + "description": "Assigns a rank to each row with gaps for ties", + "syntax": "RANK() OVER (ORDER BY column_name)", + "example": "RANK() OVER (ORDER BY score DESC)" + }, + "dense_rank": { + "description": "Assigns a rank to each row without gaps for ties", + "syntax": "DENSE_RANK() OVER (ORDER BY column_name)", + "example": "DENSE_RANK() OVER (ORDER BY score DESC)" + }, + "begin": { + "description": "Starts a transaction block", + "syntax": "BEGIN [TRANSACTION]", + "example": "BEGIN; UPDATE accounts SET balance = balance - 100; COMMIT;" + }, + "commit": { + "description": "Permanently saves all changes made in the current transaction", + "syntax": "COMMIT [TRANSACTION]", + "example": "BEGIN; INSERT INTO users VALUES (...); COMMIT;" + }, + "rollback": { + "description": "Undoes all changes made in the current transaction", + "syntax": "ROLLBACK [TRANSACTION]", + "example": "BEGIN; DELETE FROM users WHERE id = 1; ROLLBACK;" + }, + "transaction": { + "description": "Groups multiple SQL statements into a single unit of work", + "syntax": "BEGIN TRANSACTION; ... COMMIT/ROLLBACK;", + "example": "BEGIN TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT;" + } + } +} diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 0000000..e1df213 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,6 @@ +export function debug(message: string, ...args: unknown[]) { + // @ts-expect-error - import.meta.env is not typed + if (import.meta.env.DEV) { + console.log(`[codemirror-sql]`, message, ...args); + } +} diff --git a/src/index.ts b/src/index.ts index 2854c89..7149a0c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,13 @@ +export { cteCompletionSource } from "./sql/cte-completion-source.js"; export { sqlLinter } from "./sql/diagnostics.js"; export { sqlExtension } from "./sql/extension.js"; +export type { + KeywordTooltipData, + NamespaceTooltipData, + SqlHoverConfig, + SqlKeywordInfo, +} from "./sql/hover.js"; +export { sqlHover, sqlHoverTheme } from "./sql/hover.js"; export { SqlParser } from "./sql/parser.js"; export type { SqlStatement } from "./sql/structure-analyzer.js"; export { SqlStructureAnalyzer } from "./sql/structure-analyzer.js"; diff --git a/src/sql/__tests__/cte-completion-source.test.ts b/src/sql/__tests__/cte-completion-source.test.ts new file mode 100644 index 0000000..d44d022 --- /dev/null +++ b/src/sql/__tests__/cte-completion-source.test.ts @@ -0,0 +1,274 @@ +import type { CompletionContext } from "@codemirror/autocomplete"; +import { EditorState } from "@codemirror/state"; +import { describe, expect, it } from "vitest"; +import { cteCompletionSource } from "../cte-completion-source.js"; + +// Helper function to create a mock completion context +function createMockContext(doc: string, pos: number, explicit = false): CompletionContext { + const state = EditorState.create({ doc }); + + return { + state, + pos, + explicit, + matchBefore: (pattern: RegExp) => { + const before = doc.slice(0, pos); + + // For \w* pattern, find the word at the end + if (pattern.source === "\\w*") { + const wordMatch = before.match(/(\w*)$/); + if (!wordMatch) return null; + + const text = wordMatch[1] || ""; + const from = pos - text.length; + return { + from, + to: pos, + text, + }; + } + + // For other patterns, use the original logic + const match = before.match(pattern); + if (!match) return null; + + const from = pos - match[0].length; + return { + from, + to: pos, + text: match[0], + }; + }, + aborted: false, + } as CompletionContext; +} + +describe("cteCompletionSource", () => { + describe("basic CTE detection", () => { + it("should detect single CTE", () => { + const sql = `WITH user_stats AS ( + SELECT id, name FROM users + ) + SELECT * FROM user_`; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.options).toHaveLength(1); + expect(result?.options[0].label).toBe("user_stats"); + expect(result?.options[0].type).toBe("variable"); + expect(result?.options[0].info).toBe("Common Table Expression: user_stats"); + }); + + it("should detect multiple CTEs", () => { + const sql = `WITH + user_stats AS (SELECT id, name FROM users), + post_counts AS (SELECT user_id, COUNT(*) as count FROM posts GROUP BY user_id) + SELECT * FROM `; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.options).toHaveLength(2); + + const labels = result?.options.map((opt) => opt.label).sort(); + expect(labels).toEqual(["post_counts", "user_stats"]); + }); + + it("should detect RECURSIVE CTEs", () => { + const sql = `WITH RECURSIVE category_tree AS ( + SELECT id, name, parent_id FROM categories WHERE parent_id IS NULL + UNION ALL + SELECT c.id, c.name, c.parent_id + FROM categories c + JOIN category_tree ct ON c.parent_id = ct.id + ) + SELECT * FROM category_`; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.options).toHaveLength(1); + expect(result?.options[0].label).toBe("category_tree"); + }); + }); + + describe("CTE name patterns", () => { + it("should handle CTEs with underscores", () => { + const sql = `WITH user_activity_stats AS ( + SELECT * FROM users + ) + SELECT * FROM user_`; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.options[0].label).toBe("user_activity_stats"); + }); + + it("should handle CTEs with numbers", () => { + const sql = `WITH stats2024 AS ( + SELECT * FROM users + ) + SELECT * FROM stats`; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.options[0].label).toBe("stats2024"); + }); + + it("should handle case-insensitive WITH keyword", () => { + const sql = `with user_data as ( + select * from users + ) + select * from user_`; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.options[0].label).toBe("user_data"); + }); + }); + + describe("complex SQL scenarios", () => { + it("should detect CTEs in nested queries", () => { + const sql = `WITH outer_cte AS ( + WITH inner_cte AS (SELECT id FROM users) + SELECT * FROM inner_cte + ) + SELECT * FROM `; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.options).toHaveLength(2); + + const labels = result?.options.map((opt) => opt.label).sort(); + expect(labels).toEqual(["inner_cte", "outer_cte"]); + }); + + it("should handle CTEs with complex subqueries", () => { + const sql = `WITH filtered_users AS ( + SELECT u.id, u.name + FROM users u + WHERE u.active = true + AND u.created_at > ( + SELECT DATE_SUB(NOW(), INTERVAL 30 DAY) + ) + ) + SELECT * FROM filtered_`; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.options[0].label).toBe("filtered_users"); + }); + + it("should handle multiple WITH clauses in different statements", () => { + const sql = `WITH first_cte AS (SELECT 1) + SELECT * FROM first_cte; + + WITH second_cte AS (SELECT 2) + SELECT * FROM `; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.options).toHaveLength(2); + + const labels = result?.options.map((opt) => opt.label).sort(); + expect(labels).toEqual(["first_cte", "second_cte"]); + }); + }); + + describe("edge cases", () => { + it("should return null when no CTEs are present", () => { + const sql = "SELECT * FROM users WHERE id = 1"; + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeNull(); + }); + + it("should return null when not in explicit mode and no word being typed", () => { + const sql = `WITH user_stats AS (SELECT * FROM users) + SELECT * FROM `; + + const context = createMockContext(sql, sql.length, false); // explicit = false + const result = cteCompletionSource(context); + + expect(result).toBeNull(); + }); + + it("should handle incomplete CTEs gracefully", () => { + const sql = "WITH incomplete_cte AS SELECT * FROM users"; + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeNull(); + }); + + it("should handle empty document", () => { + const sql = ""; + const context = createMockContext(sql, 0, true); + const result = cteCompletionSource(context); + + expect(result).toBeNull(); + }); + + it("should deduplicate CTE names", () => { + const sql = `WITH user_stats AS (SELECT * FROM users) + SELECT * FROM user_stats + UNION ALL + WITH user_stats AS (SELECT * FROM users) + SELECT * FROM `; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.options).toHaveLength(1); + expect(result?.options[0].label).toBe("user_stats"); + }); + }); + + describe("completion context", () => { + it("should respect word boundaries", () => { + const sql = `WITH user_stats AS (SELECT * FROM users) + SELECT * FROM user_st`; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + expect(result?.from).toBe(sql.lastIndexOf("user_st")); + expect(result?.options[0].label).toBe("user_stats"); + }); + + it("should provide correct completion metadata", () => { + const sql = `WITH my_cte AS (SELECT * FROM users) + SELECT * FROM my_`; + + const context = createMockContext(sql, sql.length, true); + const result = cteCompletionSource(context); + + expect(result).toBeTruthy(); + const completion = result?.options[0]; + + expect(completion?.label).toBe("my_cte"); + expect(completion?.type).toBe("variable"); + expect(completion?.info).toBe("Common Table Expression: my_cte"); + expect(completion?.boost).toBe(10); + }); + }); +}); diff --git a/src/sql/__tests__/diagnostics.test.ts b/src/sql/__tests__/diagnostics.test.ts index 1efdb87..89ea7bf 100644 --- a/src/sql/__tests__/diagnostics.test.ts +++ b/src/sql/__tests__/diagnostics.test.ts @@ -1,6 +1,15 @@ +import { Text } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; import { describe, expect, it, vi } from "vitest"; import { sqlLinter } from "../diagnostics.js"; -import { SqlParser } from "../parser.js"; + +// Mock EditorView +const _createMockView = (content: string) => { + const doc = Text.of(content.split("\n")); + return { + state: { doc }, + } as EditorView; +}; describe("sqlLinter", () => { it("should create a linter extension", () => { @@ -8,21 +17,33 @@ describe("sqlLinter", () => { expect(linter).toBeDefined(); }); - it("should use custom parser when provided", () => { - const mockParser = vi.mocked(new SqlParser()); - mockParser.validateSql = vi.fn().mockReturnValue([]); + it("should accept configuration with custom delay", () => { + const linter = sqlLinter({ delay: 1000 }); + expect(linter).toBeDefined(); + }); + + it("should use custom parser if provided", () => { + const mockParser = { + validateSql: vi.fn(() => []), + parseSql: vi.fn(() => ({ statements: [] })), + } as any; const linter = sqlLinter({ parser: mockParser }); expect(linter).toBeDefined(); }); - it("should use default delay if not provided", () => { + it("should use default delay when no delay provided", () => { const linter = sqlLinter(); expect(linter).toBeDefined(); }); it("should use custom delay when provided", () => { - const linter = sqlLinter({ delay: 1000 }); + const linter = sqlLinter({ delay: 500 }); + expect(linter).toBeDefined(); + }); + + it("should use default parser when no parser provided", () => { + const linter = sqlLinter(); expect(linter).toBeDefined(); }); }); diff --git a/src/sql/__tests__/extension.test.ts b/src/sql/__tests__/extension.test.ts new file mode 100644 index 0000000..5995f0c --- /dev/null +++ b/src/sql/__tests__/extension.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { sqlExtension } from "../extension.js"; + +describe("sqlExtension", () => { + it("should return an array of extensions with default config", () => { + const extensions = sqlExtension(); + expect(Array.isArray(extensions)).toBe(true); + expect(extensions.length).toBeGreaterThan(0); + }); + + it("should return extensions with all features enabled by default", () => { + const extensions = sqlExtension({}); + // Should include linter, gutter (4 parts), hover, and hover theme + expect(extensions.length).toBeGreaterThan(2); + }); + + it("should exclude linting when disabled", () => { + const extensions = sqlExtension({ enableLinting: false }); + // Should include gutter, hover, and hover theme (no linter) + expect(extensions.length).toBeGreaterThan(2); + }); + + it("should exclude gutter markers when disabled", () => { + const extensions = sqlExtension({ enableGutterMarkers: false }); + // Should include linter, hover, and hover theme (no gutter) + expect(extensions.length).toBeGreaterThan(1); + }); + + it("should exclude hover when disabled", () => { + const extensions = sqlExtension({ enableHover: false }); + // Should include linter and gutter (no hover or hover theme) + expect(extensions.length).toBeGreaterThan(0); + }); + + it("should handle all features disabled", () => { + const extensions = sqlExtension({ + enableLinting: false, + enableGutterMarkers: false, + enableHover: false, + }); + expect(extensions).toEqual([]); + }); + + it("should pass config objects to individual extensions", () => { + const linterConfig = { delay: 500 }; + const gutterConfig = { backgroundColor: "#ff0000" }; + const hoverConfig = { hoverTime: 200 }; + + const extensions = sqlExtension({ + linterConfig, + gutterConfig, + hoverConfig, + }); + + expect(extensions.length).toBeGreaterThan(2); + }); + + it("should handle partial configuration", () => { + const extensions = sqlExtension({ + enableLinting: true, + linterConfig: { delay: 100 }, + }); + + expect(extensions.length).toBeGreaterThan(2); + }); +}); diff --git a/src/sql/__tests__/hover-integration.test.ts b/src/sql/__tests__/hover-integration.test.ts new file mode 100644 index 0000000..23bd639 --- /dev/null +++ b/src/sql/__tests__/hover-integration.test.ts @@ -0,0 +1,531 @@ +import type { Completion } from "@codemirror/autocomplete"; +import type { SQLNamespace } from "@codemirror/lang-sql"; +import { describe, expect, it, vi } from "vitest"; +import { sqlHover, sqlHoverTheme } from "../hover.js"; +import { resolveNamespaceItem } from "../namespace-utils.js"; + +// Helper function to create completion objects +function createCompletion(label: string, detail?: string): Completion { + return { + label, + detail: detail || `${label} completion`, + type: "property", + }; +} + +// Test namespace with various structures +const testNamespace: SQLNamespace = { + // Simple database with tables and columns + postgres: { + self: createCompletion("postgres", "PostgreSQL database"), + children: { + public: { + users: { + self: createCompletion("users", "User table"), + children: [ + createCompletion("id", "Primary key"), + createCompletion("username", "Username"), + createCompletion("email", "Email address"), + "created_at", + "updated_at", + ], + }, + orders: [ + createCompletion("id"), + createCompletion("user_id"), + createCompletion("total"), + "status", + "created_at", + ], + }, + analytics: { + user_stats: ["daily_active", "monthly_active", "retention_rate"], + sales_data: { + q1_2024: ["january", "february", "march"], + q2_2024: ["april", "may", "june"], + }, + }, + }, + }, + // Simple object namespace + mysql: { + test_db: { + customers: ["customer_id", "company_name", "contact_name"], + products: { + categories: [createCompletion("category_id"), createCompletion("category_name")], + }, + }, + }, +}; + +describe("Hover Integration Tests", () => { + describe("Extension creation", () => { + it("should create hover extension without errors", () => { + expect(() => { + sqlHover({ + schema: testNamespace, + enableKeywords: true, + enableTables: true, + enableColumns: true, + }); + }).not.toThrow(); + }); + + it("should create hover theme without errors", () => { + expect(() => { + sqlHoverTheme(); + }).not.toThrow(); + }); + + it("should handle empty configuration", () => { + expect(() => { + sqlHover(); + }).not.toThrow(); + }); + + it("should handle function-based schema configuration", () => { + expect(() => { + sqlHover({ + schema: () => testNamespace, + }); + }).not.toThrow(); + }); + }); + + describe("Namespace resolution integration", () => { + it("should resolve exact namespace paths with correct semantic types", () => { + const result = resolveNamespaceItem(testNamespace, "postgres.public.users"); + expect(result).toBeTruthy(); + expect(result?.type).toBe("completion"); + expect(result?.semanticType).toBe("table"); + expect(result?.completion?.label).toBe("users"); + expect(result?.path).toEqual(["postgres", "public", "users"]); + }); + + it("should handle self-children namespace structures with database semantic type", () => { + const result = resolveNamespaceItem(testNamespace, "postgres"); + expect(result).toBeTruthy(); + expect(result?.type).toBe("completion"); + expect(result?.semanticType).toBe("database"); + expect(result?.completion?.label).toBe("postgres"); + }); + + it("should classify schemas correctly", () => { + const result = resolveNamespaceItem(testNamespace, "postgres.public"); + expect(result).toBeTruthy(); + expect(result?.semanticType).toBe("schema"); + expect(result?.path).toEqual(["postgres", "public"]); + }); + + it("should handle array namespace structures as tables", () => { + const result = resolveNamespaceItem(testNamespace, "postgres.public.orders"); + expect(result).toBeTruthy(); + expect(result?.type).toBe("namespace"); + expect(result?.semanticType).toBe("table"); + expect(result?.path).toEqual(["postgres", "public", "orders"]); + }); + + it("should handle deeply nested namespace paths", () => { + const result = resolveNamespaceItem(testNamespace, "postgres.analytics.sales_data.q1_2024"); + expect(result).toBeTruthy(); + expect(result?.type).toBe("namespace"); + expect(result?.semanticType).toBe("table"); + expect(result?.path).toEqual(["postgres", "analytics", "sales_data", "q1_2024"]); + }); + + it("should resolve individual columns with column semantic type", () => { + const result = resolveNamespaceItem(testNamespace, "id", { enableFuzzySearch: true }); + expect(result).toBeTruthy(); + expect(result?.type).toBe("completion"); + expect(result?.semanticType).toBe("column"); + expect(result?.completion?.label).toBe("id"); + }); + + it("should resolve string columns with column semantic type", () => { + const result = resolveNamespaceItem(testNamespace, "created_at", { enableFuzzySearch: true }); + expect(result).toBeTruthy(); + expect(result?.type).toBe("string"); + expect(result?.semanticType).toBe("column"); + expect(result?.value).toBe("created_at"); + }); + }); + + describe("Preference order testing", () => { + it("should prefer exact namespace matches", () => { + // Create a namespace that has a name conflicting with a SQL keyword + const conflictNamespace: SQLNamespace = { + select: { + self: createCompletion("select", "Custom select table"), + children: ["id", "name", "value"], + }, + }; + + const result = resolveNamespaceItem(conflictNamespace, "select"); + expect(result).toBeTruthy(); + expect(result?.type).toBe("completion"); + expect(result?.semanticType).toBe("table"); + expect(result?.completion?.label).toBe("select"); + expect(result?.completion?.detail).toBe("Custom select table"); + }); + + it("should return null when no match found", () => { + const result = resolveNamespaceItem(testNamespace, "nonexistent"); + expect(result).toBeNull(); + }); + }); + + describe("Dynamic nesting support", () => { + it("should support variable depth namespace paths", () => { + const deepNamespace: SQLNamespace = { + level1: { + level2: { + level3: { + level4: { + level5: ["final_column"], + }, + }, + }, + }, + }; + + const result = resolveNamespaceItem(deepNamespace, "level1.level2.level3.level4.level5"); + expect(result).toBeTruthy(); + expect(result?.type).toBe("namespace"); + expect(result?.path).toEqual(["level1", "level2", "level3", "level4", "level5"]); + }); + + it("should handle mixed namespace types in same path", () => { + const mixedNamespace: SQLNamespace = { + db: { + self: createCompletion("db", "Database"), + children: { + schema: { + table: [createCompletion("col1"), "col2", createCompletion("col3")], + }, + }, + }, + }; + + const result = resolveNamespaceItem(mixedNamespace, "db.schema.table"); + expect(result).toBeTruthy(); + expect(result?.type).toBe("namespace"); + expect(result?.path).toEqual(["db", "schema", "table"]); + }); + }); + + describe("Edge cases and error handling", () => { + it("should handle empty namespace gracefully", () => { + expect(() => { + resolveNamespaceItem({}, "anything"); + }).not.toThrow(); + + const result = resolveNamespaceItem({}, "anything"); + expect(result).toBeNull(); + }); + + it("should handle malformed paths gracefully", () => { + expect(() => { + resolveNamespaceItem(testNamespace, "..invalid.path.."); + }).not.toThrow(); + + const result = resolveNamespaceItem(testNamespace, "..invalid.path.."); + expect(result).toBeNull(); + }); + + it("should handle very long paths gracefully", () => { + const longPath = `${"a.".repeat(20)}final`; + expect(() => { + resolveNamespaceItem(testNamespace, longPath); + }).not.toThrow(); + + const result = resolveNamespaceItem(testNamespace, longPath); + expect(result).toBeNull(); + }); + }); + + describe("Case sensitivity", () => { + it("should handle case-insensitive matching by default", () => { + const result = resolveNamespaceItem(testNamespace, "POSTGRES.PUBLIC.USERS"); + expect(result).toBeTruthy(); + expect(result?.type).toBe("completion"); + expect(result?.completion?.label).toBe("users"); + }); + + it("should support mixed case in namespace paths", () => { + const mixedCaseNamespace: SQLNamespace = { + MyDatabase: { + MySchema: { + MyTable: ["MyColumn", "AnotherColumn"], + }, + }, + }; + + const result = resolveNamespaceItem(mixedCaseNamespace, "mydatabase.myschema.mytable"); + expect(result).toBeTruthy(); + expect(result?.type).toBe("namespace"); + expect(result?.path).toEqual(["MyDatabase", "MySchema", "MyTable"]); + }); + }); +}); + +describe("Complex Namespace Scenarios", () => { + it("should correctly resolve complex namespace structures", () => { + const complexNamespace: SQLNamespace = { + warehouse: { + self: createCompletion("warehouse", "Data warehouse"), + children: { + raw: { + events: [ + createCompletion("event_id", "Event identifier"), + createCompletion("user_id", "User identifier"), + createCompletion("timestamp", "Event timestamp"), + "event_type", + "properties", + ], + users: { + self: createCompletion("users", "Raw user data"), + children: [ + createCompletion("id", "User ID"), + "username", + "email", + createCompletion("signup_date", "User signup date"), + ], + }, + }, + processed: { + daily_stats: ["date", "active_users", "total_events", "revenue"], + user_segments: { + high_value: ["user_id", "ltv", "segment_date"], + churned: ["user_id", "churn_date", "churn_reason"], + }, + }, + }, + }, + }; + + // Test various resolution scenarios with semantic types + const warehouseResult = resolveNamespaceItem(complexNamespace, "warehouse"); + expect(warehouseResult?.type).toBe("completion"); + expect(warehouseResult?.semanticType).toBe("database"); + expect(warehouseResult?.completion?.label).toBe("warehouse"); + + const rawResult = resolveNamespaceItem(complexNamespace, "warehouse.raw"); + expect(rawResult?.semanticType).toBe("schema"); + expect(rawResult?.path).toEqual(["warehouse", "raw"]); + + const eventsResult = resolveNamespaceItem(complexNamespace, "warehouse.raw.events"); + expect(eventsResult?.type).toBe("namespace"); + expect(eventsResult?.semanticType).toBe("table"); + expect(eventsResult?.path).toEqual(["warehouse", "raw", "events"]); + + const usersResult = resolveNamespaceItem(complexNamespace, "warehouse.raw.users"); + expect(usersResult?.type).toBe("completion"); + expect(usersResult?.semanticType).toBe("table"); + expect(usersResult?.completion?.label).toBe("users"); + + const columnResult = resolveNamespaceItem(complexNamespace, "event_id", { + enableFuzzySearch: true, + }); + expect(columnResult?.type).toBe("completion"); + expect(columnResult?.semanticType).toBe("column"); + expect(columnResult?.completion?.label).toBe("event_id"); + + const stringColumnResult = resolveNamespaceItem(complexNamespace, "username", { + enableFuzzySearch: true, + }); + expect(stringColumnResult?.type).toBe("string"); + expect(stringColumnResult?.semanticType).toBe("column"); + expect(stringColumnResult?.value).toBe("username"); + }); +}); + +describe("Custom Tooltip Renderers", () => { + const mockSchema: SQLNamespace = { + users: { + self: createCompletion("users", "User table"), + children: [ + createCompletion("id", "Primary key"), + createCompletion("name", "User name"), + "email", + ], + }, + products: [createCompletion("product_id", "Product identifier"), "title", "price"], + }; + + describe("Keyword renderer", () => { + it("should use custom keyword renderer when provided", () => { + const customKeywordRenderer = vi.fn().mockReturnValue("
Custom keyword tooltip
"); + + expect(() => { + sqlHover({ + schema: mockSchema, + tooltipRenderers: { + keyword: customKeywordRenderer, + }, + }); + }).not.toThrow(); + + // Since we can't easily trigger hover in tests, we just verify no errors occur + expect(customKeywordRenderer).not.toHaveBeenCalled(); // Not called in setup + }); + + it("should fallback to default keyword renderer when custom renderer not provided", () => { + expect(() => { + sqlHover({ + schema: mockSchema, + // No custom keyword renderer + }); + }).not.toThrow(); + }); + }); + + describe("Namespace renderer", () => { + it("should use custom namespace renderer for database items", () => { + const customNamespaceRenderer = vi + .fn() + .mockReturnValue("
Custom namespace tooltip
"); + + expect(() => { + sqlHover({ + schema: mockSchema, + tooltipRenderers: { + namespace: customNamespaceRenderer, + }, + }); + }).not.toThrow(); + }); + + it("should handle namespace renderer data structure correctly", () => { + // Test the data structure that would be passed to namespace renderer + const result = resolveNamespaceItem(mockSchema, "users"); + expect(result).toBeTruthy(); + expect(result?.semanticType).toBe("table"); + + // Verify data structure for namespace renderer + const namespaceData = { + item: result!, + word: "users", + resolvedSchema: mockSchema, + }; + + expect(namespaceData.item.semanticType).toBe("table"); + expect(namespaceData.word).toBe("users"); + expect(namespaceData.resolvedSchema).toBe(mockSchema); + }); + }); + + describe("Table renderer", () => { + it("should use custom table renderer for table items", () => { + const customTableRenderer = vi.fn().mockReturnValue("
Custom table tooltip
"); + + expect(() => { + sqlHover({ + schema: mockSchema, + tooltipRenderers: { + table: customTableRenderer, + }, + }); + }).not.toThrow(); + }); + + it("should handle table renderer data structure correctly", () => { + const result = resolveNamespaceItem(mockSchema, "users"); + expect(result).toBeTruthy(); + expect(result?.semanticType).toBe("table"); + + // Verify this would be passed to table renderer + const tableData = { + item: result!, + word: "users", + resolvedSchema: mockSchema, + }; + + expect(tableData.item.completion?.label).toBe("users"); + expect(tableData.item.namespace).toBeDefined(); + }); + }); + + describe("Column renderer", () => { + it("should use custom column renderer for column items", () => { + const customColumnRenderer = vi.fn().mockReturnValue("
Custom column tooltip
"); + + expect(() => { + sqlHover({ + schema: mockSchema, + tooltipRenderers: { + column: customColumnRenderer, + }, + }); + }).not.toThrow(); + }); + + it("should handle column renderer data structure correctly", () => { + const result = resolveNamespaceItem(mockSchema, "id", { enableFuzzySearch: true }); + expect(result).toBeTruthy(); + expect(result?.semanticType).toBe("column"); + + // Verify data structure for column renderer + const columnData = { + item: result!, + word: "id", + resolvedSchema: mockSchema, + }; + + expect(columnData.item.completion?.label).toBe("id"); + expect(columnData.item.semanticType).toBe("column"); + }); + }); + + describe("Multiple custom renderers", () => { + it("should handle multiple custom renderers simultaneously", () => { + const customKeywordRenderer = vi.fn().mockReturnValue("
Custom keyword
"); + const customTableRenderer = vi.fn().mockReturnValue("
Custom table
"); + const customColumnRenderer = vi.fn().mockReturnValue("
Custom column
"); + const customNamespaceRenderer = vi.fn().mockReturnValue("
Custom namespace
"); + + expect(() => { + sqlHover({ + schema: mockSchema, + tooltipRenderers: { + keyword: customKeywordRenderer, + table: customTableRenderer, + column: customColumnRenderer, + namespace: customNamespaceRenderer, + }, + }); + }).not.toThrow(); + }); + }); + + describe("Fallback behavior", () => { + it("should fallback to default renderer when custom renderer is not provided for specific type", () => { + expect(() => { + sqlHover({ + schema: mockSchema, + tooltipRenderers: { + // Only provide keyword renderer, others should fallback + keyword: () => "
Custom keyword
", + }, + }); + }).not.toThrow(); + }); + + it("should work with empty tooltipRenderers object", () => { + expect(() => { + sqlHover({ + schema: mockSchema, + tooltipRenderers: {}, + }); + }).not.toThrow(); + }); + + it("should work without tooltipRenderers option", () => { + expect(() => { + sqlHover({ + schema: mockSchema, + // No tooltipRenderers specified + }); + }).not.toThrow(); + }); + }); +}); diff --git a/src/sql/__tests__/namespace-utils.test.ts b/src/sql/__tests__/namespace-utils.test.ts new file mode 100644 index 0000000..6e92ffa --- /dev/null +++ b/src/sql/__tests__/namespace-utils.test.ts @@ -0,0 +1,613 @@ +import type { Completion } from "@codemirror/autocomplete"; +import type { SQLNamespace } from "@codemirror/lang-sql"; +import { describe, expect, it } from "vitest"; +import { + findNamespaceCompletions, + findNamespaceItemByEndMatch, + isArrayNamespace, + isObjectNamespace, + isSelfChildrenNamespace, + resolveNamespaceItem, + traverseNamespacePath, +} from "../namespace-utils.js"; + +// Helper function to create completion objects +function createCompletion(label: string, detail?: string): Completion { + return { + label, + detail: detail || `${label} completion`, + type: "property", + }; +} + +// Test data structures representing different SQLNamespace formats +const mockNamespaces = { + // Simple object namespace: { [name: string]: SQLNamespace } + simpleObject: { + users: ["id", "name", "email"], + orders: ["id", "user_id", "total", "created_at"], + products: { + electronics: ["laptop", "phone", "tablet"], + books: ["fiction", "non_fiction", "technical"], + }, + } as SQLNamespace, + + // Self-children namespace: { self: Completion; children: SQLNamespace } + selfChildren: { + self: createCompletion("database", "Main database"), + children: { + public: { + users: [createCompletion("id"), createCompletion("name"), createCompletion("email")], + orders: [createCompletion("id"), createCompletion("user_id"), createCompletion("total")], + }, + private: { + secrets: ["api_key", "password_hash"], + }, + }, + } as SQLNamespace, + + // Array namespace: readonly (Completion | string)[] + arrayNamespace: [ + "id", + "name", + "email", + createCompletion("created_at", "Timestamp column"), + createCompletion("updated_at", "Timestamp column"), + ] as SQLNamespace, + + // Complex nested structure combining all types + complexNested: { + postgres: { + self: createCompletion("postgres", "PostgreSQL database"), + children: { + public: { + users: { + self: createCompletion("users", "User table"), + children: [ + createCompletion("id", "Primary key"), + createCompletion("username", "Username"), + createCompletion("email", "Email address"), + "created_at", + "updated_at", + ], + }, + orders: [ + createCompletion("id"), + createCompletion("user_id"), + createCompletion("total"), + "status", + ], + }, + analytics: { + user_stats: ["daily_active", "monthly_active", "retention_rate"], + sales_data: { + q1: ["january", "february", "march"], + q2: ["april", "may", "june"], + }, + }, + }, + }, + mysql: { + test_db: { + customers: ["customer_id", "company_name", "contact_name"], + products: { + categories: [createCompletion("category_id"), createCompletion("category_name")], + }, + }, + }, + } as SQLNamespace, +}; + +describe("namespace-utils type guards", () => { + it("should correctly identify object namespace", () => { + expect(isObjectNamespace(mockNamespaces.simpleObject)).toBe(true); + expect(isObjectNamespace(mockNamespaces.selfChildren)).toBe(false); + expect(isObjectNamespace(mockNamespaces.arrayNamespace)).toBe(false); + }); + + it("should correctly identify self-children namespace", () => { + expect(isSelfChildrenNamespace(mockNamespaces.selfChildren)).toBe(true); + expect(isSelfChildrenNamespace(mockNamespaces.simpleObject)).toBe(false); + expect(isSelfChildrenNamespace(mockNamespaces.arrayNamespace)).toBe(false); + }); + + it("should correctly identify array namespace", () => { + expect(isArrayNamespace(mockNamespaces.arrayNamespace)).toBe(true); + expect(isArrayNamespace(mockNamespaces.simpleObject)).toBe(false); + expect(isArrayNamespace(mockNamespaces.selfChildren)).toBe(false); + }); +}); + +describe("traverseNamespacePath", () => { + describe("simple object namespace traversal", () => { + it("should traverse single level paths", () => { + const result = traverseNamespacePath(mockNamespaces.simpleObject, "users"); + expect(result).toBeTruthy(); + expect(result?.path).toEqual(["users"]); + expect(result?.type).toBe("namespace"); + }); + + it("should traverse multi-level paths", () => { + const result = traverseNamespacePath(mockNamespaces.simpleObject, "products.electronics"); + expect(result).toBeTruthy(); + expect(result?.path).toEqual(["products", "electronics"]); + expect(result?.type).toBe("namespace"); + }); + + it("should return null for non-existent paths", () => { + const result = traverseNamespacePath(mockNamespaces.simpleObject, "nonexistent"); + expect(result).toBeNull(); + }); + + it("should return null for non-existent nested paths", () => { + const result = traverseNamespacePath(mockNamespaces.simpleObject, "users.nonexistent"); + expect(result).toBeNull(); + }); + }); + + describe("self-children namespace traversal", () => { + it("should handle self-children at root level", () => { + const result = traverseNamespacePath(mockNamespaces.selfChildren, ""); + expect(result).toBeTruthy(); + expect(result?.type).toBe("completion"); + expect(result?.completion?.label).toBe("database"); + }); + + it("should traverse through self-children to nested content", () => { + const result = traverseNamespacePath(mockNamespaces.selfChildren, "public.users"); + expect(result).toBeTruthy(); + expect(result?.path).toEqual(["public", "users"]); + expect(result?.type).toBe("namespace"); + }); + }); + + describe("complex nested namespace traversal", () => { + it("should traverse deep paths with mixed namespace types", () => { + const result = traverseNamespacePath(mockNamespaces.complexNested, "postgres.public.users"); + expect(result).toBeTruthy(); + expect(result?.path).toEqual(["postgres", "public", "users"]); + expect(result?.type).toBe("completion"); + expect(result?.completion?.label).toBe("users"); + }); + + it("should handle array endpoints", () => { + const result = traverseNamespacePath( + mockNamespaces.complexNested, + "postgres.analytics.user_stats", + ); + expect(result).toBeTruthy(); + expect(result?.path).toEqual(["postgres", "analytics", "user_stats"]); + expect(result?.type).toBe("namespace"); + }); + + it("should traverse very deep paths", () => { + const result = traverseNamespacePath( + mockNamespaces.complexNested, + "postgres.analytics.sales_data.q1", + ); + expect(result).toBeTruthy(); + expect(result?.path).toEqual(["postgres", "analytics", "sales_data", "q1"]); + expect(result?.type).toBe("namespace"); + }); + }); + + describe("edge cases", () => { + it("should handle empty paths", () => { + const result = traverseNamespacePath(mockNamespaces.simpleObject, ""); + expect(result).toBeNull(); + }); + + it("should handle paths with empty segments", () => { + const result = traverseNamespacePath(mockNamespaces.simpleObject, "users..orders"); + expect(result).toBeNull(); + }); + + it("should respect maxDepth configuration", () => { + const result = traverseNamespacePath( + mockNamespaces.complexNested, + "postgres.analytics.sales_data.q1.january", + { maxDepth: 3 }, + ); + expect(result).toBeNull(); + }); + + it("should handle case-insensitive matching", () => { + const result = traverseNamespacePath(mockNamespaces.simpleObject, "USERS", { + caseSensitive: false, + }); + expect(result).toBeTruthy(); + expect(result?.path).toEqual(["users"]); + }); + + it("should handle case-sensitive matching", () => { + const result = traverseNamespacePath(mockNamespaces.simpleObject, "USERS", { + caseSensitive: true, + }); + expect(result).toBeNull(); + }); + }); +}); + +describe("findNamespaceCompletions", () => { + describe("prefix matching at root level", () => { + it("should find completions for simple prefixes", () => { + const results = findNamespaceCompletions(mockNamespaces.simpleObject, "u"); + expect(results).toHaveLength(1); + expect(results[0].path).toEqual(["users"]); + }); + + it("should find multiple completions for common prefixes", () => { + const results = findNamespaceCompletions(mockNamespaces.simpleObject, ""); + expect(results.length).toBeGreaterThanOrEqual(3); // users, orders, products + }); + + it("should return empty array for non-matching prefixes", () => { + const results = findNamespaceCompletions(mockNamespaces.simpleObject, "xyz"); + expect(results).toEqual([]); + }); + }); + + describe("prefix matching with dotted paths", () => { + it("should find completions for dotted prefixes", () => { + const results = findNamespaceCompletions(mockNamespaces.simpleObject, "products.e"); + expect(results).toHaveLength(1); + expect(results[0].path).toEqual(["products", "electronics"]); + }); + + it("should handle completions in self-children namespaces", () => { + const results = findNamespaceCompletions(mockNamespaces.selfChildren, "public.u"); + expect(results).toHaveLength(1); + expect(results[0].path).toEqual(["public", "users"]); + }); + + it("should find completions in complex nested structures", () => { + const results = findNamespaceCompletions(mockNamespaces.complexNested, "postgres.public."); + expect(results.length).toBeGreaterThanOrEqual(2); // users, orders + }); + }); + + describe("array namespace completions", () => { + it("should find string completions in arrays", () => { + const results = findNamespaceCompletions(mockNamespaces.arrayNamespace, ""); + expect(results.length).toBeGreaterThanOrEqual(5); + + const stringResults = results.filter((r) => r.type === "string"); + const completionResults = results.filter((r) => r.type === "completion"); + + expect(stringResults.length).toBeGreaterThanOrEqual(3); // id, name, email + expect(completionResults.length).toBeGreaterThanOrEqual(2); // created_at, updated_at + }); + + it("should find completion objects in arrays", () => { + const results = findNamespaceCompletions(mockNamespaces.arrayNamespace, "created"); + expect(results).toHaveLength(1); + expect(results[0].type).toBe("completion"); + expect(results[0].completion?.label).toBe("created_at"); + }); + }); + + describe("configuration options", () => { + it("should respect case sensitivity", () => { + const caseSensitiveResults = findNamespaceCompletions(mockNamespaces.simpleObject, "U", { + caseSensitive: true, + }); + expect(caseSensitiveResults).toHaveLength(0); + + const caseInsensitiveResults = findNamespaceCompletions(mockNamespaces.simpleObject, "U", { + caseSensitive: false, + }); + expect(caseInsensitiveResults).toHaveLength(1); + }); + + it("should handle exact vs partial matching", () => { + const partialResults = findNamespaceCompletions(mockNamespaces.simpleObject, "user", { + allowPartialMatch: true, + }); + expect(partialResults).toHaveLength(1); + + const exactResults = findNamespaceCompletions(mockNamespaces.simpleObject, "user", { + allowPartialMatch: false, + }); + expect(exactResults).toHaveLength(0); + }); + }); +}); + +describe("findNamespaceItemByEndMatch", () => { + it("should find items by their end identifier", () => { + const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "users"); + expect(results.length).toBeGreaterThanOrEqual(1); + + const userResult = results.find((r) => r.path[r.path.length - 1] === "users"); + expect(userResult).toBeTruthy(); + }); + + it("should find multiple matches for common end identifiers", () => { + const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "id"); + expect(results.length).toBeGreaterThanOrEqual(2); // Multiple tables have id columns + }); + + it("should sort results by path length (relevance)", () => { + const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "id"); + expect(results.length).toBeGreaterThan(1); + + // Check that results are sorted by path length + for (let i = 1; i < results.length; i++) { + expect(results[i].path.length).toBeGreaterThanOrEqual(results[i - 1].path.length); + } + }); + + it("should handle case-insensitive matching", () => { + const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "USERS", { + caseSensitive: false, + }); + expect(results.length).toBeGreaterThanOrEqual(1); + }); + + it("should handle case-sensitive matching", () => { + const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "USERS", { + caseSensitive: true, + }); + expect(results).toHaveLength(0); + }); + + it("should return empty array for non-existent identifiers", () => { + const results = findNamespaceItemByEndMatch(mockNamespaces.complexNested, "nonexistent"); + expect(results).toEqual([]); + }); +}); + +describe("resolveNamespaceItem", () => { + describe("preference order resolution", () => { + it("should prefer exact path matches over fuzzy matches", () => { + // Create a namespace where "users" exists both as exact path and fuzzy match + const testNamespace = { + users: ["id", "name"], + accounts: { + admin_users: ["admin_id", "permissions"], + }, + } as SQLNamespace; + + const result = resolveNamespaceItem(testNamespace, "users"); + expect(result).toBeTruthy(); + expect(result?.path).toEqual(["users"]); + expect(result?.type).toBe("namespace"); + }); + + it("should fall back to prefix completions when no exact match", () => { + const result = resolveNamespaceItem(mockNamespaces.simpleObject, "use"); + expect(result).toBeTruthy(); + expect(result?.path).toEqual(["users"]); + }); + + it("should fall back to end-match fuzzy search as last resort", () => { + // Search for something that doesn't exist as exact match or prefix + const result = resolveNamespaceItem(mockNamespaces.complexNested, "id", { + enableFuzzySearch: true, + }); + expect(result).toBeTruthy(); + expect(result?.path[result?.path.length - 1]).toBe("id"); + }); + + it("should return null when nothing matches", () => { + const result = resolveNamespaceItem(mockNamespaces.simpleObject, "definitely_nonexistent"); + expect(result).toBeNull(); + }); + + it("should not use fuzzy search when disabled by default", () => { + // Search for something that would be found via fuzzy search but not exact/prefix match + const result = resolveNamespaceItem(mockNamespaces.complexNested, "id"); + expect(result).toBeNull(); + }); + + it("should use fuzzy search when explicitly enabled", () => { + // Same search with fuzzy search enabled should work + const result = resolveNamespaceItem(mockNamespaces.complexNested, "id", { + enableFuzzySearch: true, + }); + expect(result).toBeTruthy(); + expect(result?.path[result?.path.length - 1]).toBe("id"); + }); + }); + + describe("exact segment matching in fuzzy search", () => { + const testSchema: SQLNamespace = { + users: { + self: { label: "users", type: "property" }, + children: [ + { label: "name", type: "property" }, + { label: "full_name", type: "property" }, + { label: "user_name", type: "property" }, + "email", + ], + }, + profiles: { + self: { label: "profiles", type: "property" }, + children: [ + { label: "name", type: "property" }, + { label: "display_name", type: "property" }, + ], + }, + companies: [ + { label: "name", type: "property" }, + { label: "company_name", type: "property" }, + "website", + ], + }; + + it("should match exact segments only, not partial segments", () => { + // Search for 'name' should match 'users.name', 'profiles.name', 'companies.name' + // but NOT 'users.full_name', 'users.user_name', 'profiles.display_name', 'companies.company_name' + const results = findNamespaceItemByEndMatch(testSchema, "name", { enableFuzzySearch: true }); + + expect(results).toHaveLength(3); // users.name, profiles.name, companies.name + + const paths = results.map((r) => r.path.join(".")); + expect(paths).toContain("users.name"); + expect(paths).toContain("profiles.name"); + expect(paths).toContain("companies.name"); + + // Should NOT contain partial matches + expect(paths).not.toContain("users.full_name"); + expect(paths).not.toContain("users.user_name"); + expect(paths).not.toContain("profiles.display_name"); + expect(paths).not.toContain("companies.company_name"); + }); + + it("should prioritize end-of-path matches", () => { + // Both 'users' and 'users.name' contain 'users', but 'users' should come first + const results = findNamespaceItemByEndMatch(testSchema, "users", { enableFuzzySearch: true }); + + expect(results.length).toBeGreaterThan(0); + expect(results[0]?.path).toEqual(["users"]); // Should prioritize the direct match + }); + + it("should handle case-insensitive matching", () => { + const results = findNamespaceItemByEndMatch(testSchema, "NAME", { + enableFuzzySearch: true, + caseSensitive: false, + }); + + expect(results).toHaveLength(3); + const paths = results.map((r) => r.path.join(".")); + expect(paths).toContain("users.name"); + expect(paths).toContain("profiles.name"); + expect(paths).toContain("companies.name"); + }); + + it("should handle case-sensitive matching", () => { + const results = findNamespaceItemByEndMatch(testSchema, "NAME", { + enableFuzzySearch: true, + caseSensitive: true, + }); + + // Should find no matches since 'NAME' (uppercase) doesn't match 'name' (lowercase) exactly + expect(results).toHaveLength(0); + }); + + it("should work with resolveNamespaceItem integration", () => { + // Test that the exact segment matching works through the main resolution function + const result = resolveNamespaceItem(testSchema, "name", { enableFuzzySearch: true }); + + expect(result).toBeTruthy(); + expect(result?.path).toContain("name"); + + // Should be one of the exact matches, not a partial match + const pathStr = result?.path.join("."); + expect(["users.name", "profiles.name", "companies.name"]).toContain(pathStr); + }); + + it("should not match when fuzzy search is disabled", () => { + // Without fuzzy search, 'name' should not be found (no exact path match) + const result = resolveNamespaceItem(testSchema, "name", { enableFuzzySearch: false }); + expect(result).toBeNull(); + }); + }); + + describe("complex resolution scenarios", () => { + it("should resolve deeply nested identifiers", () => { + const result = resolveNamespaceItem(mockNamespaces.complexNested, "postgres.public.users"); + expect(result).toBeTruthy(); + expect(result?.type).toBe("completion"); + expect(result?.completion?.label).toBe("users"); + }); + + it("should resolve partial nested paths", () => { + const result = resolveNamespaceItem(mockNamespaces.complexNested, "postgres.public"); + expect(result).toBeTruthy(); + expect(result?.path).toEqual(["postgres", "public"]); + }); + + it("should handle mixed namespace types in resolution", () => { + const result = resolveNamespaceItem( + mockNamespaces.complexNested, + "postgres.analytics.user_stats", + ); + expect(result).toBeTruthy(); + expect(result?.type).toBe("namespace"); + expect(result?.path).toEqual(["postgres", "analytics", "user_stats"]); + }); + }); +}); + +describe("edge cases and error handling", () => { + it("should handle null/undefined namespaces gracefully", () => { + expect(() => traverseNamespacePath({} as SQLNamespace, "test")).not.toThrow(); + expect(() => findNamespaceCompletions({} as SQLNamespace, "test")).not.toThrow(); + expect(() => findNamespaceItemByEndMatch({} as SQLNamespace, "test")).not.toThrow(); + expect(() => resolveNamespaceItem({} as SQLNamespace, "test")).not.toThrow(); + }); + + it("should handle malformed paths", () => { + expect(traverseNamespacePath(mockNamespaces.simpleObject, "..")).toBeNull(); + expect(traverseNamespacePath(mockNamespaces.simpleObject, ".")).toBeNull(); + expect(traverseNamespacePath(mockNamespaces.simpleObject, "users.")).toBeTruthy(); // Should handle trailing dot + }); + + it("should handle extremely deep nesting within maxDepth", () => { + const deepNamespace: SQLNamespace = { + level1: { + level2: { + level3: { + level4: { + level5: ["final"], + }, + }, + }, + }, + }; + + const result = traverseNamespacePath(deepNamespace, "level1.level2.level3.level4.level5"); + expect(result).toBeTruthy(); + + const resultExceedsDepth = traverseNamespacePath( + deepNamespace, + "level1.level2.level3.level4.level5.final", + { maxDepth: 5 }, + ); + expect(resultExceedsDepth).toBeNull(); + }); + + it("should handle circular references without infinite loops", () => { + // Create a namespace with potential circular reference + const circularNamespace: any = { + parent: { + child: null, + }, + }; + circularNamespace.parent.child = circularNamespace.parent; + + expect(() => { + findNamespaceCompletions(circularNamespace, "parent", { maxDepth: 5 }); + }).not.toThrow(); + }); +}); + +describe("performance and memory", () => { + it("should handle large namespaces efficiently", () => { + // Create a large namespace + const largeNamespace: SQLNamespace = {}; + for (let i = 0; i < 1000; i++) { + (largeNamespace as any)[`table_${i}`] = [`col1_${i}`, `col2_${i}`, `col3_${i}`]; + } + + const startTime = performance.now(); + const results = findNamespaceCompletions(largeNamespace, "table_1"); + const endTime = performance.now(); + + expect(results.length).toBeGreaterThan(0); + expect(endTime - startTime).toBeLessThan(100); // Should complete within 100ms + }); + + it("should not accumulate memory with repeated operations", () => { + // Perform many operations to check for memory leaks + for (let i = 0; i < 100; i++) { + resolveNamespaceItem(mockNamespaces.complexNested, "postgres.public.users"); + findNamespaceCompletions(mockNamespaces.complexNested, "postgres"); + findNamespaceItemByEndMatch(mockNamespaces.complexNested, "id"); + } + + // If we get here without running out of memory, the test passes + expect(true).toBe(true); + }); +}); diff --git a/src/sql/__tests__/structure-extension.test.ts b/src/sql/__tests__/structure-extension.test.ts new file mode 100644 index 0000000..59dba26 --- /dev/null +++ b/src/sql/__tests__/structure-extension.test.ts @@ -0,0 +1,74 @@ +import { EditorState, Text } from "@codemirror/state"; +import type { EditorView } from "@codemirror/view"; +import { describe, expect, it } from "vitest"; +import { sqlStructureGutter } from "../structure-extension.js"; + +// Mock EditorView +const _createMockView = (content: string, hasFocus = true) => { + const doc = Text.of(content.split("\n")); + const state = EditorState.create({ + doc, + extensions: [sqlStructureGutter()], + }); + + return { + state, + hasFocus, + dispatch: () => {}, + } as any as EditorView; +}; + +describe("sqlStructureGutter", () => { + it("should create a gutter extension with default config", () => { + const extensions = sqlStructureGutter(); + expect(Array.isArray(extensions)).toBe(true); + expect(extensions.length).toBeGreaterThan(0); + }); + + it("should accept custom configuration", () => { + const config = { + backgroundColor: "#ff0000", + errorBackgroundColor: "#00ff00", + width: 5, + className: "custom-sql-gutter", + showInvalid: false, + inactiveOpacity: 0.5, + hideWhenNotFocused: true, + }; + + const extensions = sqlStructureGutter(config); + expect(Array.isArray(extensions)).toBe(true); + expect(extensions.length).toBeGreaterThan(0); + }); + + it("should handle empty configuration", () => { + const extensions = sqlStructureGutter({}); + expect(Array.isArray(extensions)).toBe(true); + }); + + it("should create extensions for all required parts", () => { + const extensions = sqlStructureGutter(); + // Should include state field, update listener, theme, and gutter + expect(extensions.length).toBe(4); + }); + + it("should handle unfocusedOpacity configuration", () => { + const config = { unfocusedOpacity: 0.2 }; + const extensions = sqlStructureGutter(config); + expect(extensions.length).toBe(4); + }); + + it("should handle whenHide configuration", () => { + const config = { + whenHide: (view: EditorView) => view.state.doc.length === 0, + }; + const extensions = sqlStructureGutter(config); + expect(extensions.length).toBe(4); + }); + + it("should work with minimal configuration", () => { + const config = { width: 2 }; + const extensions = sqlStructureGutter(config); + expect(extensions.length).toBe(4); + }); +}); diff --git a/src/sql/cte-completion-source.ts b/src/sql/cte-completion-source.ts new file mode 100644 index 0000000..605ddc6 --- /dev/null +++ b/src/sql/cte-completion-source.ts @@ -0,0 +1,90 @@ +import type { Completion, CompletionContext, CompletionSource } from "@codemirror/autocomplete"; + +/** + * A completion source for Common Table Expressions (CTEs) in SQL + * + * This function provides autocomplete suggestions for CTE references based on + * WITH clauses found in the current SQL document. + * + * @param context The completion context from CodeMirror + * @returns Completion result with CTE suggestions or null if no completions available + * + * @example + * ```ts + * import { cteCompletionSource } from '@marimo-team/codemirror-sql'; + * import { StandardSQL } from '@codemirror/lang-sql'; + * + * // Add to SQL language configuration + * StandardSQL.language.data.of({ + * autocomplete: cteCompletionSource, + * }) + * ``` + */ +export const cteCompletionSource: CompletionSource = (context: CompletionContext) => { + const doc = context.state.doc.toString(); + const cteNames = new Set(); + + // Find all CTEs in the document using regex pattern + // Match WITH clause and extract CTE names + const ctePattern = /WITH\s+(?:RECURSIVE\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\s+AS\s*\(/gi; + let match: RegExpExecArray | null; + + match = ctePattern.exec(doc); + while (match !== null) { + const cteName = match[1]; + if (cteName) { + cteNames.add(cteName); + } + match = ctePattern.exec(doc); + } + + // Also match additional CTEs in the same WITH clause (comma-separated) + const additionalCtePattern = + /WITH\s+(?:RECURSIVE\s+)?[a-zA-Z_][a-zA-Z0-9_]*\s+AS\s*\([^)]*\)(?:\s*,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s+AS\s*\([^)]*\))*/gi; + + match = additionalCtePattern.exec(doc); + while (match !== null) { + // Extract all comma-separated CTEs + const fullMatch = match[0]; + const cteChainPattern = /([a-zA-Z_][a-zA-Z0-9_]*)\s+AS\s*\(/g; + let cteMatch: RegExpExecArray | null = cteChainPattern.exec(fullMatch); + + while (cteMatch !== null) { + const cteName = cteMatch[1]; + if (cteName) { + cteNames.add(cteName); + } + cteMatch = cteChainPattern.exec(fullMatch); + } + match = additionalCtePattern.exec(doc); + } + + // If no CTEs found, return null (no completions) + if (cteNames.size === 0) { + return null; + } + + // Get the word being typed + const word = context.matchBefore(/\w*/); + if (!word) { + return null; + } + + // If no word is being typed and not in explicit mode, don't show completions + if (word.from === word.to && !context.explicit) { + return null; + } + + // Create completion objects for each CTE + const completions: Completion[] = Array.from(cteNames).map((cteName) => ({ + label: cteName, + type: "variable", // CTEs are like temporary tables/variables + info: `Common Table Expression: ${cteName}`, + boost: 10, // Give CTEs higher priority than regular completions + })); + + return { + from: word.from, + options: completions, + }; +}; diff --git a/src/sql/diagnostics.ts b/src/sql/diagnostics.ts index 3f4a332..93c2465 100644 --- a/src/sql/diagnostics.ts +++ b/src/sql/diagnostics.ts @@ -5,11 +5,19 @@ import { type SqlParseError, SqlParser } from "./parser.js"; const DEFAULT_DELAY = 750; +/** + * Configuration options for the SQL linter + */ export interface SqlLinterConfig { + /** Delay in milliseconds before running validation (default: 750) */ delay?: number; + /** Custom SQL parser instance to use for validation */ parser?: SqlParser; } +/** + * Converts a SQL parse error to a CodeMirror diagnostic + */ function convertToCodeMirrorDiagnostic(error: SqlParseError, doc: Text): Diagnostic { const lineStart = doc.line(error.line).from; const from = lineStart + Math.max(0, error.column - 1); @@ -24,6 +32,22 @@ function convertToCodeMirrorDiagnostic(error: SqlParseError, doc: Text): Diagnos }; } +/** + * Creates a SQL linter extension that validates SQL syntax and reports errors + * + * @param config Configuration options for the linter + * @returns A CodeMirror linter extension + * + * @example + * ```ts + * import { sqlLinter } from '@marimo-team/codemirror-sql'; + * + * const linter = sqlLinter({ + * delay: 500, // 500ms delay before validation + * parser: new SqlParser() // custom parser instance + * }); + * ``` + */ export function sqlLinter(config: SqlLinterConfig = {}) { const parser = config.parser || new SqlParser(); diff --git a/src/sql/extension.ts b/src/sql/extension.ts index 51fa9a3..e2803ec 100644 --- a/src/sql/extension.ts +++ b/src/sql/extension.ts @@ -1,22 +1,74 @@ import type { Extension } from "@codemirror/state"; import { type SqlLinterConfig, sqlLinter } from "./diagnostics.js"; +import { type SqlHoverConfig, sqlHover, sqlHoverTheme } from "./hover.js"; import { type SqlGutterConfig, sqlStructureGutter } from "./structure-extension.js"; -export interface SqlExtensionConfig extends SqlLinterConfig, SqlGutterConfig { +/** + * Configuration options for the SQL extension + */ +export interface SqlExtensionConfig { + /** Whether to enable SQL linting (default: true) */ enableLinting?: boolean; - enableStructureAnalysis?: boolean; + /** Configuration for the SQL linter */ + linterConfig?: SqlLinterConfig; + + /** Whether to enable gutter markers for SQL statements (default: true) */ enableGutterMarkers?: boolean; + /** Configuration for the SQL gutter markers */ + gutterConfig?: SqlGutterConfig; + + /** Whether to enable hover tooltips (default: true) */ + enableHover?: boolean; + /** Configuration for hover tooltips */ + hoverConfig?: SqlHoverConfig; } +/** + * Creates a comprehensive SQL extension for CodeMirror that includes: + * - SQL syntax validation and linting + * - Visual gutter indicators for SQL statements + * - Hover tooltips for keywords, tables, and columns + * + * @param config Configuration options for the extension + * @returns An array of CodeMirror extensions + * + * @example + * ```ts + * import { sqlExtension } from '@marimo-team/codemirror-sql'; + * + * const editor = new EditorView({ + * extensions: [ + * sqlExtension({ + * linterConfig: { delay: 500 }, + * gutterConfig: { backgroundColor: '#3b82f6' }, + * hoverConfig: { hoverTime: 300 } + * }) + * ] + * }); + * ``` + */ export function sqlExtension(config: SqlExtensionConfig = {}): Extension { const extensions: Extension[] = []; + const { + enableLinting = true, + enableGutterMarkers = true, + enableHover = true, + linterConfig, + gutterConfig, + hoverConfig, + } = config; + + if (enableLinting) { + extensions.push(sqlLinter(linterConfig)); + } - if (config.enableLinting !== false) { - extensions.push(sqlLinter(config)); + if (enableGutterMarkers) { + extensions.push(sqlStructureGutter(gutterConfig)); } - if (config.enableStructureAnalysis !== false && config.enableGutterMarkers !== false) { - extensions.push(sqlStructureGutter(config)); + if (enableHover) { + extensions.push(sqlHover(hoverConfig)); + extensions.push(sqlHoverTheme()); } return extensions; diff --git a/src/sql/hover.ts b/src/sql/hover.ts new file mode 100644 index 0000000..bf94552 --- /dev/null +++ b/src/sql/hover.ts @@ -0,0 +1,552 @@ +import type { SQLDialect, SQLNamespace } from "@codemirror/lang-sql"; +import type { Extension } from "@codemirror/state"; +import { EditorView, hoverTooltip, type Tooltip } from "@codemirror/view"; +import { debug } from "../debug.js"; +import { + isArrayNamespace, + type ResolvedNamespaceItem, + resolveNamespaceItem, +} from "./namespace-utils.js"; + +/** + * SQL schema information for hover tooltips + */ +export interface SqlSchema { + [tableName: string]: string[]; +} + +/** + * SQL keyword information + */ +export interface SqlKeywordInfo { + description?: string; + syntax?: string; + example?: string; + metadata?: Record; +} + +/** + * Data passed to keyword tooltip renderers + */ +export interface KeywordTooltipData { + keyword: string; + info: SqlKeywordInfo; +} + +/** + * Data passed to namespace tooltip renderers (namespace, table, column) + */ +export interface NamespaceTooltipData { + item: ResolvedNamespaceItem; + /** The word being hovered over */ + word: string; + /** The resolved schema context */ + resolvedSchema: SQLNamespace; +} + +/** + * Configuration for SQL hover tooltips + */ +export interface SqlHoverConfig { + /** Database schema for table and column information */ + schema?: SQLNamespace | ((view: EditorView) => SQLNamespace); + /** SQL dialect for keyword information */ + dialect?: SQLDialect | ((view: EditorView) => SQLDialect); + /** Custom keyword information */ + keywords?: + | Record + | ((view: EditorView) => Promise>); + /** Hover delay in milliseconds (default: 300) */ + hoverTime?: number; + /** Enable hover for keywords (default: true) */ + enableKeywords?: boolean; + /** Enable hover for tables (default: true) */ + enableTables?: boolean; + /** Enable hover for columns (default: true) */ + enableColumns?: boolean; + /** Enable fuzzy search for namespace items (default: false) */ + enableFuzzySearch?: boolean; + /** Custom tooltip renderers for different item types */ + tooltipRenderers?: { + /** Custom renderer for SQL keywords */ + keyword?: (data: KeywordTooltipData) => string; + /** Custom renderer for namespace items (database, schema, generic namespace) */ + namespace?: (data: NamespaceTooltipData) => string; + /** Custom renderer for table items */ + table?: (data: NamespaceTooltipData) => string; + /** Custom renderer for column items */ + column?: (data: NamespaceTooltipData) => string; + }; +} + +/** + * Creates a hover tooltip extension for SQL + */ +export function sqlHover(config: SqlHoverConfig = {}): Extension { + const { + schema = {}, + keywords = {}, + hoverTime = 300, + enableKeywords = true, + enableTables = true, + enableColumns = true, + enableFuzzySearch = true, + tooltipRenderers = {}, + } = config; + + let keywordsPromise: Promise> | null = null; + + return hoverTooltip( + async (view: EditorView, pos: number, side: number): Promise => { + const { from, to, text } = view.state.doc.lineAt(pos); + let start = pos; + let end = pos; + + if (keywordsPromise === null) { + keywordsPromise = + typeof keywords === "function" ? keywords(view) : Promise.resolve(keywords); + } + + const resolvedKeywords = await keywordsPromise; + + // Find word boundaries (including dots for table.column syntax) + while (start > from && /[\w.]/.test(text[start - from - 1] ?? "")) start--; + while (end < to && /[\w.]/.test(text[end - from] ?? "")) end++; + + // Validate pointer position within word + if ((start === pos && side < 0) || (end === pos && side > 0)) { + return null; + } + + const word = text.slice(start - from, end - from).toLowerCase(); + if (!word || word.length === 0) { + return null; + } + + const resolvedSchema = typeof schema === "function" ? schema(view) : schema; + + let tooltipContent: string | null = null; + + debug(`hover word: '${word}'`); + + // Implement preference order: + // 1. Look in keywords if it exists + // 2. Look for it in SQLNamespace as is + // 3. If neither, look in SQLNamespace and try to guess (fuzzy match) + + // Step 1: If no namespace match, try keywords + if (!tooltipContent && enableKeywords && resolvedKeywords[word]) { + debug("keywordResult", word, resolvedKeywords[word]); + const keywordData: KeywordTooltipData = { keyword: word, info: resolvedKeywords[word] }; + tooltipContent = tooltipRenderers.keyword + ? tooltipRenderers.keyword(keywordData) + : createKeywordTooltip(keywordData); + } + + // Step 2: Try to resolve directly in SQLNamespace + if (!tooltipContent && (enableTables || enableColumns) && resolvedSchema) { + const namespaceResult = resolveNamespaceItem(resolvedSchema, word, { + enableFuzzySearch, + }); + if (namespaceResult) { + debug("namespaceResult", word, namespaceResult); + const namespaceData: NamespaceTooltipData = { + item: namespaceResult, + word, + resolvedSchema, + }; + + // Use custom renderer based on semantic type, fallback to default + const { semanticType } = namespaceResult; + if (semanticType === "table" && tooltipRenderers.table) { + tooltipContent = tooltipRenderers.table(namespaceData); + } else if (semanticType === "column" && tooltipRenderers.column) { + tooltipContent = tooltipRenderers.column(namespaceData); + } else if ( + (semanticType === "database" || + semanticType === "schema" || + semanticType === "namespace") && + tooltipRenderers.namespace + ) { + tooltipContent = tooltipRenderers.namespace(namespaceData); + } else { + // Fallback to default renderer + tooltipContent = createNamespaceTooltip(namespaceResult); + } + } + } + + // Step 3: Fuzzy matching is handled by resolveNamespaceItem if enableFuzzySearch is true + + if (!tooltipContent) { + return null; + } + + return { + pos: start, + end, + above: true, + create(_view: EditorView) { + const dom = document.createElement("div"); + dom.className = "cm-sql-hover-tooltip"; + dom.innerHTML = tooltipContent!; + return { dom }; + }, + }; + }, + { hoverTime }, + ); +} + +/** + * Creates HTML content for namespace-resolved items + */ +function createNamespaceTooltip(item: ResolvedNamespaceItem): string { + const pathStr = item.path.join("."); + const name = item.completion?.label || item.value || item.path[item.path.length - 1] || "unknown"; + + let html = `
`; + html += `
${name} ${item.semanticType}
`; + + // Add semantic-specific descriptions and information + switch (item.semanticType) { + case "database": + html += `
Database${item.completion?.detail ? `: ${item.completion.detail}` : ""}
`; + if (item.namespace) { + const childCount = countNamespaceChildren(item.namespace); + if (childCount > 0) { + html += `
Contains ${childCount} schema${childCount !== 1 ? "s" : ""}
`; + } + } + break; + + case "schema": + html += `
Schema${item.completion?.detail ? `: ${item.completion.detail}` : ""}
`; + if (pathStr) { + html += `
Path: ${pathStr}
`; + } + if (item.namespace) { + const childCount = countNamespaceChildren(item.namespace); + if (childCount > 0) { + html += `
Contains ${childCount} table${childCount !== 1 ? "s" : ""}
`; + } + } + break; + + case "table": + html += `
Table${item.completion?.detail ? `: ${item.completion.detail}` : ""}
`; + if (pathStr) { + const pathParts = item.path; + if (pathParts.length > 1) { + html += `
Schema: ${pathParts.slice(0, -1).join(".")}
`; + } + } + + // Show column information for tables + if (item.namespace && isArrayNamespace(item.namespace)) { + const columns = item.namespace; + if (columns.length > 0) { + html += `
Columns (${columns.length}):
`; + const displayColumns = columns.slice(0, 8); + const columnNames = displayColumns.map((col) => + typeof col === "string" ? col : col.label, + ); + html += columnNames.map((col) => `${col}`).join(", "); + + if (columns.length > 8) { + html += `, and ${columns.length - 8} more...`; + } + html += `
`; + } + } + break; + + case "column": + html += `
Column${item.completion?.detail ? `: ${item.completion.detail}` : ""}
`; + if (pathStr) { + const pathParts = item.path; + if (pathParts.length > 1) { + html += `
Table: ${pathParts.slice(0, -1).join(".")}
`; + } + } + break; + default: + html += `
Namespace${item.completion?.detail ? `: ${item.completion.detail}` : ""}
`; + if (pathStr) { + html += `
Path: ${pathStr}
`; + } + if (item.namespace) { + const childCount = countNamespaceChildren(item.namespace); + if (childCount > 0) { + html += `
Contains ${childCount} item${childCount !== 1 ? "s" : ""}
`; + } + } + break; + } + + // Add completion-specific info if available + if (item.completion?.info) { + html += `
${item.completion.info}
`; + } + + html += `
`; + return html; +} + +/** + * Helper function to count children in a namespace + */ +function countNamespaceChildren(namespace: SQLNamespace): number { + if (Array.isArray(namespace)) { + return namespace.length; + } else if (typeof namespace === "object" && namespace !== null) { + if ("self" in namespace && "children" in namespace) { + return 1 + countNamespaceChildren(namespace.children); + } else { + return Object.keys(namespace).length; + } + } + return 0; +} + +/** + * Creates HTML content for keyword tooltips + * Renders metadata as tags if present + */ +function createKeywordTooltip(opts: { keyword: string; info: SqlKeywordInfo }): string { + const { keyword, info } = opts; + + let html = `
`; + html += `
${keyword.toUpperCase()} keyword
`; + html += `
${info.description}
`; + + if (info.syntax) { + html += `
Syntax: ${info.syntax}
`; + } + + if (info.example) { + html += `
Example:
${info.example}
`; + } + + if (info.metadata && typeof info.metadata === "object" && Object.keys(info.metadata).length > 0) { + html += ``; + } + + html += `
`; + return html; +} + +/** + * Creates HTML content for table tooltips + */ +function createTableTooltip(opts: { + tableName: string; + columns: string[]; + metadata?: Record; +}): string { + const { tableName, columns, metadata } = opts; + + let html = `
`; + html += `
${tableName} table
`; + html += `
Table with ${columns.length} column${columns.length !== 1 ? "s" : ""}
`; + + if (columns.length > 0) { + html += `
Columns:
`; + const displayColumns = columns.slice(0, 10); // Show max 10 columns + html += displayColumns.map((col) => `${col}`).join(", "); + + if (columns.length > 10) { + html += `, and ${columns.length - 10} more...`; + } + html += `
`; + } + + if (metadata && typeof metadata === "object" && Object.keys(metadata).length > 0) { + html += ``; + } + + html += `
`; + return html; +} + +/** + * Creates HTML content for column tooltips + */ +function createColumnTooltip(opts: { + tableName: string; + columnName: string; + schema: SqlSchema; + metadata?: Record; +}): string { + const { tableName, columnName, schema, metadata } = opts; + + let html = `
`; + html += `
${columnName} column
`; + html += `
Column in table ${tableName}
`; + + const allColumns = schema[tableName]; + if (allColumns && allColumns.length > 1) { + const otherColumns = allColumns.filter((col) => col !== columnName); + if (otherColumns.length > 0) { + html += ``; + } + } + + if (metadata && typeof metadata === "object" && Object.keys(metadata).length > 0) { + html += ``; + } + + html += `
`; + return html; +} + +export const DefaultSqlTooltipRenders = { + keyword: createKeywordTooltip, + table: createTableTooltip, + column: createColumnTooltip, + namespace: createNamespaceTooltip, +}; + +/** + * Default CSS styles for hover tooltips + */ +export const sqlHoverTheme = (): Extension => + EditorView.theme({ + ".cm-sql-hover-tooltip": { + padding: "8px 12px", + backgroundColor: "#ffffff", + border: "1px solid #e5e7eb", + borderRadius: "6px", + boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", + fontSize: "13px", + lineHeight: "1.4", + maxWidth: "320px", + fontFamily: "system-ui, -apple-system, sans-serif", + }, + ".cm-sql-hover-tooltip .sql-hover-header": { + marginBottom: "6px", + display: "flex", + alignItems: "center", + gap: "6px", + }, + ".cm-sql-hover-tooltip .sql-hover-type": { + fontSize: "11px", + padding: "2px 6px", + backgroundColor: "#f3f4f6", + color: "#6b7280", + borderRadius: "4px", + fontWeight: "500", + }, + ".cm-sql-hover-tooltip .sql-hover-description": { + color: "#374151", + marginBottom: "8px", + }, + ".cm-sql-hover-tooltip .sql-hover-syntax": { + marginBottom: "8px", + color: "#374151", + }, + ".cm-sql-hover-tooltip .sql-hover-example": { + marginBottom: "4px", + color: "#374151", + }, + ".cm-sql-hover-tooltip .sql-hover-columns": { + marginBottom: "4px", + color: "#374151", + }, + ".cm-sql-hover-tooltip .sql-hover-related": { + marginBottom: "4px", + color: "#374151", + }, + ".cm-sql-hover-tooltip .sql-hover-path": { + marginBottom: "4px", + color: "#374151", + }, + ".cm-sql-hover-tooltip .sql-hover-info": { + marginBottom: "4px", + color: "#374151", + }, + ".cm-sql-hover-tooltip .sql-hover-children": { + marginBottom: "4px", + color: "#6b7280", + fontSize: "12px", + }, + ".cm-sql-hover-tooltip code": { + backgroundColor: "#f9fafb", + padding: "1px 4px", + borderRadius: "3px", + fontSize: "12px", + fontFamily: "ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace", + color: "#1f2937", + }, + ".cm-sql-hover-tooltip strong": { + fontWeight: "600", + color: "#111827", + }, + ".cm-sql-hover-tooltip em": { + fontStyle: "italic", + color: "#6b7280", + }, + // Dark theme support + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip": { + backgroundColor: "#1f2937", + borderColor: "#374151", + color: "#f9fafb", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip .sql-hover-type": { + backgroundColor: "#374151", + color: "#9ca3af", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip .sql-hover-description": { + color: "#d1d5db", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip .sql-hover-syntax": { + color: "#d1d5db", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip .sql-hover-example": { + color: "#d1d5db", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip .sql-hover-columns": { + color: "#d1d5db", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip .sql-hover-related": { + color: "#d1d5db", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip .sql-hover-path": { + color: "#d1d5db", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip .sql-hover-info": { + color: "#d1d5db", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip .sql-hover-children": { + color: "#9ca3af", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip code": { + backgroundColor: "#374151", + color: "#f3f4f6", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip strong": { + color: "#ffffff", + }, + ".cm-editor.cm-focused.cm-dark .cm-sql-hover-tooltip em": { + color: "#9ca3af", + }, + }); diff --git a/src/sql/namespace-utils.ts b/src/sql/namespace-utils.ts new file mode 100644 index 0000000..9b3ad14 --- /dev/null +++ b/src/sql/namespace-utils.ts @@ -0,0 +1,666 @@ +import type { Completion } from "@codemirror/autocomplete"; +import type { SQLNamespace } from "@codemirror/lang-sql"; + +/** + * Semantic type for SQL namespace items + */ +export type SemanticType = "database" | "schema" | "table" | "column" | "namespace"; + +/** + * Represents a resolved namespace item with its path and metadata + */ +export interface ResolvedNamespaceItem { + /** The completion object if this is a terminal node */ + completion?: Completion; + /** The string value if this is a string terminal */ + value?: string; + /** The full path to this item */ + path: string[]; + /** The basic type of this item */ + type: "completion" | "string" | "namespace"; + /** The semantic SQL type of this item */ + semanticType: SemanticType; + /** The original namespace node */ + namespace?: SQLNamespace; +} + +/** + * Configuration for namespace search operations + */ +export interface NamespaceSearchConfig { + /** Maximum depth to search (default: 10) */ + maxDepth?: number; + /** Whether to perform case-sensitive matching (default: false) */ + caseSensitive?: boolean; + /** Whether to allow partial matching (default: true) */ + allowPartialMatch?: boolean; + /** Whether to enable fuzzy search (default: false) */ + enableFuzzySearch?: boolean; +} + +/** + * Checks if a namespace node is an object with string keys + */ +export function isObjectNamespace( + namespace: SQLNamespace, +): namespace is { [name: string]: SQLNamespace } { + return typeof namespace === "object" && !Array.isArray(namespace) && !("self" in namespace); +} + +/** + * Checks if a namespace node has self and children properties + */ +export function isSelfChildrenNamespace( + namespace: SQLNamespace, +): namespace is { self: Completion; children: SQLNamespace } { + return ( + typeof namespace === "object" && + !Array.isArray(namespace) && + "self" in namespace && + "children" in namespace + ); +} + +/** + * Checks if a namespace node is an array of completions/strings + */ +export function isArrayNamespace( + namespace: SQLNamespace, +): namespace is readonly (Completion | string)[] { + return Array.isArray(namespace); +} + +/** + * Determines the semantic type of an item based on its position and context + */ +function determineSemanticType( + path: string[], + type: "completion" | "string" | "namespace", + namespace?: SQLNamespace, + parentNamespace?: SQLNamespace, +): SemanticType { + // The semantic depth is the number of namespace levels, not the path length + // For self-children structures, the depth should be based on the logical nesting level + const depth = path.length; + + // For leaf items (strings or completions in arrays), they are always columns + if ( + type === "string" || + (type === "completion" && parentNamespace && isArrayNamespace(parentNamespace)) + ) { + return "column"; + } + + // For namespace items, determine based on structure + if (type === "namespace" && namespace) { + if (isArrayNamespace(namespace)) { + // Arrays represent column collections, so the array itself represents a table + return "table"; + } + + // For object namespaces, check if they contain tables (arrays) + if (isObjectNamespace(namespace)) { + const hasTableChildren = Object.values(namespace).some( + (child) => + isArrayNamespace(child) || + (isSelfChildrenNamespace(child) && isArrayNamespace(child.children)), + ); + + if (hasTableChildren) { + // This namespace contains tables, so it's a schema or database + return depth <= 1 ? "database" : "schema"; + } else { + // This namespace contains other namespaces + return depth === 0 ? "database" : "namespace"; + } + } + } + + // For completion items with self-children structure + if (type === "completion" && namespace) { + if (isArrayNamespace(namespace)) { + // This completion has column children, so it's a table + return "table"; + } else { + // This completion has namespace children + // Check if the children contain tables (arrays) to determine if this is a database or schema + if (isObjectNamespace(namespace)) { + const hasTableChildren = Object.values(namespace).some( + (child) => + isArrayNamespace(child) || + (isSelfChildrenNamespace(child) && isArrayNamespace(child.children)), + ); + + if (hasTableChildren) { + // Contains tables directly, could be either database or schema depending on depth + // For self-children completions, depth 1 means it's actually a root-level database + return depth <= 1 ? "database" : "schema"; + } else { + // Contains other namespaces, so this is a database (unless deeply nested) + // For self-children completions, depth 1 means it's actually a root-level database + return depth <= 1 ? "database" : "schema"; + } + } else { + // For other types of children + // For self-children completions, depth 1 means it's actually a root-level database + return depth <= 1 ? "database" : "schema"; + } + } + } + + // Fallback based on depth + switch (depth) { + case 0: + return "database"; + case 1: + return "schema"; + default: + return "namespace"; + } +} + +/** + * Traverses a namespace following a dotted path + * @param namespace The root namespace to search in + * @param path The dotted path to traverse (e.g., "db.catalog.table.column") + * @param config Configuration options + * @returns The resolved item or null if not found + */ +export function traverseNamespacePath( + namespace: SQLNamespace, + path: string, + config: NamespaceSearchConfig = {}, +): ResolvedNamespaceItem | null { + const { maxDepth = 10 } = config; + + // Handle special case of empty path for self-children namespace + if (path === "" && isSelfChildrenNamespace(namespace)) { + const semanticType = determineSemanticType([], "completion", namespace.children); + return { + completion: namespace.self, + path: [], + type: "completion", + semanticType, + namespace: namespace.children, + }; + } + + const pathParts = path.split(".").filter((part) => part.length > 0); + + if (pathParts.length === 0) { + // Empty path returns null for non-self-children namespaces + return null; + } + + if (pathParts.length > maxDepth) { + return null; + } + + return traverseNamespaceRecursive(namespace, pathParts, [], config); +} + +/** + * Recursive helper for namespace traversal + */ +function traverseNamespaceRecursive( + namespace: SQLNamespace, + remainingPath: string[], + currentPath: string[], + config: NamespaceSearchConfig, + parentNamespace?: SQLNamespace, +): ResolvedNamespaceItem | null { + if (remainingPath.length === 0) { + // We've reached the end of the path + if (isSelfChildrenNamespace(namespace)) { + const semanticType = determineSemanticType( + currentPath, + "completion", + namespace.children, + parentNamespace, + ); + return { + completion: namespace.self, + path: currentPath, + type: "completion", + semanticType, + namespace: namespace.children, + }; + } + const semanticType = determineSemanticType( + currentPath, + "namespace", + namespace, + parentNamespace, + ); + return { + path: currentPath, + type: "namespace", + semanticType, + namespace, + }; + } + + const [currentSegment, ...restPath] = remainingPath; + const { caseSensitive = false } = config; + + if (!currentSegment) { + return null; + } + + if (isObjectNamespace(namespace)) { + // Search through object keys + const targetKey = caseSensitive + ? Object.keys(namespace).find((key) => key === currentSegment) + : Object.keys(namespace).find((key) => key.toLowerCase() === currentSegment.toLowerCase()); + + if (targetKey) { + const childNamespace = namespace[targetKey]; + if (childNamespace) { + return traverseNamespaceRecursive( + childNamespace, + restPath, + [...currentPath, targetKey], + config, + namespace, + ); + } + } + } else if (isSelfChildrenNamespace(namespace)) { + // If this node has self/children, we need to check if the current segment matches the self + // and then continue with children for the rest of the path + return traverseNamespaceRecursive( + namespace.children, + remainingPath, + currentPath, + config, + namespace, + ); + } else if (isArrayNamespace(namespace)) { + // Check if any item in the array matches + for (const item of namespace) { + if (typeof item === "string") { + const matches = caseSensitive + ? item === currentSegment + : item.toLowerCase() === currentSegment.toLowerCase(); + + if (matches && restPath.length === 0) { + const semanticType = determineSemanticType( + [...currentPath, item], + "string", + undefined, + namespace, + ); + return { + value: item, + path: [...currentPath, item], + type: "string", + semanticType, + }; + } + } else { + // It's a Completion object + const matches = caseSensitive + ? item.label === currentSegment + : item.label.toLowerCase() === currentSegment.toLowerCase(); + + if (matches && restPath.length === 0) { + const semanticType = determineSemanticType( + [...currentPath, item.label], + "completion", + undefined, + namespace, + ); + return { + completion: item, + path: [...currentPath, item.label], + type: "completion", + semanticType, + }; + } + } + } + } + + return null; +} + +/** + * Finds all possible completions that match a prefix + * @param namespace The namespace to search in + * @param prefix The prefix to match (can be dotted like "db.table") + * @param config Configuration options + * @returns Array of resolved items that match the prefix + */ +export function findNamespaceCompletions( + namespace: SQLNamespace, + prefix: string, + config: NamespaceSearchConfig = {}, +): ResolvedNamespaceItem[] { + const results: ResolvedNamespaceItem[] = []; + + if (prefix.includes(".")) { + // Handle dotted prefixes like "db.table" + const lastDotIndex = prefix.lastIndexOf("."); + const basePath = prefix.substring(0, lastDotIndex); + const finalSegment = prefix.substring(lastDotIndex + 1); + + // First traverse to the base path + const baseNode = traverseNamespacePath(namespace, basePath, config); + if (baseNode?.namespace) { + // Then find completions in the target namespace + return findCompletionsInNamespace(baseNode.namespace, finalSegment, baseNode.path, config); + } + } else { + // Simple prefix, search at root level + return findCompletionsInNamespace(namespace, prefix, [], config); + } + + return results; +} + +/** + * Finds completions within a specific namespace node + */ +function findCompletionsInNamespace( + namespace: SQLNamespace, + prefix: string, + basePath: string[], + config: NamespaceSearchConfig, +): ResolvedNamespaceItem[] { + const results: ResolvedNamespaceItem[] = []; + const { caseSensitive = false, allowPartialMatch = true } = config; + + if (isObjectNamespace(namespace)) { + for (const [key, value] of Object.entries(namespace)) { + const matches = allowPartialMatch + ? caseSensitive + ? key.startsWith(prefix) + : key.toLowerCase().startsWith(prefix.toLowerCase()) + : caseSensitive + ? key === prefix + : key.toLowerCase() === prefix.toLowerCase(); + + if (matches) { + if (isSelfChildrenNamespace(value)) { + const semanticType = determineSemanticType( + [...basePath, key], + "completion", + value.children, + namespace, + ); + results.push({ + completion: value.self, + path: [...basePath, key], + type: "completion", + semanticType, + namespace: value.children, + }); + } else { + const semanticType = determineSemanticType( + [...basePath, key], + "namespace", + value, + namespace, + ); + results.push({ + path: [...basePath, key], + type: "namespace", + semanticType, + namespace: value, + }); + } + } + } + } else if (isSelfChildrenNamespace(namespace)) { + // If we have a self node, include it if it matches + const selfMatches = allowPartialMatch + ? caseSensitive + ? namespace.self.label.startsWith(prefix) + : namespace.self.label.toLowerCase().startsWith(prefix.toLowerCase()) + : caseSensitive + ? namespace.self.label === prefix + : namespace.self.label.toLowerCase() === prefix.toLowerCase(); + + if (selfMatches) { + const semanticType = determineSemanticType( + [...basePath, namespace.self.label], + "completion", + namespace.children, + ); + results.push({ + completion: namespace.self, + path: [...basePath, namespace.self.label], + type: "completion", + semanticType, + namespace: namespace.children, + }); + } + + // Also search in children + results.push(...findCompletionsInNamespace(namespace.children, prefix, basePath, config)); + } else if (isArrayNamespace(namespace)) { + for (const item of namespace) { + if (typeof item === "string") { + const matches = allowPartialMatch + ? caseSensitive + ? item.startsWith(prefix) + : item.toLowerCase().startsWith(prefix.toLowerCase()) + : caseSensitive + ? item === prefix + : item.toLowerCase() === prefix.toLowerCase(); + + if (matches) { + const semanticType = determineSemanticType( + [...basePath, item], + "string", + undefined, + namespace, + ); + results.push({ + value: item, + path: [...basePath, item], + type: "string", + semanticType, + }); + } + } else { + // It's a Completion object + const matches = allowPartialMatch + ? caseSensitive + ? item.label.startsWith(prefix) + : item.label.toLowerCase().startsWith(prefix.toLowerCase()) + : caseSensitive + ? item.label === prefix + : item.label.toLowerCase() === prefix.toLowerCase(); + + if (matches) { + const semanticType = determineSemanticType( + [...basePath, item.label], + "completion", + undefined, + namespace, + ); + results.push({ + completion: item, + path: [...basePath, item.label], + type: "completion", + semanticType, + }); + } + } + } + } + + return results; +} + +/** + * Performs a fuzzy search for items by searching for exact segment matches in the full schema path + * This implements the "crawl back up the tree" functionality with exact segment matching + * @param namespace The namespace to search in + * @param identifier The identifier to search for + * @param config Configuration options + * @returns Array of possible matches ranked by relevance + */ +export function findNamespaceItemByEndMatch( + namespace: SQLNamespace, + identifier: string, + config: NamespaceSearchConfig = {}, +): ResolvedNamespaceItem[] { + const results: ResolvedNamespaceItem[] = []; + const { maxDepth = 10 } = config; + + // Recursively search through the namespace + collectAllItems(namespace, [], results, maxDepth); + + // Filter results that have the identifier as an exact segment match anywhere in the path + const { caseSensitive = false } = config; + const matchingResults = results.filter((item) => { + // Check if any segment in the path exactly matches the identifier + return item.path.some((segment) => + caseSensitive ? segment === identifier : segment.toLowerCase() === identifier.toLowerCase(), + ); + }); + + // Sort by path length (shorter paths are more specific/relevant) + // Also prioritize matches where the identifier is at the end of the path + return matchingResults.sort((a, b) => { + const aIsLastSegment = caseSensitive + ? a.path[a.path.length - 1] === identifier + : a.path[a.path.length - 1]?.toLowerCase() === identifier.toLowerCase(); + const bIsLastSegment = caseSensitive + ? b.path[b.path.length - 1] === identifier + : b.path[b.path.length - 1]?.toLowerCase() === identifier.toLowerCase(); + + // Prioritize end matches, then by path length + if (aIsLastSegment && !bIsLastSegment) return -1; + if (!aIsLastSegment && bIsLastSegment) return 1; + return a.path.length - b.path.length; + }); +} + +/** + * Recursively collects all items from a namespace + */ +function collectAllItems( + namespace: SQLNamespace, + currentPath: string[], + results: ResolvedNamespaceItem[], + maxDepth: number, +): void { + if (currentPath.length >= maxDepth) { + return; + } + + if (isObjectNamespace(namespace)) { + for (const [key, value] of Object.entries(namespace)) { + const newPath = [...currentPath, key]; + + if (isSelfChildrenNamespace(value)) { + const semanticType = determineSemanticType( + newPath, + "completion", + value.children, + namespace, + ); + results.push({ + completion: value.self, + path: newPath, + type: "completion", + semanticType, + namespace: value.children, + }); + collectAllItems(value.children, newPath, results, maxDepth); + } else { + const semanticType = determineSemanticType(newPath, "namespace", value, namespace); + results.push({ + path: newPath, + type: "namespace", + semanticType, + namespace: value, + }); + collectAllItems(value, newPath, results, maxDepth); + } + } + } else if (isSelfChildrenNamespace(namespace)) { + const semanticType = determineSemanticType(currentPath, "completion", namespace.children); + results.push({ + completion: namespace.self, + path: currentPath, + type: "completion", + semanticType, + namespace: namespace.children, + }); + collectAllItems(namespace.children, currentPath, results, maxDepth); + } else if (isArrayNamespace(namespace)) { + for (const item of namespace) { + if (typeof item === "string") { + const semanticType = determineSemanticType( + [...currentPath, item], + "string", + undefined, + namespace, + ); + results.push({ + value: item, + path: [...currentPath, item], + type: "string", + semanticType, + }); + } else { + const semanticType = determineSemanticType( + [...currentPath, item.label], + "completion", + undefined, + namespace, + ); + results.push({ + completion: item, + path: [...currentPath, item.label], + type: "completion", + semanticType, + }); + } + } + } +} + +/** + * Gets the most relevant namespace item using the preference order: + * 1. Exact match in SQLNamespace + * 2. Partial/fuzzy match by end identifier + * @param namespace The namespace to search in + * @param identifier The identifier to resolve + * @param config Configuration options + * @returns The best matching item or null if none found + */ +export function resolveNamespaceItem( + namespace: SQLNamespace, + identifier: string, + config: NamespaceSearchConfig = {}, +): ResolvedNamespaceItem | null { + const { enableFuzzySearch = false } = config; + + // First try exact path match + const exactMatch = traverseNamespacePath(namespace, identifier, config); + if (exactMatch) { + return exactMatch; + } + + // Then try prefix completion (for partial typing) + const completions = findNamespaceCompletions(namespace, identifier, config); + if (completions.length > 0) { + // Return the first completion (should be most relevant) + return completions[0] || null; + } + + // Finally try end-match fuzzy search (only if enabled) + if (enableFuzzySearch) { + const endMatches = findNamespaceItemByEndMatch(namespace, identifier, config); + if (endMatches.length > 0) { + return endMatches[0] || null; + } + } + + return null; +} diff --git a/src/sql/parser.ts b/src/sql/parser.ts index eab0d65..aa4cc16 100644 --- a/src/sql/parser.ts +++ b/src/sql/parser.ts @@ -1,18 +1,35 @@ import { Parser } from "node-sql-parser"; +/** + * Represents a SQL parsing error with location information + */ export interface SqlParseError { + /** Error message describing the issue */ message: string; + /** Line number where the error occurred (1-indexed) */ line: number; + /** Column number where the error occurred (1-indexed) */ column: number; + /** Severity level of the error */ severity: "error" | "warning"; } +/** + * Result of parsing a SQL statement + */ export interface SqlParseResult { + /** Whether parsing was successful */ success: boolean; + /** Array of parsing errors, if any */ errors: SqlParseError[]; + /** The parsed AST if successful */ ast?: unknown; } +/** + * A SQL parser wrapper around node-sql-parser with enhanced error handling + * and validation capabilities for CodeMirror integration. + */ export class SqlParser { private parser: Parser; diff --git a/src/sql/structure-analyzer.ts b/src/sql/structure-analyzer.ts index 9501429..fd7dac9 100644 --- a/src/sql/structure-analyzer.ts +++ b/src/sql/structure-analyzer.ts @@ -1,6 +1,9 @@ import type { EditorState } from "@codemirror/state"; import { SqlParser } from "./parser.js"; +/** + * Represents a SQL statement with position information + */ export interface SqlStatement { /** Start position of the statement in the document */ from: number; @@ -18,6 +21,10 @@ export interface SqlStatement { isValid: boolean; } +/** + * Analyzes SQL documents to extract statement boundaries and information + * for use with gutter markers and other SQL-aware features. + */ export class SqlStructureAnalyzer { private parser: SqlParser; private cache = new Map(); diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index cb353a8..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,58 +0,0 @@ -export function invariant(condition: boolean, message: string): asserts condition { - if (!condition) { - throw new Error(message); - } -} - -const modSymbols = { mac: "⌘", windows: "⊞ Win", default: "Ctrl" }; - -export function getModSymbol() { - const isMac = navigator.platform.startsWith("Mac"); - if (isMac) { - return modSymbols.mac; - } - if (navigator.platform.startsWith("Win")) { - return modSymbols.windows; - } - return modSymbols.default; -} - -export function formatKeymap(keymap: string) { - return keymap.replace("Mod", getModSymbol()).replace("-", " ").toUpperCase(); -} - -/** Shortcut for creating elements */ -export function ce( - tag: T, - className: string, -): HTMLElementTagNameMap[T] { - const elem = document.createElement(tag); - elem.className = className; - return elem; -} - -// biome-ignore lint/suspicious/noExplicitAny: ... -export function debouncePromise any>( - fn: T, - wait: number, - abortValue: unknown = undefined, -) { - let cancel = () => { - // do nothing - }; - // type Awaited = T extends PromiseLike ? U : T - type ReturnT = Awaited>; - const wrapFunc = (...args: Parameters): Promise => { - cancel(); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => resolve(fn(...args)), wait); - cancel = () => { - clearTimeout(timer); - if (abortValue !== undefined) { - reject(abortValue); - } - }; - }); - }; - return wrapFunc; -} diff --git a/src/utils/lru.ts b/src/utils/lru.ts deleted file mode 100644 index c564d01..0000000 --- a/src/utils/lru.ts +++ /dev/null @@ -1,46 +0,0 @@ -export class LRUCache { - private maxSize: number; - private cache = new Map(); - - constructor(maxSize: number) { - this.maxSize = maxSize; - } - - public get(key: K): V | undefined { - const item = this.cache.get(key); - if (item !== undefined) { - // re-insert for LRU effect - this.cache.delete(key); - this.cache.set(key, item); - } - return item; - } - - public set(key: K, value: V) { - // if key already in cache, remove it so we move it to the "fresh" position - if (this.cache.has(key)) { - this.cache.delete(key); - } - this.cache.set(key, value); - - // evict oldest - if (this.cache.size > this.maxSize) { - const oldestKey = this.cache.keys().next().value; - if (oldestKey !== undefined) { - this.cache.delete(oldestKey); - } - } - } - - public keys() { - return this.cache.keys(); - } - - public values() { - return this.cache.values(); - } - - public entries() { - return this.cache.entries(); - } -} diff --git a/vite.config.ts b/vite.config.ts index d060dd7..a8a0b35 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,12 @@ export default defineConfig({ test: { environment: "jsdom", watch: false, + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/__tests__/**", "src/debug.ts"], + }, }, base: "/codemirror-sql/", });