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 += ``;
+
+ // 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 += ``;
+ 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 += `
`;
+ for (const [key, value] of Object.entries(info.metadata)) {
+ html += `${value} `;
+ }
+ 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 += ``;
+ 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 += `
`;
+ for (const [key, value] of Object.entries(metadata)) {
+ html += `${value} `;
+ }
+ 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 += ``;
+ 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 += `
Other columns in ${tableName}:
`;
+ const displayColumns = otherColumns.slice(0, 8); // Show max 8 other columns
+ html += displayColumns.map((col) => `${col}`).join(", ");
+
+ if (otherColumns.length > 8) {
+ html += `, and ${otherColumns.length - 8} more...`;
+ }
+ html += `
`;
+ }
+ }
+
+ if (metadata && typeof metadata === "object" && Object.keys(metadata).length > 0) {
+ html += `
`;
+ for (const [key, value] of Object.entries(metadata)) {
+ html += `${value} `;
+ }
+ 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/",
});