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 });