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 @@
- - 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 KQLF2Edit the text containerAlt+LLaser pointerF11Full 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
DAX dax.dax +SQL Server sql,tsql.sqlKQL kql,kusto.kqlFurther 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
+.daxor.sql, or the code is embedded in adax/sql/tsqlfence.- Image files are png/jpeg/bmp/gif/svg. Linked code files are
.dax,.sql, or.kql, or the code is embedded in adax/sql/tsql/kqlfence.- 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 IReadOnlyListAll { 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 + 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; + } + } + + ///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. + /// + /// 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 IReadOnlyListCommentSignature(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 @@ + + 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 @@+ +net10.0 +SQLBI.Whiteboard.Kql +SQLBI.Whiteboard.Kql ++ ++ + 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 @@ + - File name ends in
Beside Microsoft Whiteboard
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.
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 @@