From 5cee90b1767b9bc1f0a8b338a6d4ce3fc8902447 Mon Sep 17 00:00:00 2001 From: Marco Russo Date: Sat, 5 Sep 2026 23:57:38 +0200 Subject: [PATCH] Add KQL as a text container language, highlighted and formatted Kusto Query Language joins DAX and SQL Server in the language chip, in F6, in the .wimport contract, and in Snippet format order. SQLBI.Whiteboard.Kql wraps Microsoft.Azure.Kusto.Language the way SQLBI.Whiteboard.SqlServer wraps ScriptDOM: Microsoft ships the parser, the classifier, and the formatter, so there is no grammar to write and nothing to keep current by hand. Only the syntax is judged. A board carries a snippet rather than a connection, so the semantic pass reports every table it cannot resolve and all of those would be false alarms. Formatting keeps the author's spacing around the assignment sign, so that a join written as kind=inner survives it. The library default puts spaces there, which rewrites the hints most queries carry. Co-Authored-By: Claude Opus 5 --- .azure/pipelines/build-whiteboard.yaml | 1 + CHANGELOG.md | 11 + Directory.Build.props | 2 +- README.md | 7 +- Whiteboard.sln | 14 + docs/wimport.md | 5 +- site/compare.html | 4 +- site/faq.html | 4 +- site/guide.html | 10 +- site/index.html | 4 +- site/shortcuts.html | 4 +- site/wimport.html | 3 +- .../Import/ImportCatalog.cs | 6 + .../Model/BoardObjects.cs | 4 +- src/SQLBI.Whiteboard.Kql/KqlLanguageEngine.cs | 310 ++++++++++++++++++ .../SQLBI.Whiteboard.Kql.csproj | 10 + src/SQLBI.Whiteboard/SQLBI.Whiteboard.csproj | 1 + src/SQLBI.Whiteboard/SettingsCatalog.cs | 5 +- src/SQLBI.Whiteboard/TextLanguages.cs | 112 ++++++- .../Program.cs | 119 ++++++- .../SQLBI.Whiteboard.Core.SmokeTests.csproj | 1 + 21 files changed, 608 insertions(+), 29 deletions(-) create mode 100644 src/SQLBI.Whiteboard.Kql/KqlLanguageEngine.cs create mode 100644 src/SQLBI.Whiteboard.Kql/SQLBI.Whiteboard.Kql.csproj diff --git a/.azure/pipelines/build-whiteboard.yaml b/.azure/pipelines/build-whiteboard.yaml index 36d22d2..068b28b 100644 --- a/.azure/pipelines/build-whiteboard.yaml +++ b/.azure/pipelines/build-whiteboard.yaml @@ -237,6 +237,7 @@ stages: "$(Build.BinariesDirectory)\SQLBI.Whiteboard.Core.dll" "$(Build.BinariesDirectory)\SQLBI.Whiteboard.Dax.dll" "$(Build.BinariesDirectory)\SQLBI.Whiteboard.Export.dll" + "$(Build.BinariesDirectory)\SQLBI.Whiteboard.Kql.dll" "$(Build.BinariesDirectory)\SQLBI.Whiteboard.SqlServer.dll" "$(Build.BinariesDirectory)\SQLBI.Whiteboard.ThumbnailHandler.dll" failOnStderr: true diff --git a/CHANGELOG.md b/CHANGELOG.md index aae73c6..da4f873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ broke. The heading is parsed by `scripts/release-notes.ps1`, so keep its shape; under it is ordinary Markdown, and the renderer handles paragraphs, lists, links, `code` and **bold**. +## 1.3.1 - 5 September 2026 + +### KQL joins DAX and SQL in text containers +A text container can now be Kusto Query Language. Paste a query or drop a `.kql` file, +choose **KQL** from the title-bar chip, and it is highlighted like the rest: operators, +functions, columns, string literals, and the comments between them. **F6** formats it, +and a query that does not parse is left exactly as it was rather than half rewritten. +The spacing you chose around a join hint, as in `kind=inner`, survives formatting, because +that is how the queries people bring to a session are usually written. Highlighting and +formatting both read Kusto through Microsoft's own parser, and both run locally. + ## 1.3.0 - 3 September 2026 ### Export a board to PowerPoint diff --git a/Directory.Build.props b/Directory.Build.props index c33bae5..c6b72f4 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ scripts/build-installer.ps1 both read it from here, so releasing is a reviewed change to this line rather than an edit in a pipeline variable group. --> - 1.3.0 + 1.3.1 latest enable enable diff --git a/README.md b/README.md index 1e515ee..abfc511 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Use **Copy settings** after finding a useful combination so the exact values can | F2 | Edit the selected text container, or rename the selected frame | | View > Frame | Add a frame the size of the screen: a slide drawn on the board, selected by its edge or its tab, which Export takes as it is | | Language chip | Choose Plain text, DAX, or SQL Server on a selected text container | -| F6 | Format DAX or SQL on the selected text container. In F2, formats in place | +| F6 | Format DAX, SQL, or KQL on the selected text container. In F2, formats in place | | Ctrl+Enter | Commit the F2 edit, including an F6 format done in that session, and return to display mode | | Escape | Cancel the active text edit, close the command strip, or leave full screen or canvas only | | Ctrl+S / Ctrl+O | Save / open a board | @@ -221,9 +221,9 @@ With the mouse, selection is automatic: click a container to move it, or drag th Imported images, LiveViews, and text objects act as containers. A completed stroke is linked when it touches exactly one container, including crossing its edge; a stroke touching multiple containers remains independent. Moving or resizing a container transforms its linked strokes with it. **View → Bring to front** and **View → Send to back** reorder the selected container and those linked strokes. Deleting a container also deletes all of its linked strokes. Undo/redo treats each complete container operation as one action. -Paste plain text to create a selected text container in display mode. **Help → Preferences** has Snippet format order: paste tries those languages from top to bottom and uses the first that accepts the text. Plain text always accepts, so leaving it first keeps every paste as plain text. Recognized extensions (`.dax`, `.sql`, `.txt`) keep their language; other dropped text files use the same order. Choose **Plain text**, **DAX**, or **SQL Server** from the title-bar chip afterward. Press **F6** to format DAX or SQL on the selected container without entering edit. Press **F2** to edit the body; the same list is in the title bar while editing. In F2, **F6** formats in place; **Ctrl+Enter** commits that edit (including the format) and returns to display. **Escape** restores the previous text, language, and dimensions. Text reflows while its edit-mode resize grip changes the width, and the height grows automatically when necessary. Double-click still centers and fits the container. Syntax highlighting applies in both edit and display modes. A language-aware title identifies a defined DAX or SQL object when possible. SQL Server mode targets SQL Server 2025 T-SQL, preserves `GO` batch separators, and leaves invalid scripts unchanged. In display mode, resizing preserves the aspect ratio and scales the complete text visual without reflowing it. +Paste plain text to create a selected text container in display mode. **Help → Preferences** has Snippet format order: paste tries those languages from top to bottom and uses the first that accepts the text. Plain text always accepts, so leaving it first keeps every paste as plain text. Recognized extensions (`.dax`, `.sql`, `.kql`, `.txt`) keep their language; other dropped text files use the same order. Choose **Plain text**, **DAX**, **SQL Server**, or **KQL** from the title-bar chip afterward. Press **F6** to format DAX, SQL, or KQL on the selected container without entering edit. Press **F2** to edit the body; the same list is in the title bar while editing. In F2, **F6** formats in place; **Ctrl+Enter** commits that edit (including the format) and returns to display. **Escape** restores the previous text, language, and dimensions. Text reflows while its edit-mode resize grip changes the width, and the height grows automatically when necessary. Double-click still centers and fits the container. Syntax highlighting applies in both edit and display modes. A language-aware title identifies a defined DAX, SQL, or KQL object when possible. SQL Server mode targets SQL Server 2025 T-SQL, preserves `GO` batch separators, and leaves invalid scripts unchanged. KQL mode reads Kusto through Microsoft's own parser and likewise leaves an invalid query unchanged. In display mode, resizing preserves the aspect ratio and scales the complete text visual without reflowing it. -Image files, and `.txt`, `.dax`, and `.sql` files, can be dropped directly from File Explorer. Their initial center is the board position at which they were dropped. DAX and SQL files open in the matching language mode. Other dropped text files use Snippet format order. +Image files, and `.txt`, `.dax`, `.sql`, and `.kql` files, can be dropped directly from File Explorer. Their initial center is the board position at which they were dropped. DAX, SQL, and KQL files open in the matching language mode. Other dropped text files use Snippet format order. ### `.wimport` recipes @@ -254,6 +254,7 @@ is installed. The source is `vscode/sqlbi-whiteboard`. - `SQLBI.Whiteboard.Core` contains world geometry, camera math, retained board objects, commands, hit testing, and archive persistence. It has no UI-framework dependency. - `SQLBI.Whiteboard.Dax` contains the framework-neutral DAX lexer, parser, classifier, and deterministic formatter adapted from Prompt Assistant. - `SQLBI.Whiteboard.SqlServer` contains the framework-neutral SQL Server 2025 adapter over Microsoft's ScriptDOM parser and script generator. +- `SQLBI.Whiteboard.Kql` contains the framework-neutral Kusto Query Language adapter over Microsoft's own Kusto parser, classifier, and formatter. - `vscode/sqlbi-whiteboard` is a VS Code custom editor that shows `preview.png` from a `.wboard` ZIP. It is not part of the desktop installer. - `SQLBI.Whiteboard` is the WPF shell. `TouchInkCanvas` supplies system-managed wet ink for the finger, while `BoardSurface` renders completed ink, images, text, and selection on a white canvas in camera space, plus the pen's own wet stroke. A transient AvalonEdit surface is overlaid only while a text container is being edited; language services translate parser classifications into WPF text styles. - `SQLBI.Whiteboard/LiveView` owns Windows Graphics Capture and the Direct3D-to-WPF bridge. Capture retains one GPU frame per active LiveView; CPU bitmap conversion occurs only when copying or saving a snapshot. diff --git a/Whiteboard.sln b/Whiteboard.sln index 0f5252c..e2e7b80 100644 --- a/Whiteboard.sln +++ b/Whiteboard.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SQLBI.Whiteboard.Dax", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SQLBI.Whiteboard.SqlServer", "src\SQLBI.Whiteboard.SqlServer\SQLBI.Whiteboard.SqlServer.csproj", "{B2B15228-5085-4652-B7C7-F82F3781A486}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SQLBI.Whiteboard.Kql", "src\SQLBI.Whiteboard.Kql\SQLBI.Whiteboard.Kql.csproj", "{2BDED826-A102-4601-910E-15E3243ADCA2}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SQLBI.Whiteboard.Core.SmokeTests", "tests\SQLBI.Whiteboard.Core.SmokeTests\SQLBI.Whiteboard.Core.SmokeTests.csproj", "{53EF7855-88C7-4D6A-A194-4AF36CF10615}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SQLBI.Whiteboard", "src\SQLBI.Whiteboard\SQLBI.Whiteboard.csproj", "{C9300257-0FA9-47E5-B719-F8C957FE5427}" @@ -67,6 +69,18 @@ Global {B2B15228-5085-4652-B7C7-F82F3781A486}.Release|Any CPU.Build.0 = Release|Any CPU {B2B15228-5085-4652-B7C7-F82F3781A486}.Release|x86.ActiveCfg = Release|Any CPU {B2B15228-5085-4652-B7C7-F82F3781A486}.Release|x86.Build.0 = Release|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Debug|x64.ActiveCfg = Debug|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Debug|x64.Build.0 = Debug|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Debug|x86.ActiveCfg = Debug|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Debug|x86.Build.0 = Debug|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Release|x64.ActiveCfg = Release|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Release|x64.Build.0 = Release|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Release|Any CPU.Build.0 = Release|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Release|x86.ActiveCfg = Release|Any CPU + {2BDED826-A102-4601-910E-15E3243ADCA2}.Release|x86.Build.0 = Release|Any CPU {53EF7855-88C7-4D6A-A194-4AF36CF10615}.Debug|x64.ActiveCfg = Debug|Any CPU {53EF7855-88C7-4D6A-A194-4AF36CF10615}.Debug|x64.Build.0 = Debug|Any CPU {53EF7855-88C7-4D6A-A194-4AF36CF10615}.Debug|Any CPU.ActiveCfg = Debug|Any CPU diff --git a/docs/wimport.md b/docs/wimport.md index c53fe35..8dedd90 100644 --- a/docs/wimport.md +++ b/docs/wimport.md @@ -73,6 +73,7 @@ container. The whole body, fence included, becomes plain text. | --- | --- | --- | | DAX | `dax` | `.dax` | | SQL Server | `sql`, `tsql` | `.sql` | +| KQL | `kql`, `kusto` | `.kql` | Further languages (Python, C#, TypeScript, …) will be extra rows in this table. Do not invent fence tags Whiteboard does not list here: they import as plain text. @@ -134,8 +135,8 @@ A complete sample is `docs/samples/contoso-workshop.wimport`. - File name ends in `.wimport`. - Every container is a `##` heading with exactly one payload. - Images and linked code exist next to the file, using relative paths. -- Image files are png/jpeg/bmp/gif/svg. Code files you link are `.dax` or `.sql`, or the code is - embedded in a `dax` / `sql` / `tsql` fence. +- Image files are png/jpeg/bmp/gif/svg. Code files you link are `.dax`, `.sql`, or `.kql`, or the + code is embedded in a `dax` / `sql` / `tsql` / `kql` fence. - Row breaks are a thematic break on its own line, not a fake heading. - You did not use `http://`, YAML, or explicit positions. - Opening the file in a Markdown preview still reads as a normal document. diff --git a/site/compare.html b/site/compare.html index 34fce9b..4e1d636 100644 --- a/site/compare.html +++ b/site/compare.html @@ -45,7 +45,7 @@

