From 55a2967b2e3213ac57cef464818b298dcf23d616 Mon Sep 17 00:00:00 2001
From: zhaoge <>
Date: Wed, 22 Jul 2026 15:37:43 +0800
Subject: [PATCH] feat: #206 add configurable SQL formatting with formatSQL API
---
.gitignore | 1 +
README-zh_CN.md | 26 +++
README.md | 26 +++
package.json | 14 +-
pnpm-lock.yaml | 57 +++++-
src/format.test.ts | 255 ++++++++++++++++++++++++++
src/format.ts | 91 +++++++++
src/formatEditor.ts | 88 +++++++++
src/main.ts | 2 +
src/monaco.contribution.ts | 38 +++-
src/setupLanguageFeatures.ts | 43 ++++-
src/setupLanguageMode.ts | 20 +-
test/all.js | 14 +-
tsconfig.esm.json | 3 +-
website/src/extensions/main/index.tsx | 27 ++-
website/src/languages/index.ts | 22 ++-
16 files changed, 712 insertions(+), 15 deletions(-)
create mode 100644 src/format.test.ts
create mode 100644 src/format.ts
create mode 100644 src/formatEditor.ts
diff --git a/.gitignore b/.gitignore
index 193cb599..6c298258 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
/node_modules/
/out/
/esm/
+/esm-dev/
/release/
.history
/docs/
diff --git a/README-zh_CN.md b/README-zh_CN.md
index fec76452..e1f09887 100644
--- a/README-zh_CN.md
+++ b/README-zh_CN.md
@@ -162,6 +162,32 @@ npm install monaco-sql-languages
});
```
+4. **SQL 格式化(可选)**
+
+ 格式化依赖 [`sql-formatter`](https://github.com/sql-formatter-org/sql-formatter) 作为**可选 peer 依赖**。`format.enable: true` 时,内置路径按需 dynamic import **消费者项目中安装的** sql-formatter 版本(建议较新版本,如 v15+)。
+
+ ```bash
+ npm install sql-formatter
+ ```
+
+ **优先级:** 内置 sql-formatter → `fallback`(不可用或抛错)→ 原文。
+
+ ```typescript
+ import { LanguageIdEnum, setupLanguageFeatures } from 'monaco-sql-languages';
+
+ setupLanguageFeatures(LanguageIdEnum.MYSQL, {
+ format: {
+ enable: true,
+ tabWidth: 4,
+ fallback: (code, languageId) => myLegacyFormat(code, languageId)
+ }
+ });
+ ```
+
+ > **版本说明:** 库 dev/测试使用较新版本(如 v15)。Chrome 75 等旧环境可在业务项目中 pin `sql-formatter@10.7.2`,内置路径会使用该版本;`fallback` 可用于 `${param}` 等业务逻辑。
+
+ 编辑器快捷键:**Ctrl/Cmd+Alt+F**(右键 **Format**)。若格式化结果与原文相同,编辑器不会应用 edits。
+
## 代码片段
diff --git a/README.md b/README.md
index e7264c32..3298d626 100644
--- a/README.md
+++ b/README.md
@@ -162,6 +162,32 @@ npm install monaco-sql-languages
});
```
+4. **SQL Formatting (optional)**
+
+ Formatting uses [`sql-formatter`](https://github.com/sql-formatter-org/sql-formatter) as an **optional peer dependency**. When `format.enable` is true, the built-in path dynamically imports whatever `sql-formatter` version the consumer installed (recommended: latest major, e.g. v15+).
+
+ ```bash
+ npm install sql-formatter
+ ```
+
+ **Priority:** built-in sql-formatter → `fallback` (unavailable or throw) → original code.
+
+ ```typescript
+ import { LanguageIdEnum, setupLanguageFeatures } from 'monaco-sql-languages';
+
+ setupLanguageFeatures(LanguageIdEnum.MYSQL, {
+ format: {
+ enable: true,
+ tabWidth: 4,
+ fallback: (code, languageId) => myLegacyFormat(code, languageId)
+ }
+ });
+ ```
+
+ > **Version note:** The library dev/tests use a recent sql-formatter (e.g. v15). Consumers on legacy browsers (e.g. Chrome 75) can pin `sql-formatter@10.7.2` in their own project; formatting uses that installed version via the built-in path, with optional `fallback` for custom logic.
+
+ Editor shortcut: **Ctrl/Cmd+Alt+F** (context menu **Format**). When formatting returns unchanged text, the editor applies no edit.
+
## SQL Snippets
diff --git a/package.json b/package.json
index d11e05ea..9025c8e2 100644
--- a/package.json
+++ b/package.json
@@ -1,14 +1,15 @@
{
"name": "monaco-sql-languages",
- "version": "1.2.0",
+ "version": "1.2.1-beta.2",
"description": "SQL languages for the Monaco Editor, based on monaco-languages.",
"scripts": {
"prepublishOnly": "npm run build",
"build": "rm -rf ./esm && tsc -p ./tsconfig.esm.json",
+ "build:dev": "rm -rf ./esm-dev && tsc -p ./tsconfig.esm.json --outDir esm-dev",
"build-amd": "rm -rf ./out && tsc -p ./tsconfig.amd.json",
"watch-esm": "tsc -p ./tsconfig.esm.json --watch",
"test": "npm run build-amd && mocha ./test/all.js",
- "dev": "node --max_old_space_size=4092 & cd website && npm run dev",
+ "dev": "npm run build:dev && node --max_old_space_size=4092 & cd website && npm run dev",
"prod": "rm -rf ./docs && node --max_old_space_size=4092 & cd website && npm run build",
"deploy": "npm run build && npm run prod && gh-pages -d docs -r git@github.com:DTStack/monaco-sql-languages.git",
"format": "prettier --write .",
@@ -58,6 +59,7 @@
"mocha": "^11.7.4",
"monaco-editor": "0.54.0",
"prettier": "^3.3.3",
+ "sql-formatter": "^15.8.2",
"pretty-quick": "^4.0.0",
"requirejs": "^2.3.6",
"simple-git-hooks": "^2.11.1",
@@ -68,7 +70,13 @@
"pre-commit": "npx pretty-quick --staged"
},
"peerDependencies": {
- "monaco-editor": ">=0.37.1"
+ "monaco-editor": ">=0.37.1",
+ "sql-formatter": ">=4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "sql-formatter": {
+ "optional": true
+ }
},
"engines": {
"node": ">=18"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4b1075c4..a885130d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -60,6 +60,9 @@ importers:
simple-git-hooks:
specifier: ^2.11.1
version: 2.11.1
+ sql-formatter:
+ specifier: ^15.8.2
+ version: 15.8.2
standard-version:
specifier: ^9.5.0
version: 9.5.0
@@ -701,6 +704,9 @@ packages:
resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
engines: {node: '>=0.3.1'}
+ discontinuous-range@1.0.0:
+ resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
+
domexception@2.0.1:
resolution: {integrity: sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==}
engines: {node: '>=8'}
@@ -1317,6 +1323,9 @@ packages:
monaco-editor@0.54.0:
resolution: {integrity: sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==}
+ moo@0.5.3:
+ resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
+
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -1330,6 +1339,10 @@ packages:
mute-stream@0.0.8:
resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
+ nearley@2.20.1:
+ resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
+ hasBin: true
+
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -1528,6 +1541,13 @@ packages:
resolution: {integrity: sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==}
engines: {node: '>=8'}
+ railroad-diagrams@1.0.0:
+ resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
+
+ randexp@0.4.6:
+ resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
+ engines: {node: '>=0.12'}
+
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
@@ -1602,6 +1622,10 @@ packages:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
+ ret@0.1.15:
+ resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
+ engines: {node: '>=0.12'}
+
reusify@1.0.4:
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -1691,6 +1715,10 @@ packages:
split@1.0.1:
resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==}
+ sql-formatter@15.8.2:
+ resolution: {integrity: sha512-kTYRg5FIcvsDtYUG2Qn9pYT6xKwiLJN5TTIvc5Mur6hIg4pSfdpHu8Yyu5bqESLHnVM3mXzD446cb2+uEaKZXg==}
+ hasBin: true
+
standard-version@9.5.0:
resolution: {integrity: sha512-3zWJ/mmZQsOaO+fOlsa0+QK90pwhNd042qEcw6hKFNoLFs7peGyvPffpEBbK/DSGPbyOvli0mUIFv5A4qTjh2Q==}
engines: {node: '>=10'}
@@ -2092,7 +2120,7 @@ snapshots:
'@types/node': 20.5.1
chalk: 4.1.2
cosmiconfig: 8.3.6(typescript@5.5.4)
- cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.5.4))(ts-node@10.9.2(@types/node@20.14.14)(typescript@5.5.4))(typescript@5.5.4)
+ cosmiconfig-typescript-loader: 4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.5.4))(ts-node@10.9.2(@types/node@20.5.1)(typescript@5.5.4))(typescript@5.5.4)
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
lodash.uniq: 4.5.0
@@ -2609,7 +2637,7 @@ snapshots:
core-util-is@1.0.3: {}
- cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.5.4))(ts-node@10.9.2(@types/node@20.14.14)(typescript@5.5.4))(typescript@5.5.4):
+ cosmiconfig-typescript-loader@4.4.0(@types/node@20.5.1)(cosmiconfig@8.3.6(typescript@5.5.4))(ts-node@10.9.2(@types/node@20.5.1)(typescript@5.5.4))(typescript@5.5.4):
dependencies:
'@types/node': 20.5.1
cosmiconfig: 8.3.6(typescript@5.5.4)
@@ -2724,6 +2752,8 @@ snapshots:
diff@7.0.0: {}
+ discontinuous-range@1.0.0: {}
+
domexception@2.0.1:
dependencies:
webidl-conversions: 5.0.0
@@ -3386,6 +3416,8 @@ snapshots:
dompurify: 3.1.7
marked: 14.0.0
+ moo@0.5.3: {}
+
mri@1.2.0: {}
ms@2.1.2: {}
@@ -3394,6 +3426,13 @@ snapshots:
mute-stream@0.0.8: {}
+ nearley@2.20.1:
+ dependencies:
+ commander: 2.20.3
+ moo: 0.5.3
+ railroad-diagrams: 1.0.0
+ randexp: 0.4.6
+
neo-async@2.6.2: {}
normalize-package-data@2.5.0:
@@ -3564,6 +3603,13 @@ snapshots:
quick-lru@4.0.1: {}
+ railroad-diagrams@1.0.0: {}
+
+ randexp@0.4.6:
+ dependencies:
+ discontinuous-range: 1.0.0
+ ret: 0.1.15
+
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
@@ -3647,6 +3693,8 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
+ ret@0.1.15: {}
+
reusify@1.0.4: {}
run-async@2.4.1: {}
@@ -3719,6 +3767,11 @@ snapshots:
dependencies:
through: 2.3.8
+ sql-formatter@15.8.2:
+ dependencies:
+ argparse: 2.0.1
+ nearley: 2.20.1
+
standard-version@9.5.0:
dependencies:
chalk: 2.4.2
diff --git a/src/format.test.ts b/src/format.test.ts
new file mode 100644
index 00000000..b43745cb
--- /dev/null
+++ b/src/format.test.ts
@@ -0,0 +1,255 @@
+import * as assert from 'assert';
+
+import { LanguageIdEnum } from './common/constants';
+import { formatSQL, __configureSqlFormatterLoaderForTests } from './format';
+import { formatEditorSQL, type FormatTargetEditor, type TextRange } from './formatEditor';
+
+/** Invalid input that makes sql-formatter throw a parse error. */
+const INVALID_SQL = '@@@!!!';
+
+test('formatSQL formats valid mysql sql', async () => {
+ const input = 'select id,name from users where id=1';
+ const result = await formatSQL(input, LanguageIdEnum.MYSQL);
+ assert.notStrictEqual(result, input);
+ assert.ok(result.toLowerCase().includes('select'));
+ assert.ok(result.toLowerCase().includes('from'));
+});
+
+test('formatSQL formats dialects with dedicated sql-formatter languages', async () => {
+ const input = 'select a from t';
+ const dialects = [
+ LanguageIdEnum.MYSQL,
+ LanguageIdEnum.PG,
+ LanguageIdEnum.SPARK,
+ LanguageIdEnum.HIVE,
+ LanguageIdEnum.TRINO
+ ];
+ for (const languageId of dialects) {
+ const result = await formatSQL(input, languageId);
+ assert.ok(result.toLowerCase().includes('select'), languageId);
+ assert.ok(result.toLowerCase().includes('from'), languageId);
+ }
+});
+
+test('formatSQL formats dialects mapped to standard sql', async () => {
+ const input = 'select a from t';
+ for (const languageId of [
+ LanguageIdEnum.FLINK,
+ LanguageIdEnum.IMPALA,
+ LanguageIdEnum.GENERIC
+ ]) {
+ const result = await formatSQL(input, languageId);
+ assert.ok(result.toLowerCase().includes('select'), languageId);
+ }
+});
+
+test('formatSQL falls back to standard sql for unknown languageId', async () => {
+ const input = 'select a from t';
+ const result = await formatSQL(input, 'unknown-sql');
+ assert.ok(result.toLowerCase().includes('select'));
+});
+
+test('formatSQL uses async fallback when formatting fails', async () => {
+ const fallbackResult = 'SELECT 1';
+ const result = await formatSQL(INVALID_SQL, LanguageIdEnum.MYSQL, {
+ fallback: async () => fallbackResult
+ });
+ assert.strictEqual(result, fallbackResult);
+});
+
+test('formatSQL uses sync fallback when formatting fails', async () => {
+ const fallbackResult = 'SELECT 2';
+ const result = await formatSQL(INVALID_SQL, LanguageIdEnum.MYSQL, {
+ fallback: () => fallbackResult
+ });
+ assert.strictEqual(result, fallbackResult);
+});
+
+test('formatSQL passes code and languageId to fallback', async () => {
+ let receivedCode = '';
+ let receivedLanguageId = '';
+ await formatSQL(INVALID_SQL, LanguageIdEnum.PG, {
+ fallback: (code, languageId) => {
+ receivedCode = code;
+ receivedLanguageId = languageId;
+ return 'SELECT 1';
+ }
+ });
+ assert.strictEqual(receivedCode, INVALID_SQL);
+ assert.strictEqual(receivedLanguageId, LanguageIdEnum.PG);
+});
+
+test('formatSQL returns original code when formatting fails without fallback', async () => {
+ const result = await formatSQL(INVALID_SQL, LanguageIdEnum.MYSQL);
+ assert.strictEqual(result, INVALID_SQL);
+});
+
+test('formatSQL returns original code when fallback also fails', async () => {
+ const result = await formatSQL(INVALID_SQL, LanguageIdEnum.MYSQL, {
+ fallback: () => {
+ throw new Error('fallback failed');
+ }
+ });
+ assert.strictEqual(result, INVALID_SQL);
+});
+
+test('formatSQL returns empty string for empty input', async () => {
+ const result = await formatSQL('', LanguageIdEnum.MYSQL);
+ assert.strictEqual(result, '');
+});
+
+test('formatSQL uses fallback when sql-formatter is unavailable', async () => {
+ __configureSqlFormatterLoaderForTests(async () => null);
+ try {
+ const result = await formatSQL('select 1', LanguageIdEnum.MYSQL, {
+ fallback: () => 'FROM FALLBACK'
+ });
+ assert.strictEqual(result, 'FROM FALLBACK');
+ } finally {
+ __configureSqlFormatterLoaderForTests(undefined);
+ }
+});
+
+test('formatSQL passes tabWidth to sql-formatter', async () => {
+ __configureSqlFormatterLoaderForTests(async () => ({
+ format: (_query, cfg) => String(cfg?.tabWidth ?? '')
+ }));
+ try {
+ const result = await formatSQL('select 1', LanguageIdEnum.MYSQL, { tabWidth: 2 });
+ assert.strictEqual(result, '2');
+ const defaultResult = await formatSQL('select 1', LanguageIdEnum.MYSQL);
+ assert.strictEqual(defaultResult, '4');
+ } finally {
+ __configureSqlFormatterLoaderForTests(undefined);
+ }
+});
+
+function createMockEditor(options: {
+ code: string;
+ languageId?: string;
+ selection?: (TextRange & { isEmpty(): boolean }) | null;
+}): FormatTargetEditor & {
+ edits: Array<{ range: TextRange; text: string }>;
+ undoStopCount: number;
+} {
+ const { code, languageId = LanguageIdEnum.MYSQL, selection = null } = options;
+ const fullRange: TextRange = {
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: 1,
+ endColumn: Math.max(code.length, 0) + 1
+ };
+ const edits: Array<{ range: TextRange; text: string }> = [];
+ let undoStopCount = 0;
+
+ return {
+ edits,
+ get undoStopCount() {
+ return undoStopCount;
+ },
+ getModel: () => ({
+ getLanguageId: () => languageId,
+ getValueInRange: (range) => {
+ if (
+ range.startLineNumber === fullRange.startLineNumber &&
+ range.startColumn === fullRange.startColumn &&
+ range.endLineNumber === fullRange.endLineNumber &&
+ range.endColumn === fullRange.endColumn
+ ) {
+ return code;
+ }
+ // Approximate single-line slice for selection tests
+ return code.slice(range.startColumn - 1, range.endColumn - 1);
+ },
+ getFullModelRange: () => fullRange
+ }),
+ getSelection: () => selection,
+ pushUndoStop: () => {
+ undoStopCount += 1;
+ },
+ executeEdits: (_source, nextEdits) => {
+ edits.push(...nextEdits);
+ }
+ };
+}
+
+test('formatEditorSQL formats whole document when selection is empty', async () => {
+ const input = 'select id,name from users';
+ const editor = createMockEditor({
+ code: input,
+ selection: {
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: 1,
+ endColumn: 1,
+ isEmpty: () => true
+ }
+ });
+
+ await formatEditorSQL(editor);
+
+ assert.strictEqual(editor.edits.length, 1);
+ assert.notStrictEqual(editor.edits[0].text, input);
+ assert.ok(editor.edits[0].text.toLowerCase().includes('select'));
+ assert.strictEqual(editor.undoStopCount, 2);
+});
+
+test('formatEditorSQL formats selection when present', async () => {
+ const selected = 'select id,name from users';
+ const editor = createMockEditor({
+ code: selected,
+ selection: {
+ startLineNumber: 1,
+ startColumn: 1,
+ endLineNumber: 1,
+ endColumn: selected.length + 1,
+ isEmpty: () => false
+ }
+ });
+
+ await formatEditorSQL(editor);
+
+ assert.strictEqual(editor.edits.length, 1);
+ assert.notStrictEqual(editor.edits[0].text, selected);
+ assert.ok(editor.edits[0].text.toLowerCase().includes('from'));
+});
+
+test('formatEditorSQL no-ops when model is missing', async () => {
+ const edits: Array<{ range: TextRange; text: string }> = [];
+ const editor: FormatTargetEditor = {
+ getModel: () => null,
+ getSelection: () => null,
+ pushUndoStop: () => {
+ throw new Error('should not push undo stop');
+ },
+ executeEdits: () => {
+ throw new Error('should not execute edits');
+ }
+ };
+
+ await formatEditorSQL(editor);
+ assert.strictEqual(edits.length, 0);
+});
+
+test('formatEditorSQL no-ops when selected text is empty', async () => {
+ const editor = createMockEditor({ code: '' });
+ await formatEditorSQL(editor);
+ assert.strictEqual(editor.edits.length, 0);
+ assert.strictEqual(editor.undoStopCount, 0);
+});
+
+test('formatEditorSQL no-ops when formatted result equals input', async () => {
+ const editor = createMockEditor({ code: INVALID_SQL });
+ await formatEditorSQL(editor);
+ assert.strictEqual(editor.edits.length, 0);
+ assert.strictEqual(editor.undoStopCount, 0);
+});
+
+test('formatEditorSQL uses fallback option when formatting fails', async () => {
+ const editor = createMockEditor({ code: INVALID_SQL });
+ await formatEditorSQL(editor, {
+ fallback: () => 'SELECT 1'
+ });
+ assert.strictEqual(editor.edits.length, 1);
+ assert.strictEqual(editor.edits[0].text, 'SELECT 1');
+});
diff --git a/src/format.ts b/src/format.ts
new file mode 100644
index 00000000..4017ab7e
--- /dev/null
+++ b/src/format.ts
@@ -0,0 +1,91 @@
+import { LanguageIdEnum } from './common/constants';
+import type { FormatSQLOptions } from './monaco.contribution';
+
+/**
+ * Fallback formatter when sql-formatter is unavailable or throws.
+ */
+export type FormatFallback = (code: string, languageId: string) => string | Promise;
+
+const languageIdToSqlLanguage: Record = {
+ [LanguageIdEnum.MYSQL]: 'mysql',
+ [LanguageIdEnum.PG]: 'postgresql',
+ [LanguageIdEnum.SPARK]: 'spark',
+ [LanguageIdEnum.HIVE]: 'hive',
+ [LanguageIdEnum.TRINO]: 'trino',
+ [LanguageIdEnum.FLINK]: 'sql',
+ [LanguageIdEnum.IMPALA]: 'sql',
+ [LanguageIdEnum.GENERIC]: 'sql'
+};
+
+type SqlFormatterModule = {
+ format: (query: string, cfg?: Record) => string;
+};
+
+let sqlFormatterModule: SqlFormatterModule | null | undefined;
+
+type SqlFormatterLoader = () => Promise;
+
+let sqlFormatterLoaderOverride: SqlFormatterLoader | undefined;
+
+/** @internal Resets cache and optional loader override (unit tests only). */
+export function __configureSqlFormatterLoaderForTests(loader?: SqlFormatterLoader): void {
+ sqlFormatterLoaderOverride = loader;
+ sqlFormatterModule = undefined;
+}
+
+async function loadSqlFormatter(): Promise {
+ if (sqlFormatterLoaderOverride) {
+ return sqlFormatterLoaderOverride();
+ }
+ if (sqlFormatterModule !== undefined) {
+ return sqlFormatterModule;
+ }
+ try {
+ // Let bundlers (webpack/vite) emit an async chunk when sql-formatter is installed.
+ // Do not use webpackIgnore — bare dynamic import fails in browser without a bundler.
+ const mod = (await import('sql-formatter')) as SqlFormatterModule;
+ sqlFormatterModule = mod;
+ } catch {
+ sqlFormatterModule = null;
+ }
+ return sqlFormatterModule ?? null;
+}
+
+const DEFAULT_TAB_WIDTH = 4;
+
+/**
+ * Format SQL with sql-formatter (optional peer dependency, loaded on demand).
+ * Uses whichever `sql-formatter` version the consumer installed (peer dependency).
+ * Priority: built-in sql-formatter → fallback → original code.
+ */
+export async function formatSQL(
+ code: string,
+ languageId: string,
+ options?: FormatSQLOptions
+): Promise {
+ if (!code) {
+ return code;
+ }
+
+ const sqlFormatter = await loadSqlFormatter();
+ if (sqlFormatter) {
+ try {
+ return sqlFormatter.format(code, {
+ language: languageIdToSqlLanguage[languageId] ?? 'sql',
+ tabWidth: options?.tabWidth ?? DEFAULT_TAB_WIDTH
+ });
+ } catch {
+ // fall through to fallback
+ }
+ }
+
+ if (options?.fallback) {
+ try {
+ return await options.fallback(code, languageId);
+ } catch {
+ // ignore
+ }
+ }
+
+ return code;
+}
diff --git a/src/formatEditor.ts b/src/formatEditor.ts
new file mode 100644
index 00000000..eef14cee
--- /dev/null
+++ b/src/formatEditor.ts
@@ -0,0 +1,88 @@
+import { editor, IDisposable, KeyCode, KeyMod, Range } from './fillers/monaco-editor-core';
+import { formatSQL } from './format';
+import type { FormatSQLOptions, LanguageServiceDefaults } from './monaco.contribution';
+
+/**
+ * Plain text range used by {@link FormatTargetEditor}.
+ */
+export interface TextRange {
+ startLineNumber: number;
+ startColumn: number;
+ endLineNumber: number;
+ endColumn: number;
+}
+
+/**
+ * Minimal editor surface required by {@link formatEditorSQL}.
+ * Kept structural so consumers are not tied to a specific monaco-editor minor version.
+ */
+export interface FormatTargetEditor {
+ getModel(): {
+ getLanguageId(): string;
+ getValueInRange(range: TextRange): string;
+ getFullModelRange(): TextRange;
+ } | null;
+ getSelection(): (TextRange & { isEmpty(): boolean }) | null;
+ pushUndoStop(): void;
+ executeEdits(
+ source: string | null | undefined,
+ edits: Array<{ range: TextRange; text: string }>
+ ): unknown;
+}
+
+/**
+ * Apply format to the current editor: selection if present, otherwise the whole document.
+ * No edit is applied when the formatted result equals the input (unchanged or all format paths failed).
+ */
+export async function formatEditorSQL(
+ codeEditor: FormatTargetEditor,
+ options?: FormatSQLOptions
+): Promise {
+ const model = codeEditor.getModel();
+ if (!model) {
+ return;
+ }
+ const selection = codeEditor.getSelection();
+ const range =
+ selection && !selection.isEmpty() ? Range.lift(selection) : model.getFullModelRange();
+ const code = model.getValueInRange(range);
+ if (!code) {
+ return;
+ }
+ const formatted = await formatSQL(code, model.getLanguageId(), options);
+ if (formatted === code) {
+ return;
+ }
+ codeEditor.pushUndoStop();
+ codeEditor.executeEdits('format', [{ range, text: formatted }]);
+ codeEditor.pushUndoStop();
+}
+
+/**
+ * Registers a single context-menu "Format" action for the language.
+ * Formats selection when present; otherwise formats the whole document.
+ * Shortcut: Ctrl/Cmd+Alt+F (avoids Monaco's default Shift+Alt+F Format Document).
+ */
+const defaultFormatKeybindings = [KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyF];
+
+function getFormatSQLOptions(defaults: LanguageServiceDefaults): FormatSQLOptions {
+ const { fallback, tabWidth } = defaults.modeConfiguration.format;
+ return { fallback, tabWidth };
+}
+
+export function registerFormatAction(defaults: LanguageServiceDefaults): IDisposable {
+ const { languageId } = defaults;
+ const formatOptions = defaults.modeConfiguration.format;
+ const keybindings = formatOptions.keybindings ?? defaultFormatKeybindings;
+ return editor.addEditorAction({
+ id: `monaco-sql-languages.format.${languageId}`,
+ label: 'Format',
+ precondition: `editorLangId == '${languageId}'`,
+ contextMenuGroupId: '1_modification',
+ contextMenuOrder: 1.5,
+ keybindings,
+ run: async (codeEditor) => {
+ await formatEditorSQL(codeEditor, getFormatSQLOptions(defaults));
+ }
+ });
+}
diff --git a/src/main.ts b/src/main.ts
index 900243c0..b31efd49 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,6 +5,8 @@ export * from './setupLanguageFeatures';
export * from './common/constants';
export * from './theme';
export * as snippets from './snippets';
+export { formatSQL, type FormatFallback } from './format';
+export { formatEditorSQL, type FormatTargetEditor, type TextRange } from './formatEditor';
export { EntityContextType, StmtContextType } from 'dt-sql-parser';
diff --git a/src/monaco.contribution.ts b/src/monaco.contribution.ts
index f1dca65c..e8cd57fe 100644
--- a/src/monaco.contribution.ts
+++ b/src/monaco.contribution.ts
@@ -1,7 +1,10 @@
import { EntityContext, Suggestions } from 'dt-sql-parser';
+import type { FormatFallback } from './format';
import { editor, Emitter, IEvent, IRange, languages, Position } from './fillers/monaco-editor-core';
+export type { FormatFallback } from './format';
+
/**
* A completion item.
* ICompletionItem is pretty much the same as {@link languages.CompletionItem},
@@ -58,6 +61,30 @@ export interface CompletionSnippet {
export type CompletionSnippetOption = Omit;
+export interface FormatOptions {
+ /**
+ * Defines whether the built-in Format action is enabled.
+ * Defaults to false.
+ */
+ enable: boolean;
+ /**
+ * Called when sql-formatter is unavailable or throws. Should return formatted SQL.
+ */
+ fallback?: FormatFallback;
+ /**
+ * Indent width passed to sql-formatter. Defaults to 4.
+ */
+ tabWidth?: number;
+ /**
+ * Custom keybindings for the Format action.
+ * Defaults to Ctrl/Cmd+Alt+F.
+ */
+ keybindings?: number[];
+}
+
+/** Runtime options passed to {@link formatSQL} / {@link formatEditorSQL}. */
+export type FormatSQLOptions = Pick;
+
export interface ModeConfiguration {
/**
* Defines whether the built-in completionItemProvider is enabled.
@@ -94,6 +121,12 @@ export interface ModeConfiguration {
* Defines whether the built-in hover provider is enabled.
*/
readonly hover?: boolean;
+ /**
+ * Defines whether the built-in Format action is enabled.
+ * Defaults to false.
+ * When enabled, a single "Format" item is added to the editor context menu.
+ */
+ readonly format: FormatOptions;
}
/**
@@ -211,5 +244,8 @@ export const modeConfigurationDefault: Required = {
diagnostics: true,
definitions: false,
references: false,
- hover: false
+ hover: false,
+ format: {
+ enable: false
+ }
};
diff --git a/src/setupLanguageFeatures.ts b/src/setupLanguageFeatures.ts
index 3ef5ef18..dec27627 100644
--- a/src/setupLanguageFeatures.ts
+++ b/src/setupLanguageFeatures.ts
@@ -2,6 +2,7 @@ import { LanguageIdEnum } from './common/constants';
import { IDisposable, languages } from './fillers/monaco-editor-core';
import {
CompletionOptions,
+ FormatOptions,
LanguageServiceDefaults,
LanguageServiceDefaultsImpl,
ModeConfiguration,
@@ -33,6 +34,13 @@ export interface FeatureConfiguration {
* Defines whether the built-in hover provider is enabled.
*/
hover?: boolean;
+ /**
+ * Defines whether the built-in Format action is enabled.
+ * Defaults to false.
+ * When enabled, a single "Format" item is added to the editor context menu:
+ * formats the selection when present, otherwise formats the whole document.
+ */
+ format?: boolean | Partial;
/**
* Define a function to preprocess code.
* By default, do not something.
@@ -166,6 +174,33 @@ function processConfiguration(
: (defaults?.modeConfiguration.completionItems.snippets ??
getDefaultSnippets(languageId));
+ const formatPartial =
+ configuration.format != null && typeof configuration.format !== 'boolean'
+ ? configuration.format
+ : undefined;
+
+ const formatEnable =
+ typeof configuration.format === 'boolean'
+ ? configuration.format
+ : (formatPartial?.enable ??
+ defaults?.modeConfiguration.format.enable ??
+ modeConfigurationDefault.format.enable);
+
+ const formatFallback =
+ typeof formatPartial?.fallback === 'function'
+ ? formatPartial.fallback
+ : defaults?.modeConfiguration.format.fallback;
+
+ const formatTabWidth =
+ typeof formatPartial?.tabWidth === 'number'
+ ? formatPartial.tabWidth
+ : defaults?.modeConfiguration.format.tabWidth;
+
+ const formatKeybindings =
+ formatPartial != null && Array.isArray(formatPartial.keybindings)
+ ? formatPartial.keybindings
+ : defaults?.modeConfiguration.format.keybindings;
+
return {
diagnostics,
completionItems: {
@@ -176,6 +211,12 @@ function processConfiguration(
},
references,
definitions,
- hover
+ hover,
+ format: {
+ enable: formatEnable,
+ fallback: formatFallback,
+ tabWidth: formatTabWidth,
+ keybindings: formatKeybindings
+ }
};
}
diff --git a/src/setupLanguageMode.ts b/src/setupLanguageMode.ts
index e61e32b1..c68f4dd4 100644
--- a/src/setupLanguageMode.ts
+++ b/src/setupLanguageMode.ts
@@ -9,6 +9,7 @@ export function setupLanguageMode(
): IDisposable {
const disposables: IDisposable[] = [];
const providers: IDisposable[] = [];
+ let disposed = false;
const client = new WorkerManager(defaults);
disposables.push(client);
@@ -59,13 +60,30 @@ export function setupLanguageMode(
)
);
}
+
+ if (modeConfiguration.format.enable) {
+ void import('./formatEditor').then(({ registerFormatAction }) => {
+ if (disposed) {
+ return;
+ }
+ providers.push(registerFormatAction(defaults));
+ });
+ }
}
registerProviders();
disposables.push(asDisposable(providers));
- return asDisposable(disposables);
+ return {
+ dispose: () => {
+ if (disposed) {
+ return;
+ }
+ disposed = true;
+ disposeAll(disposables);
+ }
+ };
}
function asDisposable(disposables: IDisposable[]): IDisposable {
diff --git a/test/all.js b/test/all.js
index 6158c5e3..dbed9933 100644
--- a/test/all.js
+++ b/test/all.js
@@ -103,10 +103,16 @@ requirejs(
function () {
let files;
try {
- files = glob.sync('out/amd/languages/*/*.test.js', {
- cwd: path.dirname(__dirname),
- dot: true
- });
+ files = [
+ ...glob.sync('out/amd/languages/*/*.test.js', {
+ cwd: path.dirname(__dirname),
+ dot: true
+ }),
+ ...glob.sync('out/amd/format.test.js', {
+ cwd: path.dirname(__dirname),
+ dot: true
+ })
+ ];
} catch (err) {
console.log(err);
return;
diff --git a/tsconfig.esm.json b/tsconfig.esm.json
index 4a60c9f1..5923c23a 100644
--- a/tsconfig.esm.json
+++ b/tsconfig.esm.json
@@ -6,5 +6,6 @@
"target": "es6",
"noEmit": false,
"declaration": true
- }
+ },
+ "exclude": ["**/*.test.ts", "src/test/**"]
}
diff --git a/website/src/extensions/main/index.tsx b/website/src/extensions/main/index.tsx
index a554ea4e..d1ed6336 100644
--- a/website/src/extensions/main/index.tsx
+++ b/website/src/extensions/main/index.tsx
@@ -1,6 +1,6 @@
import { ParseError } from 'dt-sql-parser';
import * as monaco from 'monaco-editor';
-import { vsPlusTheme } from 'monaco-sql-languages/esm/main';
+import { formatEditorSQL, vsPlusTheme } from 'monaco-sql-languages/esm/main';
import { LanguageService, type SerializedTreeNode } from 'monaco-sql-languages/esm/languageService';
import TreeVisualizerPanel from '@/components/treeVisualizerPanel';
@@ -14,6 +14,7 @@ import {
QUICK_GITHUB,
SQL_LANGUAGES
} from '@/consts';
+import { formatFallback } from '@/languages';
import { debounce } from '@/utils/tool';
import ApiDocPage from '@/workbench/apiDocPage';
import { ProblemsPaneView } from '@/workbench/problems';
@@ -32,6 +33,8 @@ import {
TabGroup
} from '@dtinsight/molecule';
+const FORMAT_TOOLBAR_ID = 'editor.toolbar.formatSql';
+
const problemsService = new ProblemsService();
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -348,6 +351,28 @@ export const mainExt: IExtension = {
molecule.activityBar.setCurrent(ACTIVITY_FOLDER);
molecule.sidebar.setCurrent(ACTIVITY_FOLDER);
+ molecule.editor.addToolbars([
+ {
+ id: FORMAT_TOOLBAR_ID,
+ name: 'Format',
+ title: 'Format',
+ icon: 'symbol-misc',
+ group: 'inline',
+ sortIndex: 1
+ }
+ ]);
+
+ molecule.editor.onToolbarClick(async (item, groupId) => {
+ if (item.id !== FORMAT_TOOLBAR_ID) return;
+ const group = molecule.editor.getGroup(groupId) ?? molecule.editor.getCurrentGroup();
+ const editorInstance = group?.editorInstance;
+ if (!editorInstance) return;
+ // Cast across monaco-editor minor version differences in the demo deps.
+ await formatEditorSQL(editorInstance as Parameters[0], {
+ fallback: formatFallback
+ });
+ });
+
molecule.editor.onCurrentChange((tab) => {
const language = (tab.tabId as string)?.split('_')?.[0];
const groups = molecule.editor.getGroups();
diff --git a/website/src/languages/index.ts b/website/src/languages/index.ts
index 685edca9..8348eb8e 100644
--- a/website/src/languages/index.ts
+++ b/website/src/languages/index.ts
@@ -2,10 +2,22 @@ import 'monaco-sql-languages/esm/all.contributions.js';
import './languageWorker';
import './theme';
-import { LanguageIdEnum, setupLanguageFeatures } from 'monaco-sql-languages/esm/main.js';
+import {
+ type FormatFallback,
+ LanguageIdEnum,
+ setupLanguageFeatures
+} from 'monaco-sql-languages/esm/main.js';
import { completionService } from './helpers/completionService';
+/** Demo fallback when sql-formatter fails; shared by feature setup and toolbar. */
+export const formatFallback: FormatFallback = (code) => `-- formatted by demo fallback\n${code}`;
+
+const formatFeatureOptions = {
+ enable: true,
+ fallback: formatFallback
+} as const;
+
/**
* replace dtstack custom params, eg: @@{componentParams}, ${taskCustomParams}
* @param code editor value
@@ -69,6 +81,7 @@ setupLanguageFeatures(LanguageIdEnum.FLINK, {
references: true,
definitions: true,
hover: true,
+ format: formatFeatureOptions,
preprocessCode
});
@@ -80,6 +93,7 @@ setupLanguageFeatures(LanguageIdEnum.SPARK, {
references: true,
definitions: true,
hover: true,
+ format: formatFeatureOptions,
preprocessCode
});
@@ -91,6 +105,7 @@ setupLanguageFeatures(LanguageIdEnum.HIVE, {
references: true,
definitions: true,
hover: true,
+ format: formatFeatureOptions,
preprocessCode: (code: string) => preprocessCodeHive(code, '`')
});
@@ -102,6 +117,7 @@ setupLanguageFeatures(LanguageIdEnum.MYSQL, {
references: true,
definitions: true,
hover: true,
+ format: formatFeatureOptions,
preprocessCode
});
@@ -113,6 +129,7 @@ setupLanguageFeatures(LanguageIdEnum.TRINO, {
references: true,
definitions: true,
hover: true,
+ format: formatFeatureOptions,
preprocessCode
});
@@ -124,6 +141,7 @@ setupLanguageFeatures(LanguageIdEnum.PG, {
references: true,
definitions: true,
hover: true,
+ format: formatFeatureOptions,
preprocessCode
});
@@ -135,6 +153,7 @@ setupLanguageFeatures(LanguageIdEnum.IMPALA, {
references: true,
definitions: true,
hover: true,
+ format: formatFeatureOptions,
preprocessCode
});
@@ -147,5 +166,6 @@ setupLanguageFeatures(LanguageIdEnum.GENERIC, {
references: true,
definitions: true,
hover: true,
+ format: formatFeatureOptions,
preprocessCode
});