diff --git a/package.json b/package.json index d11e05ea..70c445ff 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ ] }, "dependencies": { - "dt-sql-parser": "4.5.0" + "dt-sql-parser": "4.5.0", + "sql-formatter": "^15.8.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b1075c4..9bece159 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: dt-sql-parser: specifier: 4.5.0 version: 4.5.0(antlr4ng-cli@1.0.7) + sql-formatter: + specifier: ^15.8.2 + version: 15.8.2 devDependencies: '@commitlint/cli': specifier: ^17.7.2 @@ -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..fb9af8cb --- /dev/null +++ b/src/format.test.ts @@ -0,0 +1,229 @@ +import * as assert from 'assert'; + +import { LanguageIdEnum } from './common/constants'; +import { formatSQL } 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, ''); +}); + +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..454ec226 --- /dev/null +++ b/src/format.ts @@ -0,0 +1,52 @@ +import { format, type SqlLanguage } from 'sql-formatter'; + +import { LanguageIdEnum } from './common/constants'; + +/** + * Fallback formatter invoked when built-in sql-formatter fails. + */ +export type FormatFallback = (code: string, languageId: string) => string | Promise; + +export interface FormatSQLOptions { + fallback?: FormatFallback; +} + +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' +}; + +function resolveSqlLanguage(languageId: string): SqlLanguage { + return languageIdToSqlLanguage[languageId] ?? 'sql'; +} + +/** + * Format SQL with sql-formatter. + * On failure, runs the optional user fallback; if that is missing or also fails, returns the original code. + */ +export async function formatSQL( + code: string, + languageId: string, + options?: FormatSQLOptions +): Promise { + try { + return format(code, { + language: resolveSqlLanguage(languageId) + }); + } catch { + if (!options?.fallback) { + return code; + } + try { + return await options.fallback(code, languageId); + } catch { + return code; + } + } +} diff --git a/src/formatEditor.ts b/src/formatEditor.ts new file mode 100644 index 00000000..b9739f08 --- /dev/null +++ b/src/formatEditor.ts @@ -0,0 +1,79 @@ +import { editor, IDisposable, KeyCode, KeyMod, Range } from './fillers/monaco-editor-core'; +import { formatSQL, type FormatSQLOptions } from './format'; +import type { 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. + */ +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). + */ +export function registerFormatAction(defaults: LanguageServiceDefaults): IDisposable { + const { languageId } = defaults; + return editor.addEditorAction({ + id: `monaco-sql-languages.format.${languageId}`, + label: 'Format', + precondition: `editorLangId == '${languageId}'`, + contextMenuGroupId: '1_modification', + contextMenuOrder: 1.5, + keybindings: [KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyF], + run: (codeEditor) => + formatEditorSQL(codeEditor, { + fallback: defaults.modeConfiguration.format.fallback + }) + }); +} diff --git a/src/main.ts b/src/main.ts index 900243c0..518cc05e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,10 @@ export * from './setupLanguageFeatures'; export * from './common/constants'; export * from './theme'; export * as snippets from './snippets'; +export { formatSQL } from './format'; +export type { FormatSQLOptions } from './format'; +export { formatEditorSQL } from './formatEditor'; +export type { FormatTargetEditor, TextRange } from './formatEditor'; export { EntityContextType, StmtContextType } from 'dt-sql-parser'; diff --git a/src/monaco.contribution.ts b/src/monaco.contribution.ts index f1dca65c..22d589c2 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,18 @@ 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 fails. Should return formatted SQL. + */ + fallback?: FormatFallback; +} + export interface ModeConfiguration { /** * Defines whether the built-in completionItemProvider is enabled. @@ -94,6 +109,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 +232,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..634cd403 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,20 @@ function processConfiguration( : (defaults?.modeConfiguration.completionItems.snippets ?? getDefaultSnippets(languageId)); + const formatEnable = + typeof configuration.format === 'boolean' + ? configuration.format + : (configuration.format?.enable ?? + defaults?.modeConfiguration.format.enable ?? + modeConfigurationDefault.format.enable); + + const formatFallback = + typeof configuration.format !== 'boolean' && + typeof configuration.format?.fallback === 'function' + ? configuration.format.fallback + : (defaults?.modeConfiguration.format.fallback ?? + modeConfigurationDefault.format.fallback); + return { diagnostics, completionItems: { @@ -176,6 +198,10 @@ function processConfiguration( }, references, definitions, - hover + hover, + format: { + enable: formatEnable, + fallback: formatFallback + } }; } diff --git a/src/setupLanguageMode.ts b/src/setupLanguageMode.ts index e61e32b1..57910640 100644 --- a/src/setupLanguageMode.ts +++ b/src/setupLanguageMode.ts @@ -1,5 +1,6 @@ import type { BaseSQLWorker } from './baseSQLWorker'; import { IDisposable, languages, Uri } from './fillers/monaco-editor-core'; +import { registerFormatAction } from './formatEditor'; import * as languageFeatures from './languageFeatures'; import { LanguageServiceDefaults } from './monaco.contribution'; import { WorkerManager } from './workerManager'; @@ -59,6 +60,10 @@ export function setupLanguageMode( ) ); } + + if (modeConfiguration.format.enable) { + providers.push(registerFormatAction(defaults)); + } } registerProviders(); 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/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..3e1b721b 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) => 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 });