What this is and is not

What this is

-

A native Windows application for a live explanation: pressure-aware ink, an unbounded canvas, SQL and DAX containers with local formatting, LiveView capture of a window or a display, and portable .wboard files. No account. The guide is how a session runs.

+

A native Windows application for a live explanation: pressure-aware ink, an unbounded canvas, DAX, SQL, and KQL containers with local formatting, LiveView capture of a window or a display, and portable .wboard files. No account. The guide is how a session runs.

What this is not

    @@ -66,7 +66,7 @@

    Beside Microsoft Whiteboard

    PlatformsWindows 10 version 2004 or later, 64-bitWindows, web, and Microsoft 365 clients CollaborationOne person at the boardReal-time, multi-user Ink and objectsStrokes can travel with a containerInk and objects on a shared canvas - CodeSQL and DAX containers, F6 formats locallyNot a language surface + CodeDAX, SQL, and KQL containers, F6 formats locallyNot a language surface Live applicationLiveView of a window or a displayNot a capture surface diff --git a/site/faq.html b/site/faq.html index b6655e3..2af5b82 100644 --- a/site/faq.html +++ b/site/faq.html @@ -74,8 +74,8 @@

    Can Released and Dev both be installed?

    What is LiveView, and why Reconnect after open?

    LiveView captures a window or a display onto the board so you can draw over it. Windows cannot save the capture permission. After you reload a board the last frame appears immediately; Reconnect restores the live feed. The guide has the session story.

    -

    Can it format DAX and SQL?

    -

    Yes. Paste or drop a .dax or .sql file, choose the language, press F6. Help → Preferences has Snippet format order for clipboard paste and unrecognized dropped files. Formatting is local. SQL Server mode targets SQL Server 2025 T-SQL and leaves an invalid script unchanged.

    +

    Can it format DAX, SQL, and KQL?

    +

    Yes. Paste or drop a .dax, .sql, or .kql file, choose the language, press F6. Help → Preferences has Snippet format order for clipboard paste and unrecognized dropped files. Formatting is local. SQL Server mode targets SQL Server 2025 T-SQL and leaves an invalid script unchanged; KQL reads Kusto through Microsoft's own parser and does the same.

    How do I bring a workshop in?

    A .wimport file is Markdown that builds image and text containers. Drop it on an open board, or use Import. The import format is the contract. Save always writes a .wboard.

    diff --git a/site/guide.html b/site/guide.html index f4dec57..074568d 100644 --- a/site/guide.html +++ b/site/guide.html @@ -4,7 +4,7 @@ Guide · SQLBI Whiteboard - + @@ -252,7 +252,7 @@

    Keep ink on the slide you are discussing

    Explain code

    -

    Paste SQL or DAX, or drop a .sql or .dax file. Use the title-bar chip to set the language, press F6 to format it locally, then draw on the container. Formatting no longer needs edit mode: F6 works on the container as soon as it is selected.

    +

    Paste DAX, SQL, or KQL, or drop a .dax, .sql, or .kql file. Use the title-bar chip to set the language, press F6 to format it locally, then draw on the container. Formatting no longer needs edit mode: F6 works on the container as soon as it is selected.

    F2 moves the container into edit mode. Ctrl+Enter goes back to display mode and keeps the changes. Escape also goes back to display mode, but cancels the changes and restores the text, language, and size the container had before.

    The two modes resize differently. In edit mode the text reflows as you change the width, so a long line wraps onto more lines at the same size. In display mode a resize scales the whole visual, like a picture of the code, so the room still sees the layout you just formatted.

    @@ -693,11 +693,11 @@

    Containers

    View → Bring to front and View → Send to back reorder the selected container and its linked strokes. Strokes cannot be reordered on their own.

    View → Frame adds a frame the size of the screen: a slide drawn on the board. A frame is selected by its dashed edge or by its title tab, never by its inside, so everything in it stays reachable; drag it to move it, drag the handle to resize it, press F2 to rename it, and Delete removes the frame alone. Moving a frame moves nothing inside it: it is a label over content. Export takes frames as they are, first and in the order they were added, and cuts the rest of the board automatically. Frames never appear in an export or in a preview.

    -

    Text, SQL, and DAX

    +

    Text, DAX, SQL, and KQL

    Paste prefers an image when the clipboard has one, including a file SnagIt or another capture tool left on the clipboard. Paste plain text to create a selected text container.

    SVG counts as an image, whether the source application published it as SVG or the markup was simply copied as text — the output of a DAX SVG measure pastes as a picture, not as a snippet. Copying an SVG container puts the markup and a bitmap on the clipboard together, so an editor receives the source and a slide receives the picture.

    -

    Help → Preferences sets Snippet format order: paste tries those languages from top to bottom. Plain text always matches, so putting it first keeps paste as plain text. Use the title-bar chip to choose Plain text, SQL Server, or DAX afterward.

    -

    F6 formats SQL or DAX on the selected container, without entering edit mode. F2 edits the container, and double-click still centers it and fits it to the canvas; inside an edit F6 formats in place and Ctrl+Enter commits that format with the rest of the edit. SQL Server mode targets SQL Server 2025 T-SQL, keeps GO batch separators, and leaves an invalid script unchanged. Escape restores the previous text, language, and size.

    +

    Help → Preferences sets Snippet format order: paste tries those languages from top to bottom. Plain text always matches, so putting it first keeps paste as plain text. Use the title-bar chip to choose Plain text, DAX, SQL Server, or KQL afterward.

    +

    F6 formats DAX, SQL, or KQL on the selected container, without entering edit mode. F2 edits the container, and double-click still centers it and fits it to the canvas; inside an edit F6 formats in place and Ctrl+Enter commits that format with the rest of the edit. SQL Server mode targets SQL Server 2025 T-SQL, keeps GO batch separators, and leaves an invalid script unchanged; KQL reads Kusto through Microsoft's own parser and likewise leaves an invalid query alone. Escape restores the previous text, language, and size.

    LiveView

    View → LiveView captures a window or a display. The container behaves like an image: move, resize, frame, delete, undo.

    diff --git a/site/index.html b/site/index.html index 46a8a4e..144feac 100644 --- a/site/index.html +++ b/site/index.html @@ -116,7 +116,7 @@

    Everything stays on one canvas

  • - DAX and SQL containers + DAX, SQL, and KQL containers Text blocks with syntax highlighting, and F6 to format the code in place.
  • @@ -140,7 +140,7 @@

    Read next

    Guide - Ink, containers, LiveView, DAX and SQL, and board files. The way a live session actually runs. + Ink, containers, LiveView, DAX, SQL and KQL, and board files. The way a live session actually runs. diff --git a/site/shortcuts.html b/site/shortcuts.html index 5716527..fa3f4b7 100644 --- a/site/shortcuts.html +++ b/site/shortcuts.html @@ -62,7 +62,7 @@

    Shortcuts

    In every session

    -
    F6Format DAX or SQL
    +
    F6Format DAX, SQL, or KQL
    F2Edit the text container
    Alt+LLaser pointer
    F11Full screen
    @@ -122,7 +122,7 @@

    DeleteDelete the selected container and its linked strokes.
    F2Edit the selected text container, or rename the selected frame.
    -
    F6Format DAX or SQL Server on the selected text container. In F2, formats in place; Ctrl+Enter then commits.
    +
    F6Format DAX, SQL Server, or KQL on the selected text container. In F2, formats in place; Ctrl+Enter then commits.
    Ctrl+EnterCommit the F2 edit, including an F6 format done in that session, and return to display mode.
    EscapeCancel the F2 edit (including an uncommitted F6), close the command strip, or leave full screen or canvas only.
    Alt+LLaser pointer.
    diff --git a/site/wimport.html b/site/wimport.html index 051ee46..61a5a62 100644 --- a/site/wimport.html +++ b/site/wimport.html @@ -88,6 +88,7 @@

    Languages shipped today

    DAXdax.dax SQL Serversql, tsql.sql + KQLkql, kusto.kql

    Further languages will be extra rows in this table. Do not invent fence tags Whiteboard does not list here: they import as plain text. Linked text files larger than 1 000 000 bytes are skipped and reported as missing.

    @@ -134,7 +135,7 @@

    Checklist

  • File name ends in .wimport.
  • Every container is a ## heading with exactly one payload.
  • Images and linked code exist next to the file, using relative paths.
  • -
  • Image files are png/jpeg/bmp/gif/svg. Linked code files are .dax or .sql, or the code is embedded in a dax / sql / tsql fence.
  • +
  • Image files are png/jpeg/bmp/gif/svg. Linked code files are .dax, .sql, or .kql, or the code is embedded in a dax / sql / tsql / kql fence.
  • Row breaks are a thematic break on its own line, not a fake heading.
  • You did not use http://, YAML, or explicit positions.
  • Opening the file in a Markdown preview still reads as a normal document.
  • diff --git a/src/SQLBI.Whiteboard.Core/Import/ImportCatalog.cs b/src/SQLBI.Whiteboard.Core/Import/ImportCatalog.cs index d282421..7a959ae 100644 --- a/src/SQLBI.Whiteboard.Core/Import/ImportCatalog.cs +++ b/src/SQLBI.Whiteboard.Core/Import/ImportCatalog.cs @@ -27,6 +27,12 @@ public sealed class ImportCatalog FenceTags = ["sql", "tsql"], Extensions = [".sql"], }, + new ImportLanguage + { + Id = TextLanguageIds.Kql, + FenceTags = ["kql", "kusto"], + Extensions = [".kql"], + }, ], [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".svg"]); diff --git a/src/SQLBI.Whiteboard.Core/Model/BoardObjects.cs b/src/SQLBI.Whiteboard.Core/Model/BoardObjects.cs index 6279e58..de054c8 100644 --- a/src/SQLBI.Whiteboard.Core/Model/BoardObjects.cs +++ b/src/SQLBI.Whiteboard.Core/Model/BoardObjects.cs @@ -245,16 +245,18 @@ public static class TextLanguageIds public const string Plain = "plain"; public const string Dax = "dax"; public const string SqlServer = "sqlserver"; + public const string Kql = "kql"; public static string Normalize(string? languageId) => languageId?.Trim().ToLowerInvariant() switch { Dax => Dax, SqlServer => SqlServer, + Kql => Kql, _ => Plain, }; - public static IReadOnlyList All { get; } = [Plain, Dax, SqlServer]; + public static IReadOnlyList All { get; } = [Plain, Dax, SqlServer, Kql]; public static IReadOnlyList NormalizeOrder(IEnumerable? languageIds) { diff --git a/src/SQLBI.Whiteboard.Kql/KqlLanguageEngine.cs b/src/SQLBI.Whiteboard.Kql/KqlLanguageEngine.cs new file mode 100644 index 0000000..cb50587 --- /dev/null +++ b/src/SQLBI.Whiteboard.Kql/KqlLanguageEngine.cs @@ -0,0 +1,310 @@ +using Kusto.Language; +using Kusto.Language.Editor; +using Kusto.Language.Parsing; +using Kusto.Language.Syntax; + +namespace SQLBI.Whiteboard.Kql; + +public enum KqlTextClassification +{ + Text, + Keyword, + QueryOperator, + Command, + Function, + StringLiteral, + Number, + Comment, + Identifier, + Variable, + Parameter, + TableName, + ColumnName, + DataType, + QueryParameter, + Punctuation, + Operator, + DefinitionName, +} + +public readonly record struct KqlClassifiedSpan( + int Start, + int Length, + KqlTextClassification Classification); + +public readonly record struct KqlParseDiagnostic( + int Offset, + int Line, + int Column, + string Message); + +public sealed record KqlTextAnalysis( + IReadOnlyList Spans, + string? DefinedObjectName, + IReadOnlyList Diagnostics); + +/// +/// Highlighting and formatting for Kusto Query Language, over Microsoft's own parser and +/// formatter. Only the syntax is judged: a board carries a snippet rather than a connection, +/// so the tables and columns it names cannot be resolved and the semantic diagnostics that +/// reports would all be false alarms. +/// +public static class KqlLanguageEngine +{ + /// + /// The author's spacing is kept around the assignment sign, so that a join written as + /// kind=inner and the hints beside it survive formatting the way they were typed. Every + /// other rule is the library default. + /// + private static readonly FormattingOptions Options = + FormattingOptions.Default.WithAssignmentSpacing(DualSpacingStyle.AsIs); + + public static KqlTextAnalysis Analyze(string source) + { + if (string.IsNullOrEmpty(source)) + { + return new KqlTextAnalysis([], null, []); + } + + try + { + KustoCode code = KustoCode.Parse(source); + NameNode? definition = DefinitionNode(code); + var spans = new List(); + foreach (ClassifiedRange range in new KustoCodeService(source) + .GetClassifications(0, source.Length) + .Classifications) + { + int length = Math.Min(range.Length, source.Length - range.Start); + if (range.Start < 0 || length <= 0) + { + continue; + } + + KqlTextClassification classification = + definition is not null && range.Start == definition.Start + ? KqlTextClassification.DefinitionName + : Map(range.Kind); + spans.Add(new KqlClassifiedSpan(range.Start, length, classification)); + } + + return new KqlTextAnalysis(spans, definition?.Name, Diagnostics(code, source)); + } + catch (Exception exception) when (IsRecoverable(exception)) + { + return new KqlTextAnalysis( + [], + null, + [new KqlParseDiagnostic(0, 1, 1, exception.Message)]); + } + } + + public static IReadOnlyList Classify(string source) => + Analyze(source).Spans; + + public static string? DefinedObjectName(string source) + { + if (string.IsNullOrEmpty(source)) + { + return null; + } + + try + { + return DefinitionNode(KustoCode.Parse(source))?.Name; + } + catch (Exception exception) when (IsRecoverable(exception)) + { + return null; + } + } + + /// + /// Formats and reports whether the result is the formatted code. + /// Formatting must never change the code, so the tokens and the comments of the result are + /// compared with those of the input and the original text is returned when they differ. + /// + public static bool TryFormat(string source, out string formatted) + { + formatted = source; + if (string.IsNullOrWhiteSpace(source)) + { + return false; + } + + try + { + // The formatter returns a mixture of line endings whatever it is given, so the + // text handed to it is settled first and the result normalized afterwards. That + // keeps the comparison below between like and like however the caller stored it. + string prepared = ToLineFeeds(source); + if (KustoCode.Parse(prepared).GetSyntaxDiagnostics().Count > 0) + { + return false; + } + + string generated = NormalizeLineEndings( + new KustoCodeService(prepared).GetFormattedText(Options).Text).TrimEnd(); + if (generated.Length == 0 || !PreservesTokens(prepared, generated)) + { + return false; + } + + formatted = generated; + return true; + } + catch (Exception exception) when (IsRecoverable(exception)) + { + return false; + } + } + + /// + /// The name of the object a management command defines, and where it sits. The name is the + /// first one the command itself carries: the properties of a with-clause are nested deeper, + /// so they are passed over rather than mistaken for the name. + /// + private static NameNode? DefinitionNode(KustoCode code) + { + if (code.Syntax.GetFirstDescendant() is null) + { + return null; + } + + foreach (SyntaxNode node in code.Syntax.GetDescendants()) + { + string? name = node switch + { + NameDeclaration declaration => declaration.SimpleName, + NameReference reference => reference.SimpleName, + _ => null, + }; + + if (name is { Length: > 0 } && + node.Parent is CustomNode { Parent: CustomCommand }) + { + return new NameNode(name, node.TextStart); + } + } + + return null; + } + + private static IReadOnlyList Diagnostics(KustoCode code, string source) + { + IReadOnlyList diagnostics = code.GetSyntaxDiagnostics(); + if (diagnostics.Count == 0) + { + return []; + } + + var result = new List(diagnostics.Count); + foreach (Diagnostic diagnostic in diagnostics) + { + int offset = Math.Clamp(diagnostic.Start, 0, source.Length); + (int line, int column) = LineAndColumn(source, offset); + result.Add(new KqlParseDiagnostic(offset, line, column, diagnostic.Message)); + } + + return result; + } + + private static (int Line, int Column) LineAndColumn(string source, int offset) + { + int line = 1; + int lineStart = 0; + for (int index = 0; index < offset; index++) + { + if (source[index] != '\n') + { + continue; + } + + line++; + lineStart = index + 1; + } + + return (line, offset - lineStart + 1); + } + + private static bool PreservesTokens(string before, string after) => + TokenSignature(before).SequenceEqual(TokenSignature(after), StringComparer.Ordinal) && + CommentSignature(before).SequenceEqual(CommentSignature(after), StringComparer.Ordinal); + + private static IReadOnlyList TokenSignature(string source) => + TokenParser.ParseTokens(source) + .Where(token => token.Kind != SyntaxKind.EndOfTextToken && token.Text.Length > 0) + .Select(token => token.Kind + "|" + token.Text) + .ToArray(); + + /// + /// The comments of some KQL, in the order they appear. They are compared separately from + /// the code because a comment lives in the trivia ahead of a token rather than among the + /// tokens, and formatting can move it to the end of the line the code ends up on. + /// + private static IReadOnlyList CommentSignature(string source) + { + var comments = new List(); + foreach (LexicalToken token in TokenParser.ParseTokens(source)) + { + string trivia = token.Trivia; + int index = trivia.IndexOf("//", StringComparison.Ordinal); + while (index >= 0) + { + int end = trivia.IndexOfAny(NewLineCharacters, index); + int stop = end < 0 ? trivia.Length : end; + comments.Add(trivia[index..stop].TrimEnd()); + if (end < 0) + { + break; + } + + index = trivia.IndexOf("//", stop, StringComparison.Ordinal); + } + } + + return comments; + } + + private static readonly char[] NewLineCharacters = ['\r', '\n']; + + private static string ToLineFeeds(string text) => + text.Replace("\r\n", "\n").Replace('\r', '\n'); + + private static string NormalizeLineEndings(string text) => + string.Join(Environment.NewLine, ToLineFeeds(text).Split('\n')); + + private static bool IsRecoverable(Exception exception) => + exception is ArgumentException or FormatException or InvalidOperationException or + IndexOutOfRangeException or NullReferenceException; + + private static KqlTextClassification Map(ClassificationKind kind) => + kind switch + { + ClassificationKind.Comment => KqlTextClassification.Comment, + ClassificationKind.Punctuation => KqlTextClassification.Punctuation, + ClassificationKind.Literal => KqlTextClassification.Number, + ClassificationKind.StringLiteral => KqlTextClassification.StringLiteral, + ClassificationKind.Type => KqlTextClassification.DataType, + ClassificationKind.Column or ClassificationKind.SchemaMember => + KqlTextClassification.ColumnName, + ClassificationKind.Table or ClassificationKind.Database or + ClassificationKind.MaterializedView => KqlTextClassification.TableName, + ClassificationKind.Function => KqlTextClassification.Function, + ClassificationKind.Parameter or ClassificationKind.SignatureParameter => + KqlTextClassification.Parameter, + ClassificationKind.Variable => KqlTextClassification.Variable, + ClassificationKind.Identifier => KqlTextClassification.Identifier, + ClassificationKind.QueryParameter or ClassificationKind.ClientParameter or + ClassificationKind.Option => KqlTextClassification.QueryParameter, + ClassificationKind.ScalarOperator or ClassificationKind.MathOperator => + KqlTextClassification.Operator, + ClassificationKind.QueryOperator => KqlTextClassification.QueryOperator, + ClassificationKind.Command or ClassificationKind.Directive => + KqlTextClassification.Command, + ClassificationKind.Keyword => KqlTextClassification.Keyword, + _ => KqlTextClassification.Text, + }; + + private sealed record NameNode(string Name, int Start); +} diff --git a/src/SQLBI.Whiteboard.Kql/SQLBI.Whiteboard.Kql.csproj b/src/SQLBI.Whiteboard.Kql/SQLBI.Whiteboard.Kql.csproj new file mode 100644 index 0000000..0bfce27 --- /dev/null +++ b/src/SQLBI.Whiteboard.Kql/SQLBI.Whiteboard.Kql.csproj @@ -0,0 +1,10 @@ + + + net10.0 + SQLBI.Whiteboard.Kql + SQLBI.Whiteboard.Kql + + + + + diff --git a/src/SQLBI.Whiteboard/SQLBI.Whiteboard.csproj b/src/SQLBI.Whiteboard/SQLBI.Whiteboard.csproj index 2161ae8..da07d91 100644 --- a/src/SQLBI.Whiteboard/SQLBI.Whiteboard.csproj +++ b/src/SQLBI.Whiteboard/SQLBI.Whiteboard.csproj @@ -29,6 +29,7 @@ + diff --git a/src/SQLBI.Whiteboard/SettingsCatalog.cs b/src/SQLBI.Whiteboard/SettingsCatalog.cs index d4984b7..616964c 100644 --- a/src/SQLBI.Whiteboard/SettingsCatalog.cs +++ b/src/SQLBI.Whiteboard/SettingsCatalog.cs @@ -227,8 +227,9 @@ public static class EraserButton Category = Input, Title = "Snippet format order", Summary = "Which language pasted text is tried as first", - Description = "Paste tries formats from top to bottom and uses the first that accepts the text. Plain text always accepts, so putting it first keeps every paste as plain text. Recognized file extensions (.dax, .sql, .txt) keep their language.", - Keywords = ["snippet", "language", "dax", "sql", "paste", "format", "text", "order"], + Description = "Paste tries formats from top to bottom and uses the first that accepts the text. Plain text always accepts, so putting it first keeps every paste as plain text. Recognized file extensions (.dax, .sql, .kql, .txt) keep their language.", + Keywords = + ["snippet", "language", "dax", "sql", "kql", "paste", "format", "text", "order"], Editor = SettingEditorKind.OrderedList, }, new() diff --git a/src/SQLBI.Whiteboard/TextLanguages.cs b/src/SQLBI.Whiteboard/TextLanguages.cs index db7c0a3..4294bca 100644 --- a/src/SQLBI.Whiteboard/TextLanguages.cs +++ b/src/SQLBI.Whiteboard/TextLanguages.cs @@ -2,6 +2,7 @@ using System.Windows.Media; using SQLBI.Whiteboard.Core.Model; using SQLBI.Whiteboard.Dax; +using SQLBI.Whiteboard.Kql; using SQLBI.Whiteboard.SqlServer; namespace SQLBI.Whiteboard; @@ -40,8 +41,10 @@ internal static class TextLanguageRegistry private static readonly ITextLanguageService Plain = new PlainTextLanguageService(); private static readonly ITextLanguageService Dax = new DaxTextLanguageService(); private static readonly ITextLanguageService SqlServer = new SqlServerTextLanguageService(); + private static readonly ITextLanguageService Kql = new KqlTextLanguageService(); - public static IReadOnlyList All { get; } = [Plain, Dax, SqlServer]; + public static IReadOnlyList All { get; } = + [Plain, Dax, SqlServer, Kql]; public static ITextLanguageService Resolve(string? languageId) { @@ -278,6 +281,113 @@ SqlServerTextClassification.Parenthesis or }; } + private sealed class KqlTextLanguageService : ITextLanguageService + { + private readonly object _cacheLock = new(); + private string? _cachedSource; + private TextLanguageAnalysis? _cachedAnalysis; + private static readonly Brush DefaultText = CreateBrush(0xFF333333); + private static readonly Brush Keyword = CreateBrush(0xFF035ACA); + private static readonly Brush Function = CreateBrush(0xFF795E26); + private static readonly Brush StringLiteral = CreateBrush(0xFFA31515); + private static readonly Brush Number = CreateBrush(0xFFEE7F18); + private static readonly Brush Comment = CreateBrush(0xFF268E26); + private static readonly Brush Variable = CreateBrush(0xFF168C8B); + private static readonly Brush DataType = CreateBrush(0xFF267F99); + private static readonly Brush TableName = CreateBrush(0xFF005A70); + private static readonly Brush QueryParameter = CreateBrush(0xFF6F42C1); + private static readonly Brush Parenthesis = CreateBrush(0xFF808080); + private static readonly Brush DefinitionName = CreateBrush(0xFF202020); + private static readonly Brush Operator = CreateBrush(0xFF5E6470); + + public string Id => TextLanguageIds.Kql; + public string DisplayName => "KQL"; + public string FontFamilyName => "Consolas"; + public bool CanFormat => true; + public bool ShowLineNumbers => false; + public bool WordWrap => true; + public bool UseBackgroundAnalysis => true; + + public TextLanguageAnalysis Analyze(string source, string fallbackTitle) + { + lock (_cacheLock) + { + if (_cachedAnalysis is not null && + string.Equals(_cachedSource, source, StringComparison.Ordinal)) + { + return _cachedAnalysis; + } + } + + KqlTextAnalysis analysis = KqlLanguageEngine.Analyze(source); + string title = string.IsNullOrWhiteSpace(analysis.DefinedObjectName) + ? "KQL Code" + : $"KQL Code of {analysis.DefinedObjectName}"; + StyledTextSpan[] spans = analysis.Spans + .Select(span => new StyledTextSpan( + span.Start, + span.Length, + StyleOf(span.Classification))) + .ToArray(); + var result = new TextLanguageAnalysis(title, spans); + lock (_cacheLock) + { + _cachedSource = source; + _cachedAnalysis = result; + } + + return result; + } + + public bool TryFormat(string source, out string formatted) => + KqlLanguageEngine.TryFormat(source, out formatted); + + public bool TryAccept(string source) + { + try + { + return TryFormat(source, out _); + } + catch (Exception) + { + return false; + } + } + + public override string ToString() => DisplayName; + + private static TextRunStyle StyleOf(KqlTextClassification classification) => + classification switch + { + KqlTextClassification.Keyword or KqlTextClassification.QueryOperator or + KqlTextClassification.Command => + new TextRunStyle(Keyword, FontWeights.Bold, FontStyles.Normal), + KqlTextClassification.Function => + new TextRunStyle(Function, FontWeights.SemiBold, FontStyles.Normal), + KqlTextClassification.StringLiteral => + new TextRunStyle(StringLiteral, FontWeights.Normal, FontStyles.Normal), + KqlTextClassification.Number => + new TextRunStyle(Number, FontWeights.Normal, FontStyles.Normal), + KqlTextClassification.Comment => + new TextRunStyle(Comment, FontWeights.Normal, FontStyles.Italic), + KqlTextClassification.Variable or KqlTextClassification.Parameter => + new TextRunStyle(Variable, FontWeights.SemiBold, FontStyles.Normal), + KqlTextClassification.DataType => + new TextRunStyle(DataType, FontWeights.SemiBold, FontStyles.Normal), + KqlTextClassification.TableName => + new TextRunStyle(TableName, FontWeights.Normal, FontStyles.Normal), + KqlTextClassification.QueryParameter => + new TextRunStyle(QueryParameter, FontWeights.SemiBold, FontStyles.Normal), + KqlTextClassification.Punctuation => + new TextRunStyle(Parenthesis, FontWeights.Normal, FontStyles.Normal), + KqlTextClassification.DefinitionName => + new TextRunStyle(DefinitionName, FontWeights.Bold, FontStyles.Normal), + KqlTextClassification.Operator => + new TextRunStyle(Operator, FontWeights.SemiBold, FontStyles.Normal), + _ => new TextRunStyle(DefaultText, FontWeights.Normal, FontStyles.Normal), + }; + } + private static SolidColorBrush CreateBrush(uint argb) { var brush = new SolidColorBrush(Color.FromArgb( diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs index e6cfc13..5ca1488 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/Program.cs @@ -11,6 +11,7 @@ using SQLBI.Whiteboard.Core.Viewport; using SQLBI.Whiteboard.Dax; using SQLBI.Whiteboard.Export; +using SQLBI.Whiteboard.Kql; using SQLBI.Whiteboard.SqlServer; var camera = new Camera2D(); @@ -108,6 +109,7 @@ DroppedFileImport.Classify("notes.txt") == DroppedFileKind.Text && DroppedFileImport.Classify("measure.dax") == DroppedFileKind.Text && DroppedFileImport.Classify("query.sql") == DroppedFileKind.Text && + DroppedFileImport.Classify("alerts.kql") == DroppedFileKind.Text && DroppedFileImport.Classify("lesson.wimport") == DroppedFileKind.Import && DroppedFileImport.Classify("board.wboard") == DroppedFileKind.Unsupported && DroppedFileImport.Classify("notes.md") == DroppedFileKind.Text, @@ -125,6 +127,7 @@ Assert( DroppedFileImport.LanguageIdFor("measure.dax") == TextLanguageIds.Dax && DroppedFileImport.LanguageIdFor("query.sql") == TextLanguageIds.SqlServer && + DroppedFileImport.LanguageIdFor("alerts.kql") == TextLanguageIds.Kql && DroppedFileImport.LanguageIdFor("notes.txt") == TextLanguageIds.Plain, "Dropped text files should pick a language from the extension."); Assert( @@ -248,6 +251,27 @@ parsedImport.Items[3] is parsedImport.Items[4].Text!.Contains("print", StringComparison.Ordinal), "An unknown fence should fall through to plain text."); +var kqlFromFence = ImportDocument.Parse( + """ + ## Failed logons + ```kql + SecurityEvent | where EventID == 4625 + ``` + + ## Alerts + [alerts](./queries/alerts.kql) + """); +Assert( + kqlFromFence.Items is + [ + { LanguageId: TextLanguageIds.Kql, Text: "SecurityEvent | where EventID == 4625" }, + { LanguageId: TextLanguageIds.Kql, SourcePath: "./queries/alerts.kql" }, + ], + "A kql fence and a .kql link should both import as KQL."); +Assert( + ImportCatalog.Default.LanguageForFence("kusto")?.Id == TextLanguageIds.Kql, + "Kusto is the other name the same fence is written under."); + var pythonCatalog = ImportCatalog.Default.WithLanguage( new ImportLanguage { @@ -926,11 +950,11 @@ defaultSettings.StartupMonitorName is null && eraserButtonRoundTrip.ShowEraserButton, "Settings JSON should round-trip the always-show-the-Eraser choice."); Assert( - defaultSettings.SnippetFormatOrder is ["plain", "dax", "sqlserver"], + defaultSettings.SnippetFormatOrder is ["plain", "dax", "sqlserver", "kql"], "Missing settings should keep Plain text first so paste stays plain text."); Assert( TextLanguageIds.NormalizeOrder(["sqlserver", "plain", "dax", "plain", "python"]) is - ["sqlserver", "plain", "dax"], + ["sqlserver", "plain", "dax", "kql"], "Snippet format order should drop unknowns, keep first-seen order, and fill missing languages."); var snippetOrderRoundTrip = AppSettingsSerializer.Parse( AppSettingsSerializer.Format(new AppSettings @@ -938,10 +962,11 @@ defaultSettings.StartupMonitorName is null && SnippetFormatOrder = ["dax", "sqlserver", "plain"], })); Assert( - snippetOrderRoundTrip.SnippetFormatOrder is ["dax", "sqlserver", "plain"], - "Settings JSON should round-trip snippet format order."); + snippetOrderRoundTrip.SnippetFormatOrder is ["dax", "sqlserver", "plain", "kql"], + "A language added after a board was saved should join the order last, not displace it."); Assert( - AppSettingsSerializer.Parse("{ }").SnippetFormatOrder is ["plain", "dax", "sqlserver"], + AppSettingsSerializer.Parse("{ }").SnippetFormatOrder is + ["plain", "dax", "sqlserver", "kql"], "Partial settings should fill the default snippet format order."); Assert( defaultSettings.PenButtons.Barrel == PenButtonAction.Laser, @@ -1176,6 +1201,90 @@ string SqlText(SqlServerClassifiedSpan span) => TextLanguageIds.Normalize("SQLSERVER") == TextLanguageIds.SqlServer, "The SQL Server text language identifier should normalize for persistence."); +// KQL rides on Microsoft's own parser, so what is checked here is the adapter: that a +// snippet without a database still classifies and formats, that formatting leaves the +// code alone, and that the spacing an author chose around a join hint survives it. +var kqlSource = """ +let Threshold = 10; +let ErrorSummary = (T:(UserId:string, EventType:string)) { + T + | where EventType == "Error" + | summarize ErrorCount = count() by UserId +}; +// Main query execution combining optimization hints +ErrorSummary(AppLogs) +| where ErrorCount > Threshold +| join kind=inner hint.strategy=broadcast UserMetadata on UserId +| project UserId, ErrorCount, Region +"""; +KqlTextAnalysis kqlAnalysis = KqlLanguageEngine.Analyze(kqlSource); +Assert( + kqlAnalysis.Diagnostics.Count == 0, + "A KQL snippet naming tables it cannot resolve should still parse without complaint."); +string KqlText(KqlClassifiedSpan span) => kqlSource.Substring(span.Start, span.Length); +bool KqlHas(KqlTextClassification classification, string text) => + kqlAnalysis.Spans.Any(span => + span.Classification == classification && + KqlText(span).Equals(text, StringComparison.Ordinal)); +Assert( + KqlHas(KqlTextClassification.QueryOperator, "summarize") && + KqlHas(KqlTextClassification.Function, "count") && + KqlHas(KqlTextClassification.Variable, "Threshold") && + KqlHas(KqlTextClassification.Parameter, "T") && + KqlHas(KqlTextClassification.ColumnName, "UserId") && + KqlHas(KqlTextClassification.DataType, "string") && + KqlHas(KqlTextClassification.QueryParameter, "kind"), + "KQL classification should tell operators, functions, and names apart."); +Assert( + KqlHas(KqlTextClassification.StringLiteral, "\"Error\"") && + kqlAnalysis.Spans.Any(span => + span.Classification == KqlTextClassification.Comment && + KqlText(span).StartsWith("// Main query", StringComparison.Ordinal)), + "KQL strings and comments should be classified."); +Assert( + KqlLanguageEngine.TryFormat(kqlSource, out string formattedKql), + "Valid KQL should format successfully."); +Assert( + formattedKql.Contains( + "| join kind=inner hint.strategy=broadcast UserMetadata on UserId", + StringComparison.Ordinal), + "Formatting should leave the spacing an author chose around a join hint alone."); +Assert( + formattedKql.Contains( + "// Main query execution combining optimization hints", + StringComparison.Ordinal) && + formattedKql.Contains("\"Error\"", StringComparison.Ordinal), + "KQL formatting should keep comments and string literals."); +Assert( + KqlLanguageEngine.TryFormat(formattedKql, out string formattedKqlAgain) && + formattedKqlAgain == formattedKql, + "KQL formatting should be idempotent."); +Assert( + KqlLanguageEngine.TryFormat( + kqlSource.Replace("\r\n", "\n").Replace("\n", "\r\n"), + out string formattedCrlfKql) && + formattedCrlfKql == formattedKql, + "Text stored with carriage returns should format to the same code as text without."); +const string invalidKql = "let Threshold ="; +Assert( + !KqlLanguageEngine.TryFormat(invalidKql, out string unchangedInvalidKql) && + unchangedInvalidKql == invalidKql, + "Invalid KQL should be left untouched by formatting."); +Assert( + KqlLanguageEngine.Analyze(invalidKql).Diagnostics.Count > 0, + "Invalid KQL should expose parser diagnostics without interrupting highlighting."); +Assert( + KqlLanguageEngine.DefinedObjectName( + ".create-or-alter function with (docstring = 'Errors per user') " + + "PerUserErrors() { AppLogs | count }") == "PerUserErrors", + "The name a command defines should be the function's, not its first property's."); +Assert( + KqlLanguageEngine.DefinedObjectName(kqlSource) is null, + "An ordinary KQL query should use the generic KQL Code title."); +Assert( + TextLanguageIds.Normalize("KQL") == TextLanguageIds.Kql, + "The KQL text language identifier should normalize for persistence."); + // The SVG handed to the renderer is rewritten around its blind spots. An image's own // clip-path is hoisted onto a group around it, with its transform, so the clip lands // where the author put it (issue 98); letter-spacing comes off text that is anchored at diff --git a/tests/SQLBI.Whiteboard.Core.SmokeTests/SQLBI.Whiteboard.Core.SmokeTests.csproj b/tests/SQLBI.Whiteboard.Core.SmokeTests/SQLBI.Whiteboard.Core.SmokeTests.csproj index c1030c5..d5500ff 100644 --- a/tests/SQLBI.Whiteboard.Core.SmokeTests/SQLBI.Whiteboard.Core.SmokeTests.csproj +++ b/tests/SQLBI.Whiteboard.Core.SmokeTests/SQLBI.Whiteboard.Core.SmokeTests.csproj @@ -7,6 +7,7 @@ +