UNPKG

@kusto/monaco-kusto

Version:

CSL, KQL plugin for the Monaco Editor

590 lines (570 loc) 20 kB
/*!----------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * monaco-kusto version: 13.1.1(178105a761985a9b7c16d45b528f829e1c112ff0) * Released under the MIT license * https://https://github.com/Azure/monaco-kusto/blob/master/README.md *-----------------------------------------------------------------------------*/ import * as monaco from 'monaco-editor/esm/vs/editor/editor.api'; import { editor } from 'monaco-editor/esm/vs/editor/editor.api'; import { T as Token, L as LANGUAGE_ID } from './globals-ab5bacd5.js'; import { g as getCslTypeNameFromClrType, a as getCallName, b as getExpression, c as getInputParametersAsCslString, d as getEntityDataTypeFromCslType } from './schema-c46b688b.js'; export { s as showSchema } from './schema-c46b688b.js'; function getCurrentCommandRange(editor, cursorPosition) { const zeroBasedCursorLineNumber = cursorPosition.lineNumber - 1; const lines = editor.getModel().getLinesContent(); let commandOrdinal = 0; const linesWithCommandOrdinal = []; for (let lineNumber = 0; lineNumber < lines.length; lineNumber++) { let isEmptyLine = lines[lineNumber].trim() === ''; if (isEmptyLine) { // increase commandCounter - we'll be starting a new command. linesWithCommandOrdinal.push({ commandOrdinal: commandOrdinal++, lineNumber }); } else { linesWithCommandOrdinal.push({ commandOrdinal: commandOrdinal, lineNumber }); } // No need to keep scanning if we're past our line and we've seen an empty line. if (lineNumber > zeroBasedCursorLineNumber && commandOrdinal > linesWithCommandOrdinal[zeroBasedCursorLineNumber].commandOrdinal) { break; } } const currentCommandOrdinal = linesWithCommandOrdinal[zeroBasedCursorLineNumber].commandOrdinal; const currentCommandLines = linesWithCommandOrdinal.filter(line => line.commandOrdinal === currentCommandOrdinal); const currentCommandStartLine = currentCommandLines[0].lineNumber + 1; const currentCommandEndLine = currentCommandLines[currentCommandLines.length - 1].lineNumber + 1; // End-column of 1 means no characters will be highlighted - since columns are 1-based in monaco apis. // Start-column of 1 and End column of 2 means 1st character is selected. // Thus if a line has n column and we need to provide n+1 so that the entire line will be highlighted. const commandEndColumn = lines[currentCommandEndLine - 1].length + 1; return new monaco.Range(currentCommandStartLine, 1, currentCommandEndLine, commandEndColumn); } /** * Extending ICode editor to contain additional kusto-specific methods. * note that the extend method needs to be called at least once to take affect, otherwise this here code is useless. */ function extend(editor) { const proto = Object.getPrototypeOf(editor); proto.getCurrentCommandRange = function (cursorPosition) { getCurrentCommandRange(this, cursorPosition); }; } /** * Highlights the command that surround cursor location */ class KustoCommandHighlighter { static ID = 'editor.contrib.kustoCommandHighlighter'; static CURRENT_COMMAND_HIGHLIGHT = { className: 'selectionHighlight' }; disposables = []; decorations = []; /** * Register to cursor movement and selection events. * @param editor monaco editor instance */ constructor(editor) { this.editor = editor; // Note that selection update is triggered not only for selection changes, but also just when no text selection is occurring and cursor just moves around. // This case is counted as a 0-length selection starting and ending on the cursor position. this.editor.onDidChangeCursorSelection(changeEvent => { if (this.editor.getModel().getLanguageId() !== 'kusto') { return; } this.highlightCommandUnderCursor(changeEvent); }); } getId() { return KustoCommandHighlighter.ID; } dispose() { this.disposables.forEach(d => d.dispose()); } highlightCommandUnderCursor(changeEvent) { // Looks like the user selected a bunch of text. we don't want to highlight the entire command in this case - since highlighting // the text is more helpful. if (!changeEvent.selection.isEmpty()) { this.decorations = this.editor.deltaDecorations(this.decorations, []); return; } const commandRange = getCurrentCommandRange(this.editor, changeEvent.selection.getStartPosition()); const decorations = [{ range: commandRange, options: KustoCommandHighlighter.CURRENT_COMMAND_HIGHLIGHT }]; this.decorations = this.editor.deltaDecorations(this.decorations, decorations); } } class KustoCommandFormatter { actionAdded = false; constructor(editor) { this.editor = editor; // selection also represents no selection - for example the event gets triggered when moving cursor from point // a to point b. in the case start position will equal end position. editor.onDidChangeCursorSelection(changeEvent => { if (this.editor.getModel().getLanguageId() !== 'kusto') { return; } // Theoretically you would expect this code to run only once in onDidCreateEditor. // Turns out that onDidCreateEditor is fired before the IStandaloneEditor is completely created (it is emitted by // the super ctor before the child ctor was able to fully run). // Thus we don't have a key binding provided yet when onDidCreateEditor is run, which is essential to call addAction. // By adding the action here in onDidChangeCursorSelection we're making sure that the editor has a key binding provider, // and we just need to make sure that this happens only once. if (!this.actionAdded) { editor.addAction({ id: 'editor.action.kusto.formatCurrentCommand', label: 'Format Command Under Cursor', keybindings: [monaco.KeyMod.chord(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyF)], run: ed => { editor.trigger('KustoCommandFormatter', 'editor.action.formatSelection', null); }, contextMenuGroupId: '1_modification' }); this.actionAdded = true; } }); } } function dateStringWrapper(editor) { editor.onDidPaste(event => { const { range } = event; if (!range) return; const model = editor.getModel(); const pasted = model.getValueInRange(range); if (!isBareIsoDate(pasted)) return; const wrapped = `datetime(${pasted})`; const edit = { range, text: wrapped, forceMoveMarkers: true }; const cursorStateComputer = () => { const startOffset = model.getOffsetAt(range.getStartPosition()); const endPos = model.getPositionAt(startOffset + wrapped.length); return [new monaco.Selection(endPos.lineNumber, endPos.column, endPos.lineNumber, endPos.column)]; }; editor.executeEdits('paste-date', [edit], cursorStateComputer); }); } function isBareIsoDate(text) { const s = text.trim(); return /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}Z)?$/.test(s); } let ThemeName = /*#__PURE__*/function (ThemeName) { ThemeName["light"] = "kusto-light"; ThemeName["dark"] = "kusto-dark"; return ThemeName; }({}); const colors = { white: '#DCDCDC', lightGoldenrodYellow: '#FAFAD2', softGold: '#D7BA7D', paleChestnut: '#D69D85', paleVioletRed: '#DB7093', firebrick: '#B22222', orangeRed: '#CE3600', mediumVioletRed: '#C71585', magenta: '#FF00FF', // for debugging darkOrchid: '#9932CC', darkViolet: '#9400D3', midnightBlue: '#191970', blue: '#0000FF', blueSapphire: '#004E8C', tealBlue: '#2B91AF', skyBlue: '#569CD6', lightSkyBlue: '#92CAF4', mediumTurquoise: '#4EC9B0', oliveDrab: '#608B4E', green: '#008000', jetBlack: '#1B1A19', black: '#000000' }; const light = { base: 'vs', inherit: true, rules: [{ token: '', foreground: colors.black }, { token: Token.PlainText, foreground: colors.black }, { token: Token.Comment, foreground: colors.green }, { token: Token.Punctuation, foreground: colors.black }, { token: Token.Directive, foreground: colors.darkViolet }, { token: Token.Literal, foreground: colors.black }, { token: Token.StringLiteral, foreground: colors.firebrick }, { token: Token.Type, foreground: colors.blue }, { token: Token.Column, foreground: colors.mediumVioletRed }, { token: Token.Table, foreground: colors.darkOrchid }, { token: Token.Database, foreground: colors.darkOrchid }, { token: Token.Function, foreground: colors.blue }, { token: Token.Parameter, foreground: colors.midnightBlue }, { token: Token.Variable, foreground: colors.midnightBlue }, { token: Token.Identifier, foreground: colors.black }, { token: Token.ClientParameter, foreground: colors.tealBlue }, { token: Token.QueryParameter, foreground: colors.tealBlue }, { token: Token.ScalarParameter, foreground: colors.blue }, { token: Token.MathOperator, foreground: colors.black }, { token: Token.QueryOperator, foreground: colors.orangeRed }, { token: Token.Command, foreground: colors.blue }, { token: Token.Keyword, foreground: colors.blue }, { token: Token.MaterializedView, foreground: colors.darkOrchid }, { token: Token.SchemaMember, foreground: colors.black }, { token: Token.SignatureParameter, foreground: colors.black }, { token: Token.Option, foreground: colors.black }], colors: {} }; const dark = { base: 'vs-dark', inherit: true, rules: [{ token: '', foreground: colors.white }, { token: Token.PlainText, foreground: colors.white }, { token: Token.Comment, foreground: colors.oliveDrab }, { token: Token.Punctuation, foreground: colors.white }, { token: Token.Directive, foreground: colors.lightGoldenrodYellow }, { token: Token.Literal, foreground: colors.white }, { token: Token.StringLiteral, foreground: colors.paleChestnut }, { token: Token.Type, foreground: colors.skyBlue }, { token: Token.Column, foreground: colors.paleVioletRed }, { token: Token.Table, foreground: colors.softGold }, { token: Token.Database, foreground: colors.softGold }, { token: Token.Function, foreground: colors.skyBlue }, { token: Token.Parameter, foreground: colors.lightSkyBlue }, { token: Token.Variable, foreground: colors.lightSkyBlue }, { token: Token.Identifier, foreground: colors.white }, { token: Token.ClientParameter, foreground: colors.tealBlue }, { token: Token.QueryParameter, foreground: colors.tealBlue }, { token: Token.ScalarParameter, foreground: colors.skyBlue }, { token: Token.MathOperator, foreground: colors.white }, { token: Token.QueryOperator, foreground: colors.mediumTurquoise }, { token: Token.Command, foreground: colors.skyBlue }, { token: Token.Keyword, foreground: colors.skyBlue }, { token: Token.MaterializedView, foreground: colors.softGold }, { token: Token.SchemaMember, foreground: colors.white }, { token: Token.SignatureParameter, foreground: colors.white }, { token: Token.Option, foreground: colors.white }], colors: { 'editor.background': colors.jetBlack, 'editorSuggestWidget.selectedBackground': colors.blueSapphire } }; const themes = [{ name: ThemeName.light, data: light }, { name: ThemeName.dark, data: dark }]; function getRangeHtml(model, range) { const { startLineNumber, endLineNumber, endColumn } = range; const isLastLineEmpty = endColumn === 1; const actualLastLine = isLastLineEmpty ? endLineNumber - 1 : endLineNumber; const totalLines = actualLastLine - startLineNumber + 1; const colorizedLines = new Array(totalLines).fill(undefined).map((_, index) => editor.colorizeModelLine(model, startLineNumber + index)); return colorizedLines.join('<br/>'); } /** * Registers a Kusto-specific action to close the IntelliSense suggestions popup. * * Note: * We register the action on the first cursor movement, not on editor creation, * because Monaco fires 'onDidCreateEditor' before the keybinding service is fully initialized. * Waiting for a cursor event guarantees that the editor is fully ready * and allows safe registration of actions with keybindings. */ class CaseInvertor { actionsRegistered = false; constructor(editor) { this.editor = editor; this.ctrlKeyMod = this.isMac() ? monaco.KeyMod.WinCtrl : monaco.KeyMod.CtrlCmd; this.editor.onDidChangeCursorSelection(() => { if (this.editor.getModel()?.getLanguageId() !== 'kusto') { return; } if (!this.actionsRegistered) { this.registerUpperCaseHandler(); this.registerLowerCaseHandler(); this.actionsRegistered = true; } }); } registerUpperCaseHandler() { this.editor.addAction({ id: 'kusto.toUpperCase', label: 'To Upper Case', keybindings: [this.ctrlKeyMod | monaco.KeyMod.Shift | monaco.KeyCode.KeyU], run: editor => { const selectedRange = editor.getSelection(); const selectedText = editor.getModel().getValueInRange(selectedRange); const upperCaseText = selectedText.toUpperCase(); editor.executeEdits('toUpperCase', [{ range: selectedRange, text: upperCaseText }]); } }); } registerLowerCaseHandler() { this.editor.addAction({ id: 'kusto.toLowerCase', label: 'To Lower Case', keybindings: [this.ctrlKeyMod | monaco.KeyMod.Shift | monaco.KeyCode.KeyL], run: editor => { const selectedRange = editor.getSelection(); const selectedText = editor.getModel().getValueInRange(selectedRange); const lowerCaseText = selectedText.toLowerCase(); editor.executeEdits('toLowerCase', [{ range: selectedRange, text: lowerCaseText }]); } }); } isMac() { const uaData = navigator.userAgentData; return uaData ? uaData.platform === 'macOS' : /Mac|iPod|iPhone|iPad/.test(navigator.platform); } } // --- Kusto configuration and defaults --------- class LanguageServiceDefaultsImpl { _onDidChange = new monaco.Emitter(); // in milliseconds. For example - this is 2 minutes 2 * 60 * 1000 constructor(languageSettings) { this.setLanguageSettings(languageSettings); // default to never kill worker when idle. // reason: when killing worker - schema gets lost. We transmit the schema back to main process when killing // the worker, but in some extreme cases web worker runs out of memory while stringifying the schema. // This stems from the fact that web workers have much more limited memory that the main process. // An alternative solution (not currently implemented) is to just save the schema in the main process whenever calling // setSchema. That way we don't need to stringify the schema on the worker side when killing the web worker. this._workerMaxIdleTime = 0; } get onDidChange() { return this._onDidChange.event; } get languageSettings() { return this._languageSettings; } setLanguageSettings(options) { this._languageSettings = options || Object.create(null); this._onDidChange.fire(this); } setMaximumWorkerIdleTime(value) { // doesn't fire an event since no // worker restart is required here this._workerMaxIdleTime = value; } getWorkerMaxIdleTime() { return this._workerMaxIdleTime; } } const defaultLanguageSettings = { includeControlCommands: true, newlineAfterPipe: true, openSuggestionDialogAfterPreviousSuggestionAccepted: true, enableHover: true, formatter: { indentationSize: 4, pipeOperatorStyle: 'Smart' }, syntaxErrorAsMarkDown: { enableSyntaxErrorAsMarkDown: false }, enableQueryWarnings: false, enableQuerySuggestions: false, disabledDiagnosticCodes: [], quickFixCodeActions: ['Change to', 'FixAll'], enableQuickFixes: false, completionOptions: { includeExtendedSyntax: false } }; function getKustoWorker() { return new Promise((resolve, reject) => { withMode(mode => { mode.getKustoWorker().then(resolve, reject); }); }); } function withMode(callback) { return import('./kustoMode.js').then(callback); } const kustoDefaults = new LanguageServiceDefaultsImpl(defaultLanguageSettings); monaco.languages.onLanguage('kusto', async () => { await withMode(mode => mode.setupMode(kustoDefaults, monaco)); }); monaco.languages.register({ id: LANGUAGE_ID, extensions: ['.csl', '.kql'] }); themes.forEach(({ name, data }) => monaco.editor.defineTheme(name, data)); // Initialize kusto specific language features that don't currently have a natural way to extend using existing apis. // Most other language features are initialized in kustoMode.ts monaco.editor.onDidCreateEditor(editor => { if (window.MonacoEnvironment?.globalAPI) { // hook up extension methods to editor. extend(editor); } // TODO: asked if there's a cleaner way to register an editor contribution. looks like monaco has an internal contribution registrar but it's no exposed in the API. // https://stackoverflow.com/questions/46700245/how-to-add-an-ieditorcontribution-to-monaco-editor new KustoCommandHighlighter(editor); if (isStandaloneCodeEditor(editor)) { new KustoCommandFormatter(editor); new CaseInvertor(editor); } triggerSuggestDialogWhenCompletionItemSelected(editor); dateStringWrapper(editor); }); function triggerSuggestDialogWhenCompletionItemSelected(editor) { editor.onDidChangeCursorSelection(event => { // checking the condition inside the event makes sure we will stay up to date when kusto configuration changes at runtime. if (kustoDefaults && kustoDefaults.languageSettings && kustoDefaults.languageSettings.openSuggestionDialogAfterPreviousSuggestionAccepted) { var didAcceptSuggestion = event.source === 'snippet' && event.reason === monaco.editor.CursorChangeReason.NotSet; // If the word at the current position is not null - meaning we did not add a space after completion. // In this case we don't want to activate the eager mode, since it will display the current selected word.. if (!didAcceptSuggestion || editor.getModel().getWordAtPosition(event.selection.getPosition()) !== null) { return; } event.selection; // OK so now we in a situation where we know a suggestion was selected, and we want to trigger another one. // the only problem is that the suggestion widget itself listens to this same event in order to know it needs to close. // The only problem is that we're ahead in line, so we're triggering a suggest operation that will be shut down once // the next callback is called. This is why we're waiting here - to let all the callbacks run synchronously and be // the 'last' subscriber to run. Granted this is hacky, but until monaco provides a specific event for suggestions, // this is the best we have. setTimeout(() => editor.trigger('monaco-kusto', 'editor.action.triggerSuggest', {}), 10); } }); } function isStandaloneCodeEditor(editor) { return editor.addAction !== undefined; } const globalApi = { getCslTypeNameFromClrType, getCallName, getExpression, getInputParametersAsCslString, getEntityDataTypeFromCslType, kustoDefaults, getKustoWorker, getRangeHtml }; monaco.languages.kusto = globalApi; export { getCallName, getCslTypeNameFromClrType, getEntityDataTypeFromCslType, getExpression, getInputParametersAsCslString, getKustoWorker, getRangeHtml, kustoDefaults };