diff --git a/README-zh_CN.md b/README-zh_CN.md index 932f7609..7e58ffe8 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -205,6 +205,45 @@ npm install monaco-sql-languages
+## 运行语句按钮 + +在每条 SQL 语句前显示 gutter 运行图标。创建编辑器后调用 `createRunStatementButton`,编辑器销毁时调用 `dispose()`。 + +```typescript +import { + LanguageIdEnum, + createRunStatementButton, + getStatementRangesByLanguage +} from 'monaco-sql-languages'; + +const controller = createRunStatementButton({ + editor, + languageId: LanguageIdEnum.FLINK, + glyphMarginHoverMessage: '运行此语句', + preprocessCode: (code) => code, + onRun: ({ statement, editor, model }) => { + console.log(statement.text); + } +}); + +// controller.refresh(); // 需要时手动刷新图标 +// controller.dispose(); // 编辑器卸载时调用 +``` + +**说明** + +- 建议显式传入 `languageId`,以便内置拆分器使用正确的 parser。 +- 自定义 `getStatementRanges` 只需提供准确的 `startLineNumber` 和 `text`,`executableLineNumber` 会自动归一化。 +- 编辑器只读时不显示图标,且忽略点击。 +- 若只需内置拆分逻辑,可单独使用 `getStatementRangesByLanguage(code, languageId, preprocessCode)`。 +- `preprocessCode` 必须与编辑器 model 保持行号对齐(不要增删行导致行号偏移);装饰是打在原始 model 上的。 +- 默认情况下,仅空白或仅分号的片段(如 `;;;`)不显示运行图标。 +- `executableLineNumber` 会跳过 leading 注释,但不解析字符串字面量;若 leading 字符串内含 `/*` 或 `--` 可能误判(实际场景较少见)。 +- 多条语句的 `executableLineNumber` 相同时会输出 console 警告,gutter 点击以最后一条为准。 +- 编辑器销毁时请调用 `controller.dispose()`(每个编辑器实例对应一个 controller)。 + +
+ ## 代码片段 我们为每种SQL语言内置了一部分代码片段, 帮助我们快速编写SQL。 diff --git a/README.md b/README.md index 31f2ee58..0f433a5b 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,45 @@ npm install monaco-sql-languages
+## Run Statement Button + +Add a gutter run icon before each SQL statement. Call `createRunStatementButton` after creating the editor and dispose the controller when the editor is destroyed. + +```typescript +import { + LanguageIdEnum, + createRunStatementButton, + getStatementRangesByLanguage +} from 'monaco-sql-languages'; + +const controller = createRunStatementButton({ + editor, + languageId: LanguageIdEnum.FLINK, + glyphMarginHoverMessage: 'Run this statement', + preprocessCode: (code) => code, + onRun: ({ statement, editor, model }) => { + console.log(statement.text); + } +}); + +// controller.refresh(); // recompute icons manually when needed +// controller.dispose(); // on editor unmount +``` + +**Notes** + +- Pass `languageId` explicitly so the built-in splitter uses the correct parser. +- Custom `getStatementRanges` only needs accurate `startLineNumber` and `text`; `executableLineNumber` is normalized automatically. +- Icons are hidden and clicks are ignored when the editor is read-only. +- Use `getStatementRangesByLanguage(code, languageId, preprocessCode)` if you need the built-in splitter outside the button. +- `preprocessCode` must keep the same line mapping as the editor model (no insert/delete that shifts lines); decorations are applied to the original model. +- By default, blank or semicolon-only fragments (e.g. `;;;`) do not show a run icon. +- `executableLineNumber` detection skips leading comments but does not parse string literals; a leading string that contains `/*` or `--` may be misidentified (rare in practice). +- When multiple statements share the same `executableLineNumber`, a console warning is emitted and the last one wins for gutter clicks. +- Call `controller.dispose()` when the editor is destroyed (one controller per editor instance). + +
+ ## SQL Snippets We provide some built-in SQL snippets for each SQL language, which helps us to write SQL quickly. diff --git a/package.json b/package.json index 33794750..a693f6fe 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,6 @@ ] }, "dependencies": { - "dt-sql-parser": "4.5.0" + "dt-sql-parser": "4.5.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a885130d..8701c328 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: dt-sql-parser: - specifier: 4.5.0 - version: 4.5.0(antlr4ng-cli@1.0.7) + specifier: 4.5.1 + version: 4.5.1(antlr4ng-cli@1.0.7) devDependencies: '@commitlint/cli': specifier: ^17.7.2 @@ -723,8 +723,8 @@ packages: resolution: {integrity: sha512-sCm11ak2oY6DglEPpCB8TixLjWAxd3kJTs6UIcSasNYxXdFPV+YKlye92c8H4kKFqV5qYMIh7d+cYecEg0dIkA==} engines: {node: '>=6'} - dt-sql-parser@4.5.0: - resolution: {integrity: sha512-x12CX4vqeJKUZzCDjVBlG2DLQZ7ORnqVYYmnOFiz1IMBCOuamjHUeU05IGiRbRO+VqQSoN5RasiFJAjqzZ1WJw==} + dt-sql-parser@4.5.1: + resolution: {integrity: sha512-D1B4z2m6D1jkfruPo42x81jJ5uyuRH5lEx41K449DHvtVPSK+jYHWEUvgBixFaOBdP0ONyEnYpfzSUzRbgF1JA==} engines: {node: '>=18'} eastasianwidth@0.2.0: @@ -2769,7 +2769,7 @@ snapshots: find-up: 3.0.0 minimatch: 3.1.2 - dt-sql-parser@4.5.0(antlr4ng-cli@1.0.7): + dt-sql-parser@4.5.1(antlr4ng-cli@1.0.7): dependencies: antlr4-c3: 3.3.7(antlr4ng-cli@1.0.7) antlr4ng: 2.0.11(antlr4ng-cli@1.0.7) diff --git a/src/common/utils.ts b/src/common/utils.ts index e635b7f5..a9634ddc 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -1,10 +1,16 @@ +export interface DebouncedFunction unknown> { + (...args: Parameters): unknown; + cancel: () => void; +} + export function debounce unknown>( func: T, timeout: number, immediate?: boolean -): (...args: Parameters) => unknown { +): DebouncedFunction { let timer: NodeJS.Timeout | null = null; - return (...args) => { + + const debounced = (...args: Parameters) => { if (timer) { clearTimeout(timer); } @@ -18,4 +24,13 @@ export function debounce unknown>( func?.(...args); }, timeout); }; + + debounced.cancel = () => { + if (timer) { + clearTimeout(timer); + timer = null; + } + }; + + return debounced; } diff --git a/src/languageFeatures.ts b/src/languageFeatures.ts index d3877b37..cf8c74c0 100644 --- a/src/languageFeatures.ts +++ b/src/languageFeatures.ts @@ -145,6 +145,33 @@ function toDiagnostics(_resource: Uri, diag: ParseError): editor.IMarkerData { }; } +interface TokenizationTextModel { + tokenization?: { + tokenizeLinesAt?: ( + lineNumber: number, + lines: string[] + ) => Array<{ + findTokenIndexAtOffset: (offset: number) => number; + getStandardTokenType: (tokenIndex: number) => number; + }> | null; + }; +} + +function isPositionInComment(model: editor.IReadOnlyModel, position: Position): boolean { + const lineContent = model.getLineContent(position.lineNumber); + const tokenizationModel = model as editor.IReadOnlyModel & TokenizationTextModel; + // 在光标处追加哨兵字符,避免将刚结束的块注释误判为仍在注释中 + const tokenizedLine = tokenizationModel.tokenization?.tokenizeLinesAt?.(position.lineNumber, [ + `${lineContent.slice(0, position.column - 1)}x` + ])?.[0]; + if (!tokenizedLine) { + return false; + } + + const tokenIndex = tokenizedLine.findTokenIndexAtOffset(position.column - 1); + return tokenizedLine.getStandardTokenType(tokenIndex) === 1; +} + export class CompletionAdapter implements languages.CompletionItemProvider { @@ -165,6 +192,10 @@ export class CompletionAdapter context: languages.CompletionContext, _token: CancellationToken ): Promise { + if (isPositionInComment(model, position)) { + return Promise.resolve({ suggestions: [] }); + } + const resource = model.uri; return this._worker(resource) .then((worker) => { diff --git a/src/main.ts b/src/main.ts index 900243c0..10b0d1bb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ export * from './setupLanguageFeatures'; export * from './common/constants'; export * from './theme'; export * as snippets from './snippets'; +export * from './runStatementButton'; export { EntityContextType, StmtContextType } from 'dt-sql-parser'; diff --git a/src/runStatementButton/index.ts b/src/runStatementButton/index.ts new file mode 100644 index 00000000..471d92d7 --- /dev/null +++ b/src/runStatementButton/index.ts @@ -0,0 +1,242 @@ +import { debounce } from '../common/utils'; +import { editor, IDisposable, Range } from '../fillers/monaco-editor-core'; +import { getStatementRangesByLanguage, normalizeStatementRanges } from './statementRanges'; +import { DEFAULT_RUN_GLYPH_CLASS_NAME, injectRunStatementButtonStyles } from './styles'; +import type { + RunStatementButtonController, + RunStatementButtonOptions, + RunStatementContext, + StatementRange +} from './types'; + +/** Hide run icons for blank or semicolon-only fragments (e.g. `;;;`). */ +function defaultShouldShowRunButton(statement: StatementRange): boolean { + return /[^\s;]/.test(statement.text); +} + +function toHoverMessage( + message: string | { value: string } | undefined +): { value: string } | undefined { + if (!message) { + return undefined; + } + return typeof message === 'string' ? { value: message } : message; +} + +function isEditorReadOnly(codeEditor: editor.IStandaloneCodeEditor): boolean { + return codeEditor.getOption(editor.EditorOption.readOnly); +} + +export function createRunStatementButton( + options: RunStatementButtonOptions +): RunStatementButtonController { + const { + editor: codeEditor, + languageId, + onRun, + getStatementRanges, + shouldShowRunButton = defaultShouldShowRunButton, + glyphMarginClassName = DEFAULT_RUN_GLYPH_CLASS_NAME, + glyphMarginHoverMessage, + debounceMs = 200, + enableGlyphMargin = true, + preprocessCode, + injectDefaultStyles = true, + themeColors + } = options; + + if (injectDefaultStyles) { + injectRunStatementButtonStyles(glyphMarginClassName, themeColors); + } + + if (enableGlyphMargin) { + codeEditor.updateOptions({ glyphMargin: true }); + } + + const disposables: IDisposable[] = []; + let decorationCollection: editor.IEditorDecorationsCollection | null = null; + let lineToStatementMap = new Map(); + let refreshRequestId = 0; + let disposed = false; + + const resolveStatements = (code: string): StatementRange[] | Promise => { + const resolvedLanguageId = languageId ?? codeEditor.getModel()?.getLanguageId(); + if (typeof getStatementRanges === 'function') { + return Promise.resolve(getStatementRanges(code, resolvedLanguageId)).then( + normalizeStatementRanges + ); + } + return getStatementRangesByLanguage(code, resolvedLanguageId, preprocessCode); + }; + + const applyDecorations = (statements: StatementRange[]) => { + lineToStatementMap = new Map(); + const decorations: editor.IModelDeltaDecoration[] = []; + + if (isEditorReadOnly(codeEditor)) { + decorationCollection?.set([]); + return; + } + + statements.forEach((statement) => { + if (!shouldShowRunButton(statement)) { + return; + } + const { executableLineNumber } = statement; + const existing = lineToStatementMap.get(executableLineNumber); + if (existing) { + console.warn( + '[monaco-sql-languages] Multiple run statements share executable line', + executableLineNumber, + { previous: existing.text, current: statement.text } + ); + } + lineToStatementMap.set(executableLineNumber, statement); + decorations.push({ + range: new Range(executableLineNumber, 1, executableLineNumber, 1), + options: { + glyphMarginClassName, + glyphMarginHoverMessage: toHoverMessage(glyphMarginHoverMessage) + } + }); + }); + + if (decorationCollection) { + decorationCollection.set(decorations); + } else { + decorationCollection = codeEditor.createDecorationsCollection(decorations); + } + }; + + const refresh = () => { + if (disposed) { + return; + } + + const model = codeEditor.getModel(); + if (!model) { + lineToStatementMap.clear(); + decorationCollection?.clear(); + return; + } + + const currentRequestId = ++refreshRequestId; + + void Promise.resolve(resolveStatements(model.getValue())) + .then((statements) => { + if (disposed || currentRequestId !== refreshRequestId) { + return; + } + applyDecorations(statements); + }) + .catch((error) => { + console.error( + '[monaco-sql-languages] Failed to resolve run statement ranges:', + error + ); + }); + }; + + const scheduleRefresh = debounce(() => { + refresh(); + }, debounceMs); + + const handleMouseDown = codeEditor.onMouseDown((event) => { + if (isEditorReadOnly(codeEditor)) { + return; + } + if (event.target.type !== editor.MouseTargetType.GUTTER_GLYPH_MARGIN) { + return; + } + const lineNumber = event.target.position?.lineNumber; + if (!lineNumber) { + return; + } + + const statement = lineToStatementMap.get(lineNumber); + if (!statement) { + return; + } + + event.event.preventDefault(); + event.event.stopPropagation(); + + const model = codeEditor.getModel(); + if (!model) { + return; + } + + const context: RunStatementContext = { + statement, + editor: codeEditor, + model + }; + void Promise.resolve() + .then(() => onRun(context)) + .catch((error) => { + console.error('[monaco-sql-languages] Run statement handler failed:', error); + }); + }); + + const handleModelChange = codeEditor.onDidChangeModel(() => { + scheduleRefresh(); + }); + + const handleModelLanguageChange = codeEditor.onDidChangeModelLanguage(() => { + scheduleRefresh(); + }); + + const handleContentChange = codeEditor.onDidChangeModelContent(() => { + scheduleRefresh(); + }); + + const handleConfigurationChange = codeEditor.onDidChangeConfiguration((event) => { + if (event.hasChanged(editor.EditorOption.readOnly)) { + scheduleRefresh(); + } + }); + + disposables.push( + handleMouseDown, + handleModelChange, + handleModelLanguageChange, + handleContentChange, + handleConfigurationChange + ); + + refresh(); + + return { + refresh, + dispose: () => { + disposed = true; + refreshRequestId += 1; + scheduleRefresh.cancel(); + disposables.forEach((item) => item.dispose()); + decorationCollection?.clear(); + decorationCollection = null; + lineToStatementMap.clear(); + } + }; +} + +export { + DEFAULT_RUN_GLYPH_CLASS_NAME, + DEFAULT_RUN_BUTTON_THEME_PALETTE, + injectRunStatementButtonStyles +} from './styles'; +export { + getStatementRangesByLanguage, + normalizeStatementRange, + normalizeStatementRanges +} from './statementRanges'; +export type { + RunStatementButtonOptions, + RunStatementButtonController, + RunStatementContext, + RunStatementButtonThemeColors, + RunStatementButtonThemePalette, + StatementRange, + GetStatementRanges, + ShouldShowRunButton +} from './types'; diff --git a/src/runStatementButton/statementRanges.ts b/src/runStatementButton/statementRanges.ts new file mode 100644 index 00000000..b911d2ea --- /dev/null +++ b/src/runStatementButton/statementRanges.ts @@ -0,0 +1,125 @@ +/** + * Statement splitting for run-button gutter icons. + * + * Strategy: + * 1. Prefer dt-sql-parser `splitSQLByStatement` when languageId is known. + * 2. Fall back to semicolon split (respecting quotes). + * 3. Set `executableLineNumber` by skipping leading blank lines, line comments and block comments. + */ +import { + FlinkSQL, + GenericSQL, + HiveSQL, + ImpalaSQL, + MySQL, + PostgreSQL, + SparkSQL, + TrinoSQL +} from 'dt-sql-parser'; +import type { TextSlice } from 'dt-sql-parser'; +import { BasicSQL } from 'dt-sql-parser/dist/parser/common/basicSQL'; + +import { LanguageIdEnum } from '../common/constants'; +import type { PreprocessCode } from '../monaco.contribution'; +import { + buildStatementRange, + findFirstExecutableLine, + splitStatementsFallback +} from './statementSplitUtils'; +import type { StatementRange } from './types'; + +export { findFirstExecutableLine, splitStatementsFallback } from './statementSplitUtils'; + +const parserMap: Record BasicSQL> = { + [LanguageIdEnum.FLINK]: () => new FlinkSQL(), + [LanguageIdEnum.HIVE]: () => new HiveSQL(), + [LanguageIdEnum.MYSQL]: () => new MySQL(), + [LanguageIdEnum.PG]: () => new PostgreSQL(), + [LanguageIdEnum.SPARK]: () => new SparkSQL(), + [LanguageIdEnum.TRINO]: () => new TrinoSQL(), + [LanguageIdEnum.IMPALA]: () => new ImpalaSQL(), + [LanguageIdEnum.GENERIC]: () => new GenericSQL() +}; + +const parserCache = new Map(); + +function getOrCreateParser(languageId: string): BasicSQL | null { + const cached = parserCache.get(languageId); + if (cached) { + return cached; + } + const factory = parserMap[languageId]; + if (!factory) { + return null; + } + const parser = factory(); + parserCache.set(languageId, parser); + return parser; +} + +/** Ensure `executableLineNumber` skips leading comments even for custom statement resolvers. */ +export function normalizeStatementRange(range: StatementRange): StatementRange { + const executableLineNumber = findFirstExecutableLine(range.text, range.startLineNumber); + if (executableLineNumber === range.executableLineNumber) { + return range; + } + return { + ...range, + executableLineNumber + }; +} + +export function normalizeStatementRanges(ranges: StatementRange[]): StatementRange[] { + return ranges.map(normalizeStatementRange); +} + +function textSliceToStatementRange(slice: TextSlice): StatementRange { + return buildStatementRange( + slice.text, + slice.startLine, + slice.endLine, + slice.startColumn, + slice.endColumn + ); +} + +function splitStatementsByParser(source: string, languageId: string): StatementRange[] | null { + const parser = getOrCreateParser(languageId); + if (!parser) { + return null; + } + + const slices = parser.splitSQLByStatement(source); + if (!slices?.length) { + return null; + } + + return slices.map(textSliceToStatementRange).filter((item) => item.text.trim()); +} + +export function getStatementRangesByLanguage( + code: string, + languageId?: string, + preprocessCode?: PreprocessCode | null +): StatementRange[] { + const source = typeof preprocessCode === 'function' ? preprocessCode(code) : code; + if (!source.trim()) { + return []; + } + + if (languageId) { + try { + const parsedStatements = splitStatementsByParser(source, languageId); + if (parsedStatements?.length) { + return parsedStatements; + } + } catch (error) { + console.error( + '[monaco-sql-languages] Failed to split SQL statements by parser, fallback to semicolon split:', + error + ); + } + } + + return splitStatementsFallback(source); +} diff --git a/src/runStatementButton/statementSplitUtils.test.ts b/src/runStatementButton/statementSplitUtils.test.ts new file mode 100644 index 00000000..257a128a --- /dev/null +++ b/src/runStatementButton/statementSplitUtils.test.ts @@ -0,0 +1,72 @@ +import * as assert from 'assert'; +import { findFirstExecutableLine, splitStatementsFallback } from './statementSplitUtils'; + +test('findFirstExecutableLine returns start line for plain SQL', () => { + assert.strictEqual(findFirstExecutableLine('SELECT 1;', 1), 1); + assert.strictEqual(findFirstExecutableLine('SELECT 1;', 5), 5); +}); + +test('findFirstExecutableLine skips leading blank lines', () => { + assert.strictEqual(findFirstExecutableLine('\n\nSELECT 1;', 1), 3); +}); + +test('findFirstExecutableLine skips leading line comments', () => { + const text = '-- comment\nSELECT 1;'; + assert.strictEqual(findFirstExecutableLine(text, 1), 2); +}); + +test('findFirstExecutableLine skips leading block comments', () => { + const text = '/* block\ncomment */\nSELECT 1;'; + assert.strictEqual(findFirstExecutableLine(text, 1), 3); +}); + +test('findFirstExecutableLine skips mixed leading whitespace and comments', () => { + const text = '\n -- line\n/* a */ SELECT 1;'; + assert.strictEqual(findFirstExecutableLine(text, 10), 12); +}); + +test('findFirstExecutableLine returns startLineNumber when text is only comments', () => { + assert.strictEqual(findFirstExecutableLine('-- only comment', 3), 3); + assert.strictEqual(findFirstExecutableLine('/* only */', 1), 1); +}); + +test('splitStatementsFallback splits by semicolon', () => { + const ranges = splitStatementsFallback('SELECT 1; SELECT 2;'); + assert.strictEqual(ranges.length, 2); + assert.strictEqual(ranges[0].text.trim(), 'SELECT 1;'); + assert.strictEqual(ranges[1].text.trim(), 'SELECT 2;'); + assert.strictEqual(ranges[0].executableLineNumber, 1); + assert.strictEqual(ranges[1].executableLineNumber, 1); +}); + +test('splitStatementsFallback keeps semicolon inside quotes', () => { + const ranges = splitStatementsFallback('SELECT \'a;b\'; SELECT "c;d";'); + assert.strictEqual(ranges.length, 2); + assert.ok(ranges[0].text.includes("'a;b'")); + assert.ok(ranges[1].text.includes('"c;d"')); +}); + +test('splitStatementsFallback handles multiline statements and leading comments', () => { + const code = 'SELECT\n 1;\n-- comment\nSELECT 2;'; + const ranges = splitStatementsFallback(code); + assert.strictEqual(ranges.length, 2); + assert.strictEqual(ranges[0].startLineNumber, 1); + assert.strictEqual(ranges[0].executableLineNumber, 1); + assert.strictEqual(ranges[1].executableLineNumber, 4); + assert.ok(ranges[1].text.includes('SELECT 2;')); +}); + +test('splitStatementsFallback keeps lone semicolons as statements', () => { + // Splitter still returns `;` segments; createRunStatementButton hides them by default. + const ranges = splitStatementsFallback('SELECT 1;;;SELECT 2;'); + assert.strictEqual(ranges.length, 4); + assert.strictEqual(ranges[0].text.trim(), 'SELECT 1;'); + assert.strictEqual(ranges[1].text, ';'); + assert.strictEqual(ranges[2].text, ';'); + assert.strictEqual(ranges[3].text.trim(), 'SELECT 2;'); +}); + +test('splitStatementsFallback returns empty array for blank input', () => { + assert.deepStrictEqual(splitStatementsFallback(' \n\t'), []); + assert.deepStrictEqual(splitStatementsFallback(''), []); +}); diff --git a/src/runStatementButton/statementSplitUtils.ts b/src/runStatementButton/statementSplitUtils.ts new file mode 100644 index 00000000..66827be7 --- /dev/null +++ b/src/runStatementButton/statementSplitUtils.ts @@ -0,0 +1,126 @@ +/** + * Pure statement-split helpers (no parser deps) for run-button gutter placement. + */ +import type { StatementRange } from './types'; + +/** + * Find the first non-comment, non-whitespace character and return its 1-based line number. + * + * Limitation: does not treat `--` / `/*` inside string literals as text; rare for leading gutter placement. + */ +export function findFirstExecutableLine(text: string, startLineNumber: number): number { + let index = 0; + let lineOffset = 0; + let inBlockComment = false; + const length = text.length; + + while (index < length) { + const char = text[index]; + const nextChar = text[index + 1]; + + if (inBlockComment) { + if (char === '*' && nextChar === '/') { + index += 2; + inBlockComment = false; + continue; + } + if (char === '\n') { + lineOffset++; + } + index++; + continue; + } + + if (char === ' ' || char === '\t' || char === '\r') { + index++; + continue; + } + + if (char === '\n') { + lineOffset++; + index++; + continue; + } + + if (char === '-' && nextChar === '-') { + while (index < length && text[index] !== '\n') { + index++; + } + continue; + } + + if (char === '/' && nextChar === '*') { + index += 2; + inBlockComment = true; + continue; + } + + return startLineNumber + lineOffset; + } + + return startLineNumber; +} + +export function buildStatementRange( + text: string, + startLineNumber: number, + endLineNumber: number, + startColumn: number, + endColumn: number +): StatementRange { + return { + executableLineNumber: findFirstExecutableLine(text, startLineNumber), + text, + startLineNumber, + endLineNumber, + startColumn, + endColumn + }; +} + +function getPositionFromOffset(code: string, offset: number): { line: number; column: number } { + const before = code.slice(0, offset); + const lines = before.split('\n'); + return { + line: lines.length, + column: (lines[lines.length - 1]?.length ?? 0) + 1 + }; +} + +/** Semicolon split fallback when parser split is unavailable; respects quotes. */ +export function splitStatementsFallback(code: string): StatementRange[] { + const statements: StatementRange[] = []; + let inSingleQuote = false; + let inDoubleQuote = false; + let lastSplit = 0; + + const pushStatement = (start: number, end: number) => { + const text = code.slice(start, end); + if (!text.trim()) { + return; + } + const startPos = getPositionFromOffset(code, start); + const endPos = getPositionFromOffset(code, Math.max(start, end - 1)); + statements.push( + buildStatementRange(text, startPos.line, endPos.line, startPos.column, endPos.column) + ); + }; + + for (let i = 0; i < code.length; i++) { + const ch = code[i]; + if (ch === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + } else if (ch === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + } else if (ch === ';' && !inSingleQuote && !inDoubleQuote) { + pushStatement(lastSplit, i + 1); + lastSplit = i + 1; + } + } + + if (lastSplit < code.length) { + pushStatement(lastSplit, code.length); + } + + return statements; +} diff --git a/src/runStatementButton/styles.ts b/src/runStatementButton/styles.ts new file mode 100644 index 00000000..dd86ce65 --- /dev/null +++ b/src/runStatementButton/styles.ts @@ -0,0 +1,94 @@ +import type { RunStatementButtonThemeColors, RunStatementButtonThemePalette } from './types'; + +const STYLE_ELEMENT_ID_PREFIX = 'monaco-sql-languages-run-statement-button-styles'; + +export const DEFAULT_RUN_GLYPH_CLASS_NAME = 'mssql-run-statement-glyph'; + +/** Default palette aligned with VS Code run/debug accent colors. */ +export const DEFAULT_RUN_BUTTON_THEME_PALETTE: Record< + 'dark' | 'light' | 'hc', + RunStatementButtonThemeColors +> = { + dark: { normal: '#73c991', hover: '#89d19d' }, + light: { normal: '#16825d', hover: '#107c10' }, + hc: { normal: '#1aebff', hover: '#4df2ff' } +}; + +function getStyleElementId(className: string): string { + return `${STYLE_ELEMENT_ID_PREFIX}-${className.replace(/[^a-zA-Z0-9_-]/g, '-')}`; +} + +function buildTriangleRules( + className: string, + selector: string, + colors: RunStatementButtonThemeColors +): string { + return ` +${selector} .${className}::before { + border-left-color: ${colors.normal}; +} +${selector} .${className}:hover::before { + border-left-color: ${colors.hover}; +} +`; +} + +function getStyleContent( + className: string, + palette: Record<'dark' | 'light' | 'hc', RunStatementButtonThemeColors> +): string { + return ` +.monaco-editor .${className} { + cursor: pointer; + position: relative; + opacity: 1 !important; + background: none !important; + background-image: none !important; +} +.monaco-editor .${className}::before { + content: ''; + position: absolute; + left: 50%; + top: 50%; + margin-left: -3px; + transform: translate(-50%, -50%); + border-style: solid; + border-width: 5px 0 5px 9px; + border-color: transparent; + pointer-events: none; + transition: border-color 0.15s ease; +} +${buildTriangleRules(className, '.monaco-editor.vs-dark', palette.dark)} +${buildTriangleRules(className, '.monaco-editor.hc-black', palette.hc)} +${buildTriangleRules(className, '.monaco-editor.vs', palette.light)} +${buildTriangleRules(className, '.monaco-editor.hc-light', palette.hc)} +`; +} + +export function injectRunStatementButtonStyles( + className = DEFAULT_RUN_GLYPH_CLASS_NAME, + palette?: RunStatementButtonThemePalette +): void { + if (typeof document === 'undefined') { + return; + } + + const resolvedPalette = { + dark: palette?.dark ?? DEFAULT_RUN_BUTTON_THEME_PALETTE.dark, + light: palette?.light ?? DEFAULT_RUN_BUTTON_THEME_PALETTE.light, + hc: palette?.hc ?? DEFAULT_RUN_BUTTON_THEME_PALETTE.hc + }; + + const styleElementId = getStyleElementId(className); + const styleContent = getStyleContent(className, resolvedPalette); + const existing = document.getElementById(styleElementId); + if (existing) { + existing.textContent = styleContent; + return; + } + + const style = document.createElement('style'); + style.id = styleElementId; + style.textContent = styleContent; + document.head.appendChild(style); +} diff --git a/src/runStatementButton/types.ts b/src/runStatementButton/types.ts new file mode 100644 index 00000000..b5e61ccc --- /dev/null +++ b/src/runStatementButton/types.ts @@ -0,0 +1,86 @@ +import type { PreprocessCode } from '../monaco.contribution'; +import type { editor, IDisposable } from '../fillers/monaco-editor-core'; + +export interface RunStatementButtonThemeColors { + normal: string; + hover: string; +} + +export interface RunStatementButtonThemePalette { + dark?: RunStatementButtonThemeColors; + light?: RunStatementButtonThemeColors; + hc?: RunStatementButtonThemeColors; +} + +export interface StatementRange { + /** + * Gutter icon line (1-based). Skips leading blank lines and comments — not necessarily `startLineNumber`. + * Built-in and normalized custom resolvers compute this automatically. + */ + executableLineNumber: number; + /** Full statement text passed to `onRun`. */ + text: string; + /** Statement block start line (1-based, may include leading comments). */ + startLineNumber: number; + endLineNumber: number; + startColumn: number; + endColumn: number; +} + +export interface RunStatementContext { + statement: StatementRange; + editor: editor.IStandaloneCodeEditor; + model: editor.ITextModel; +} + +/** + * Custom statement resolver. When provided, it fully replaces the built-in splitter. + * Return ranges with accurate `startLineNumber` / `text`; `executableLineNumber` is normalized automatically. + */ +export type GetStatementRanges = ( + code: string, + languageId?: string +) => StatementRange[] | Promise; + +export type ShouldShowRunButton = (statement: StatementRange) => boolean; + +export interface RunStatementButtonOptions { + editor: editor.IStandaloneCodeEditor; + /** + * SQL language id, e.g. {@link LanguageIdEnum.FLINK}. + * Used by the built-in statement splitter when `getStatementRanges` is not provided. + */ + languageId?: string; + /** Called when the run icon is clicked. Async errors are logged to the console. */ + onRun: (context: RunStatementContext) => void | Promise; + /** Custom statement resolver. See {@link GetStatementRanges}. */ + getStatementRanges?: GetStatementRanges; + /** + * Whether to show the run icon for a statement. + * Defaults to statements that contain at least one non-whitespace, non-semicolon character. + */ + shouldShowRunButton?: ShouldShowRunButton; + /** CSS class applied to the glyph margin icon. */ + glyphMarginClassName?: string; + /** Hover message shown on the run icon. No hover text when omitted. */ + glyphMarginHoverMessage?: string | { value: string }; + /** Debounce duration for content updates in milliseconds. Defaults to 200. */ + debounceMs?: number; + /** Whether to enable glyph margin on the editor. Defaults to true. */ + enableGlyphMargin?: boolean; + /** + * Preprocess code before resolving statements. + * Must preserve line-number alignment with the editor model (do not insert/delete lines); + * otherwise gutter icons may land on the wrong lines. + */ + preprocessCode?: PreprocessCode | null; + /** Inject default run icon styles into the document. Defaults to true. */ + injectDefaultStyles?: boolean; + /** Custom icon colors per editor theme base (vs-dark / vs / hc). */ + themeColors?: RunStatementButtonThemePalette; +} + +export interface RunStatementButtonController extends IDisposable { + /** Recompute statement ranges and refresh gutter icons. */ + refresh(): void; +} diff --git a/src/test/languageFeatures.test.ts b/src/test/languageFeatures.test.ts new file mode 100644 index 00000000..565e6c0c --- /dev/null +++ b/src/test/languageFeatures.test.ts @@ -0,0 +1,229 @@ +import * as assert from 'assert'; + +import { BaseSQLWorker } from '../baseSQLWorker'; +import { LanguageIdEnum } from '../common/constants'; +import { + CancellationTokenSource, + editor, + languages, + Position +} from '../fillers/monaco-editor-core'; +import { CompletionAdapter, WorkerAccessor } from '../languageFeatures'; +import { language as flinkLanguage } from '../languages/flink/flink'; +import { language as genericLanguage } from '../languages/generic/generic'; +import { language as hiveLanguage } from '../languages/hive/hive'; +import { language as impalaLanguage } from '../languages/impala/impala'; +import { language as mysqlLanguage } from '../languages/mysql/mysql'; +import { language as pgsqlLanguage } from '../languages/pgsql/pgsql'; +import { language as sparkLanguage } from '../languages/spark/spark'; +import { language as trinoLanguage } from '../languages/trino/trino'; +import { LanguageServiceDefaultsImpl, modeConfigurationDefault } from '../monaco.contribution'; + +const SQL_DIALECTS = [ + { name: LanguageIdEnum.FLINK, language: flinkLanguage }, + { name: LanguageIdEnum.HIVE, language: hiveLanguage }, + { name: LanguageIdEnum.MYSQL, language: mysqlLanguage }, + { name: LanguageIdEnum.PG, language: pgsqlLanguage }, + { name: LanguageIdEnum.SPARK, language: sparkLanguage }, + { name: LanguageIdEnum.TRINO, language: trinoLanguage }, + { name: LanguageIdEnum.IMPALA, language: impalaLanguage }, + { name: LanguageIdEnum.GENERIC, language: genericLanguage } +].map((dialect) => ({ + ...dialect, + languageId: `${dialect.name}-completion-test` +})); +const MYSQL_DIALECT = SQL_DIALECTS.find(({ name }) => name === LanguageIdEnum.MYSQL)!; + +SQL_DIALECTS.forEach((dialect) => { + languages.register({ id: dialect.languageId }); + languages.setMonarchTokensProvider(dialect.languageId, dialect.language); +}); + +interface CompletionResult { + suggestions: languages.CompletionItem[]; + workerCallCount: number; +} + +function getEndPosition(value: string): Position { + const lines = value.split('\n'); + return new Position(lines.length, lines[lines.length - 1].length + 1); +} + +async function provideCompletionItems( + languageId: string, + value: string, + position: Position = getEndPosition(value) +): Promise { + const model = editor.createModel(value, languageId); + const cancellationTokenSource = new CancellationTokenSource(); + let workerCallCount = 0; + const worker: WorkerAccessor = async () => { + workerCallCount++; + return { + doCompletionWithEntities: async () => ({ + suggestions: { + syntax: [], + keywords: ['SELECT'] + }, + allEntities: null, + context: null + }) + } as unknown as BaseSQLWorker; + }; + const defaults = new LanguageServiceDefaultsImpl(languageId, modeConfigurationDefault); + const adapter = new CompletionAdapter(worker, defaults); + + try { + const completionList = await adapter.provideCompletionItems( + model, + position, + { triggerKind: languages.CompletionTriggerKind.Invoke }, + cancellationTokenSource.token + ); + + return { + suggestions: completionList.suggestions, + workerCallCount + }; + } finally { + cancellationTokenSource.dispose(); + model.dispose(); + } +} + +SQL_DIALECTS.forEach((dialect) => { + test(`does not provide ${dialect.name} completion items inside a line comment`, async () => { + const result = await provideCompletionItems(dialect.languageId, 'SELECT 1 -- comment'); + + assert.deepStrictEqual(result.suggestions, []); + assert.strictEqual(result.workerCallCount, 0); + }); + + test(`does not provide ${dialect.name} completion items inside a multiline block comment`, async () => { + const result = await provideCompletionItems( + dialect.languageId, + 'SELECT /* comment\nstill comment */', + new Position(2, 6) + ); + + assert.deepStrictEqual(result.suggestions, []); + assert.strictEqual(result.workerCallCount, 0); + }); +}); + +test('does not provide completion items after a line comment marker', async () => { + const result = await provideCompletionItems(MYSQL_DIALECT.languageId, '--'); + + assert.deepStrictEqual(result.suggestions, []); + assert.strictEqual(result.workerCallCount, 0); +}); + +test('does not provide completion items inside a MySQL hash comment', async () => { + const result = await provideCompletionItems(MYSQL_DIALECT.languageId, '# comment'); + + assert.deepStrictEqual(result.suggestions, []); + assert.strictEqual(result.workerCallCount, 0); +}); + +test('does not provide completion items inside a block comment', async () => { + const result = await provideCompletionItems( + MYSQL_DIALECT.languageId, + '/* comment */', + new Position(1, 4) + ); + + assert.deepStrictEqual(result.suggestions, []); + assert.strictEqual(result.workerCallCount, 0); +}); + +test('provides completion items after a closed block comment', async () => { + const result = await provideCompletionItems(MYSQL_DIALECT.languageId, 'SELECT /* comment */'); + + assert.deepStrictEqual( + result.suggestions.map((item) => item.label), + ['SELECT'] + ); + assert.strictEqual(result.workerCallCount, 1); +}); + +test('does not treat comment markers inside strings as comments', async () => { + const result = await provideCompletionItems(MYSQL_DIALECT.languageId, "SELECT '--'"); + + assert.deepStrictEqual( + result.suggestions.map((item) => item.label), + ['SELECT'] + ); + assert.strictEqual(result.workerCallCount, 1); +}); + +test('does not treat a single minus sign as a comment', async () => { + const result = await provideCompletionItems(MYSQL_DIALECT.languageId, '-'); + + assert.deepStrictEqual( + result.suggestions.map((item) => item.label), + ['SELECT'] + ); + assert.strictEqual(result.workerCallCount, 1); +}); + +test('checks comment state without reading the whole document prefix', async () => { + const value = 'SELECT 1;\n-- comment'; + const model = editor.createModel(value, MYSQL_DIALECT.languageId); + const position = getEndPosition(value); + const originalGetValueInRange = model.getValueInRange.bind(model); + const cancellationTokenSource = new CancellationTokenSource(); + const defaults = new LanguageServiceDefaultsImpl( + MYSQL_DIALECT.languageId, + modeConfigurationDefault + ); + const adapter = new CompletionAdapter( + async () => + ({ + doCompletionWithEntities: async () => ({ + suggestions: { + syntax: [], + keywords: ['SELECT'] + }, + allEntities: null, + context: null + }) + }) as unknown as BaseSQLWorker, + defaults + ); + + model.getValueInRange = ((range, eol) => { + if ( + range.startLineNumber === 1 && + range.startColumn === 1 && + (range.endLineNumber > 1 || range.endColumn > 1) + ) { + throw new Error('comment detection should not read the whole document prefix'); + } + + return originalGetValueInRange(range, eol); + }) as typeof model.getValueInRange; + + try { + const completionList = await adapter.provideCompletionItems( + model, + position, + { triggerKind: languages.CompletionTriggerKind.Invoke }, + cancellationTokenSource.token + ); + + assert.deepStrictEqual(completionList.suggestions, []); + } finally { + cancellationTokenSource.dispose(); + model.dispose(); + } +}); + +test('keeps the existing completion flow for regular SQL', async () => { + const result = await provideCompletionItems(MYSQL_DIALECT.languageId, 'SELECT '); + + assert.deepStrictEqual( + result.suggestions.map((item) => item.label), + ['SELECT'] + ); + assert.strictEqual(result.workerCallCount, 1); +}); diff --git a/test/all.js b/test/all.js index 7be7010a..bca4bd49 100644 --- a/test/all.js +++ b/test/all.js @@ -103,20 +103,19 @@ requirejs( function () { let files; try { - files = [ - ...glob.sync('out/amd/languages/*/*.test.js', { + files = glob.sync( + [ + 'out/amd/languages/*/*.test.js', + 'out/amd/runStatementButton/*.test.js', + 'out/amd/test/*.test.js', + 'out/amd/format.test.js', + 'out/amd/formatBridge.test.js' + ], + { cwd: path.dirname(__dirname), dot: true - }), - ...glob.sync('out/amd/format.test.js', { - cwd: path.dirname(__dirname), - dot: true - }), - ...glob.sync('out/amd/formatBridge.test.js', { - cwd: path.dirname(__dirname), - dot: true - }) - ]; + } + ); } catch (err) { console.log(err); return; diff --git a/test/setup.js b/test/setup.js index 6d6a4ea7..2bdda08b 100644 --- a/test/setup.js +++ b/test/setup.js @@ -29,6 +29,17 @@ define('vs/nls', [], { } }); +define('dt-sql-parser', [], { + EntityContextType: { + TABLE: 'table', + TABLE_CREATE: 'tableCreate' + } +}); + +define('monaco-editor', ['vs/editor/editor.main'], function (api) { + return api.m || api; +}); + define(['vs/editor/editor.main'], function (api) { // Monaco Editor 0.54.0+ exports as api.m instead of api directly const monaco = api.m || api; diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index a78a0462..f590edb3 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + monaco-editor: 0.54.0 + importers: .: @@ -627,42 +630,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -1845,28 +1842,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -2029,9 +2022,6 @@ packages: monaco-editor-nls@3.1.0: resolution: {integrity: sha512-GdzgKRAiwXlGI/ude/HbzP8ukml28yUoZ7rx/xsSsmYf9W13jTyWjXjoMK/+pjL6JorAThH2WkCYaDoCnIX7Jg==} - monaco-editor@0.52.2: - resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} - monaco-editor@0.54.0: resolution: {integrity: sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==} @@ -3148,7 +3138,7 @@ snapshots: '@vscode/codicons': 0.0.33 immer: 10.1.3 lodash-es: 4.17.21 - monaco-editor: 0.52.2 + monaco-editor: 0.54.0 monaco-editor-nls: 3.1.0 normalize.css: 8.0.1 rc-dropdown: 4.2.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4877,8 +4867,6 @@ snapshots: monaco-editor-nls@3.1.0: {} - monaco-editor@0.52.2: {} - monaco-editor@0.54.0: dependencies: dompurify: 3.1.7 diff --git a/website/src/App.tsx b/website/src/App.tsx index 3800a819..3f7131bd 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -7,18 +7,38 @@ import extensions from './extensions'; import './languages'; import './App.css'; import { editor } from 'monaco-editor'; +import { + createRunStatementButton, + type RunStatementButtonController +} from 'monaco-sql-languages/esm/main'; + +/** Demo-only: one controller per editor; dispose on app unmount. Production: dispose in editor unmount. */ +const runButtonControllers = new Map(); /** * Allow code completion when typing in snippets. * * You can also set configurations when creating monaco-editor instance */ -editor.onDidCreateEditor((editor) => { - editor.updateOptions({ +editor.onDidCreateEditor((editorInstance) => { + const codeEditor = editorInstance as editor.IStandaloneCodeEditor; + + codeEditor.updateOptions({ suggest: { snippetsPreventQuickSuggestions: false } }); + + // languageId is resolved from the model on each refresh; no need to pass it at create time. + const runButtonController = createRunStatementButton({ + editor: codeEditor, + glyphMarginHoverMessage: '运行此语句', + onRun: ({ statement }) => { + window.console.log('[run-statement-button]', statement.text); + } + }); + + runButtonControllers.set(codeEditor, runButtonController); }); const instance = create({ @@ -34,6 +54,8 @@ export default function App() { instance.render(container.current); return () => { + runButtonControllers.forEach((controller) => controller.dispose()); + runButtonControllers.clear(); instance.dispose(); }; }, []);