Highlighted code blocks inside the app (#1153)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-11-04 07:58:57 +01:00
1 parent 0b42c0288e
commit f4a86c2fb8
60 files changed
+2550 -41

No files matched your search

@@ -4147,6 +4147,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1335399555"] = "O
-- Manage your templates -- Manage your templates
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T3058934130"] = "Manage your templates" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T3058934130"] = "Manage your templates"
-- Copied!
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CODEBLOCKCOPYBUTTON::T1283727788"] = "Copied!"
-- Region -- Region
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T1227782301"] = "Region" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T1227782301"] = "Region"
@@ -245,7 +245,7 @@
var segmentContent = segment.GetContent(renderPlan.Source); var segmentContent = segment.GetContent(renderPlan.Source);
if (segment.Type is MarkdownRenderSegmentType.MARKDOWN) if (segment.Type is MarkdownRenderSegmentType.MARKDOWN)
{ {
<ExportableMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" Files="@this.FilesWithSaveButton" ValueStart="@segment.Start"> <ExportableMarkdown @key="@segment.RenderKey" Value="@segmentContent" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.CHAT_MARKDOWN_PIPELINE" Files="@this.FilesWithSaveButton" ValueStart="@segment.Start">
<CodeBlockActions Context="file"> <CodeBlockActions Context="file">
<MudTooltip Text="@this.SaveAsLabel(file)" Placement="Placement.Top"> <MudTooltip Text="@this.SaveAsLabel(file)" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.Save" Color="Color.Primary" Variant="Variant.Filled" Class="ma-2" OnClick="@(() => this.ExportFile(file))"/> <MudIconButton Icon="@Icons.Material.Filled.Save" Color="Color.Primary" Variant="Variant.Filled" Class="ma-2" OnClick="@(() => this.ExportFile(file))"/>
@@ -0,0 +1,97 @@
using AIStudio.Tools.PluginSystem;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
namespace AIStudio.Components;
/// <summary>
/// The copy button of a code block which AI Studio highlights itself, made like the one of
/// MudBlazor.Markdown, which is internal: the same classes, so that it appears at the same place while
/// the pointer is on the block, and the same confirmation for a moment after a click.
/// </summary>
/// <remarks>
/// It copies through the clipboard service of the app, the native clipboard of the runtime, which the
/// button of the library falls back to as well.
/// </remarks>
public sealed class CodeBlockCopyButton : ComponentBase
{
private const string CSS_CLASS = "ma-2 mud-markdown-code-highlight-copybtn";
private static readonly TimeSpan CONFIRMATION_DURATION = TimeSpan.FromSeconds(1.25);
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(CodeBlockCopyButton).Namespace, nameof(CodeBlockCopyButton));
/// <summary>
/// The code to copy.
/// </summary>
[Parameter]
public string Text { get; set; } = string.Empty;
[Inject]
private IMudMarkdownClipboardService ClipboardService { get; init; } = null!;
private bool isCopied;
/// <summary>
/// What a copy button of a code block confirms with, those of MudBlazor.Markdown included.
/// </summary>
internal static string ConfirmationText => TB("Copied!");
#region Overrides of ComponentBase
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
if (!this.isCopied)
{
builder.OpenComponent<MudIconButton>(0);
builder.AddComponentParameter(1, nameof(MudIconButton.OnClick), EventCallback.Factory.Create<MouseEventArgs>(this, this.CopyAsync));
builder.AddComponentParameter(2, nameof(MudIconButton.Class), CSS_CLASS);
builder.AddComponentParameter(3, nameof(MudIconButton.Icon), Icons.Material.Rounded.ContentCopy);
builder.AddComponentParameter(4, nameof(MudIconButton.Color), Color.Primary);
builder.AddComponentParameter(5, nameof(MudIconButton.Variant), Variant.Filled);
builder.AddComponentParameter(6, nameof(MudIconButton.Size), Size.Medium);
builder.CloseComponent();
return;
}
builder.OpenComponent<MudTooltip>(10);
builder.AddComponentParameter(11, nameof(MudTooltip.Text), ConfirmationText);
builder.AddComponentParameter(12, nameof(MudTooltip.Arrow), true);
builder.AddComponentParameter(13, nameof(MudTooltip.Placement), Placement.Left);
builder.AddComponentParameter(14, nameof(MudTooltip.Visible), true);
builder.AddComponentParameter(15, nameof(MudTooltip.ShowOnClick), false);
builder.AddComponentParameter(16, nameof(MudTooltip.ShowOnFocus), false);
builder.AddComponentParameter(17, nameof(MudTooltip.ShowOnHover), false);
builder.AddComponentParameter(18, nameof(MudTooltip.RootClass), CSS_CLASS);
builder.AddComponentParameter(19, nameof(MudTooltip.ChildContent), (RenderFragment)(content =>
{
content.OpenComponent<MudIconButton>(0);
content.AddComponentParameter(1, nameof(MudIconButton.Icon), Icons.Material.Rounded.DoneAll);
content.AddComponentParameter(2, nameof(MudIconButton.Color), Color.Success);
content.AddComponentParameter(3, nameof(MudIconButton.Variant), Variant.Filled);
content.AddComponentParameter(4, nameof(MudIconButton.Size), Size.Medium);
content.CloseComponent();
}));
builder.CloseComponent();
}
#endregion
private async Task CopyAsync()
{
if (string.IsNullOrEmpty(this.Text))
return;
await this.ClipboardService.CopyToClipboardAsync(this.Text);
this.isCopied = true;
this.StateHasChanged();
await Task.Delay(CONFIRMATION_DURATION);
this.isCopied = false;
this.StateHasChanged();
}
}
@@ -36,7 +36,7 @@
break; break;
case ContentView.FORMATTED: case ContentView.FORMATTED:
<MudMarkdown Value="@this.shownText" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.CONTENT_MARKDOWN_PIPELINE"/> <HighlightedMarkdown Value="@this.shownText" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.CONTENT_MARKDOWN_PIPELINE"/>
break; break;
default: default:
@@ -4,14 +4,14 @@ using Microsoft.AspNetCore.Components.Rendering;
namespace AIStudio.Components; namespace AIStudio.Components;
/// <summary> /// <summary>
/// Renders Markdown the way MudMarkdown does, and shows actions next to every code block which is /// Renders Markdown the way HighlightedMarkdown does, and shows actions next to every code block which
/// one of the files of its message, such as a button to save that block. /// is one of the files of its message, such as a button to save that block.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// In a long answer, the export menu at the top of the message lies far away from the code block /// In a long answer, the export menu at the top of the message lies far away from the code block
/// the user is reading. MudMarkdown renders every code block through RenderCodeBlock, the highlighted /// the user is reading. MudMarkdown renders every code block through RenderCodeBlock, the highlighted
/// code together with its copy button, and lets us override that. We wrap what it renders and put our /// code together with its copy button, and lets us override that. We wrap what HighlightedMarkdown
/// actions beside it, so the copy button stays exactly as the library made it. /// renders and put our actions beside it, so the copy button stays exactly as it was made.
/// ///
/// A block finds its file by where it starts. The chat renders an answer in parts, cut around its /// A block finds its file by where it starts. The chat renders an answer in parts, cut around its
/// formulas, and Markdig reads each part on its own: a block knows where it starts within its part /// formulas, and Markdig reads each part on its own: a block knows where it starts within its part
@@ -19,7 +19,7 @@ namespace AIStudio.Components;
/// A block without a file, such as a formula or a block the model never closed, gets no actions, /// A block without a file, such as a formula or a block the model never closed, gets no actions,
/// just as the export menu does not offer it. /// just as the export menu does not offer it.
/// </remarks> /// </remarks>
public sealed class ExportableMarkdown : MudMarkdown public sealed class ExportableMarkdown : HighlightedMarkdown
{ {
/// <summary> /// <summary>
/// The files of the message, as PlainFileExport.ExtractFiles read them out of its text. Without /// The files of the message, as PlainFileExport.ExtractFiles read them out of its text. Without
@@ -48,7 +48,7 @@ public sealed class ExportableMarkdown : MudMarkdown
/// <returns>The file, or null when no file starts there.</returns> /// <returns>The file, or null when no file starts there.</returns>
internal static MessageFile? FileAt(IReadOnlyList<MessageFile> files, int position) => files.FirstOrDefault(file => file.Position == position); internal static MessageFile? FileAt(IReadOnlyList<MessageFile> files, int position) => files.FirstOrDefault(file => file.Position == position);
#region Overrides of MudMarkdown #region Overrides of HighlightedMarkdown
/// <inheritdoc /> /// <inheritdoc />
protected override void RenderCodeBlock(in RenderTreeBuilder builder, ref int elementIndex, in Markdig.Syntax.CodeBlock code, in string? info) protected override void RenderCodeBlock(in RenderTreeBuilder builder, ref int elementIndex, in Markdig.Syntax.CodeBlock code, in string? info)
@@ -0,0 +1,83 @@
using AIStudio.Tools.CodeHighlighting;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
namespace AIStudio.Components;
/// <summary>
/// A code block in a language which AI Studio highlights itself, see CodeHighlighters. It is rendered
/// in the markup of MudBlazor.Markdown, so that it looks like the blocks the library highlights: the
/// same classes, the same colors of the theme, and the same copy button.
/// </summary>
/// <remarks>
/// Its parameters are strings only. Blazor then renders it again only when the code or the language
/// change, so while an answer streams in, only the block which grows is highlighted again.
/// </remarks>
public sealed class HighlightedCodeBlock : ComponentBase
{
/// <summary>
/// The code of the block.
/// </summary>
[Parameter]
public string Code { get; set; } = string.Empty;
/// <summary>
/// The language behind the opening fence, as the model wrote it, for example py.
/// </summary>
[Parameter]
public string Language { get; set; } = string.Empty;
#region Overrides of ComponentBase
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "div");
builder.AddAttribute(1, "class", "hljs mud-markdown-code-highlight");
builder.OpenComponent<CodeBlockCopyButton>(2);
builder.AddComponentParameter(3, nameof(CodeBlockCopyButton.Text), this.Code);
builder.CloseComponent();
//
// MudMarkdown leaves the typography of its code blocks at the default value, and so do we:
//
builder.OpenComponent<MudText>(4);
builder.AddComponentParameter(5, nameof(MudText.HtmlTag), "pre");
builder.AddComponentParameter(6, nameof(MudText.Typo), default(Typo));
builder.AddComponentParameter(7, nameof(MudText.ChildContent), (RenderFragment)this.RenderCode);
builder.CloseComponent();
builder.CloseElement();
}
#endregion
private void RenderCode(RenderTreeBuilder builder)
{
builder.OpenElement(0, "code");
builder.AddAttribute(1, "class", $"hljs language-{this.Language}");
if (CodeHighlighters.TryGetRules(this.Language, out var rules))
{
var position = 0;
foreach (var token in CodeTokenizer.Tokenize(this.Code, rules))
{
if (token.Start > position)
builder.AddContent(2, this.Code[position..token.Start]);
builder.OpenElement(3, "span");
builder.AddAttribute(4, "class", token.Kind.ToCssClass());
builder.AddContent(5, this.Code.Substring(token.Start, token.Length));
builder.CloseElement();
position = token.Start + token.Length;
}
if (position < this.Code.Length)
builder.AddContent(6, this.Code[position..]);
}
else
builder.AddContent(7, this.Code);
builder.CloseElement();
}
}
@@ -0,0 +1,55 @@
using AIStudio.Tools.CodeHighlighting;
using Microsoft.AspNetCore.Components.Rendering;
namespace AIStudio.Components;
/// <summary>
/// Renders Markdown the way MudMarkdown does, and highlights the code blocks of more languages than
/// the library does, such as Python or SQL.
/// </summary>
/// <remarks>
/// MudBlazor.Markdown highlights only a few languages itself, and its highlighter is internal.
/// MudMarkdown renders every code block through RenderCodeBlock, though, and lets us override that.
/// A block in one of the languages of CodeHighlighters becomes a HighlightedCodeBlock; any other block
/// stays as the library renders it. A block which names no language gets the one its code is written in,
/// see CodeLanguageDetector, the same as when it is saved as a file.
/// </remarks>
public class HighlightedMarkdown : MudMarkdown
{
/// <summary>
/// The text of a code block: its lines, each with the line break it came with.
/// </summary>
/// <param name="code">The code block, as Markdig read it.</param>
/// <returns>The text of the block.</returns>
internal static string TextOf(Markdig.Syntax.CodeBlock code) => code.Lines.ToString();
#region Overrides of MudMarkdown
/// <inheritdoc />
protected override void RenderCodeBlock(in RenderTreeBuilder builder, ref int elementIndex, in Markdig.Syntax.CodeBlock code, in string? info)
{
var text = TextOf(code);
var language = CodeLanguageDetector.FenceLanguageOf(info, text);
if (!CodeHighlighters.TryGetRules(language, out _))
{
//
// The copy button of the library confirms in the language of the app, as ours does:
//
this.Styling.CodeBlock.CopyButtonText = CodeBlockCopyButton.ConfirmationText;
base.RenderCodeBlock(builder, ref elementIndex, code, language);
return;
}
//
// Our frames are numbered by their place in this method, as Blazor asks for. The frames of
// the library keep the running count it hands through every render method instead:
//
builder.OpenComponent<HighlightedCodeBlock>(0);
builder.AddComponentParameter(1, nameof(HighlightedCodeBlock.Code), text);
builder.AddComponentParameter(2, nameof(HighlightedCodeBlock.Language), language);
builder.CloseComponent();
}
#endregion
}
@@ -23,19 +23,6 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDispo
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage; private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
/// <summary>
/// How Markdown rendered by this component looks: its code blocks follow the color theme of the app.
/// </summary>
/// <remarks>
/// Every component which renders Markdown wants the same, so it is written down once here instead
/// of in each of them. Read on every render: a change of the color theme renders every component
/// again, cf. ProcessMessage, and the code blocks change along with it.
/// </remarks>
protected MudMarkdownStyling MarkdownStyling => new()
{
CodeBlock = { Theme = this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default },
};
#region Overrides of ComponentBase #region Overrides of ComponentBase
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -10,7 +10,7 @@
<MudTabPanel Text="@T("Preview")" Icon="@Icons.Material.Filled.Article"> <MudTabPanel Text="@T("Preview")" Icon="@Icons.Material.Filled.Article">
<MudField Variant="Variant.Outlined" AdornmentIcon="@Icons.Material.Filled.Visibility" Adornment="Adornment.Start" Label="@T("Assistant draft")" FullWidth="true" Class="ma-2 pe-4"> <MudField Variant="Variant.Outlined" AdornmentIcon="@Icons.Material.Filled.Visibility" Adornment="Adornment.Start" Label="@T("Assistant draft")" FullWidth="true" Class="ma-2 pe-4">
<div style="max-height: 58vh; overflow-y: auto;"> <div style="max-height: 58vh; overflow-y: auto;">
<MudMarkdown Value="@this.DraftMarkdown" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE"/> <HighlightedMarkdown Value="@this.DraftMarkdown" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE"/>
</div> </div>
</MudField> </MudField>
</MudTabPanel> </MudTabPanel>
@@ -14,7 +14,7 @@
FullWidth="true" FullWidth="true"
Class="ma-2 pe-4"> Class="ma-2 pe-4">
<div style="max-height: 62vh; overflow-y: auto;"> <div style="max-height: 62vh; overflow-y: auto;">
<MudMarkdown Value="@this.GuidelineMarkdown" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE"/> <HighlightedMarkdown Value="@this.GuidelineMarkdown" Props="Markdown.DefaultConfig" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE"/>
</div> </div>
</MudField> </MudField>
</DialogContent> </DialogContent>
@@ -102,4 +102,4 @@
</MudLayout> </MudLayout>
</MudPaper> </MudPaper>
<MudThemeProvider @ref="@this.themeProvider" Theme="@this.ColorTheme" IsDarkMode="@this.useDarkMode" ObserveSystemDarkModeChange="@this.FollowSystemTheme" /> <MudMarkdownThemeProvider @ref="@this.themeProvider" Theme="@this.ColorTheme" IsDarkMode="@this.useDarkMode" ObserveSystemDarkModeChange="@this.FollowSystemTheme" />
@@ -81,7 +81,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan
private bool navBarOpen; private bool navBarOpen;
private bool performingUpdate; private bool performingUpdate;
private UpdateResponse? currentUpdateResponse; private UpdateResponse? currentUpdateResponse;
private MudThemeProvider themeProvider = null!; private MudMarkdownThemeProvider themeProvider = null!;
private bool useDarkMode; private bool useDarkMode;
private bool startupCompleted; private bool startupCompleted;
private bool settingsWriteProtectionWarningShown; private bool settingsWriteProtectionWarningShown;
@@ -88,7 +88,7 @@
--> -->
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.14" /> <PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="10.0.14" />
<PackageReference Include="MudBlazor" Version="9.12.0" /> <PackageReference Include="MudBlazor" Version="9.12.0" />
<PackageReference Include="MudBlazor.Markdown" Version="9.0.0" /> <PackageReference Include="MudBlazor.Markdown" Version="9.10.0" />
<PackageReference Include="ReverseMarkdown" Version="5.0.0" /> <PackageReference Include="ReverseMarkdown" Version="5.0.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
<PackageReference Include="LuaCSharp" Version="0.5.8" /> <PackageReference Include="LuaCSharp" Version="0.5.8" />
@@ -4149,6 +4149,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1335399555"] = "E
-- Manage your templates -- Manage your templates
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T3058934130"] = "Verwalten Sie Ihre Vorlagen" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T3058934130"] = "Verwalten Sie Ihre Vorlagen"
-- Copied!
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CODEBLOCKCOPYBUTTON::T1283727788"] = "Kopiert!"
-- Region -- Region
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T1227782301"] = "Region" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T1227782301"] = "Region"
@@ -4149,6 +4149,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T1335399555"] = "O
-- Manage your templates -- Manage your templates
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T3058934130"] = "Manage your templates" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CHATTEMPLATESELECTION::T3058934130"] = "Manage your templates"
-- Copied!
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CODEBLOCKCOPYBUTTON::T1283727788"] = "Copied!"
-- Region -- Region
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T1227782301"] = "Region" UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIDENCEINFO::T1227782301"] = "Region"
+11 -2
View File
@@ -173,9 +173,18 @@ internal sealed class Program
config.PopoverOptions.ModalOverlay = true; config.PopoverOptions.ModalOverlay = true;
}); });
builder.Services.AddMemoryCache(); // Needed for the Markdown library
builder.Services.AddMudMarkdownServices(); builder.Services.AddMudMarkdownServices();
builder.Services.AddSingleton(new MudTheme());
//
// Code blocks in Markdown take their colors from the palettes, see MudMarkdownThemeProvider in the
// main layout. Their backgrounds stay those of the code blocks before, so that a code block still
// stands out against a white card:
//
builder.Services.AddSingleton(new MudTheme
{
PaletteLight = new MudMarkdownPaletteLight { CodeHighlight = MudCodeHighlightThemes.Light.LightPlus with { Background = "#F0F0F0" } },
PaletteDark = new MudMarkdownPaletteDark { CodeHighlight = MudCodeHighlightThemes.Dark.DarkPlus with { Background = "#444444" } },
});
builder.Services.AddSingleton(MessageBus.INSTANCE); builder.Services.AddSingleton(MessageBus.INSTANCE);
builder.Services.AddSingleton(rust); builder.Services.AddSingleton(rust);
builder.Services.AddSingleton(typeof(RuntimeInfoResponse), runtimeInfo); builder.Services.AddSingleton(typeof(RuntimeInfoResponse), runtimeInfo);
@@ -0,0 +1,8 @@
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// What opens and closes a block comment, such as /* and */.
/// </summary>
/// <param name="Open">What opens the comment.</param>
/// <param name="Close">What closes it.</param>
public sealed record CodeDelimiters(string Open, string Close);
@@ -0,0 +1,56 @@
using System.Collections.Frozen;
using System.Diagnostics.CodeAnalysis;
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// The languages AI Studio highlights itself, beyond those MudBlazor.Markdown highlights.
/// </summary>
/// <remarks>
/// MudBlazor.Markdown highlights C#, Go, Kotlin, Razor, Rust, JSON, XML, and YAML. Its highlighter is
/// internal, so the languages models write just as often, such as Python, get rules of their own here.
/// A language is found by the name SourceCodeLanguages gives it, which already knows all the aliases
/// models write behind the fence, such as py or Python3.
/// </remarks>
public static class CodeHighlighters
{
private static readonly FrozenDictionary<string, CodeLanguageRules> RULES_BY_LANGUAGE = new Dictionary<string, CodeLanguageRules>(StringComparer.Ordinal)
{
["C"] = CppCodeRules.RULES,
["C++"] = CppCodeRules.RULES,
["CSS"] = CssCodeRules.RULES,
["Java"] = JavaCodeRules.RULES,
["JavaScript"] = JavaScriptCodeRules.RULES,
["JSX"] = JavaScriptCodeRules.RULES,
["Lua"] = LuaCodeRules.RULES,
["PowerShell"] = PowerShellCodeRules.RULES,
["Python"] = PythonCodeRules.RULES,
["Shell"] = ShellCodeRules.RULES,
["SQL"] = SqlCodeRules.RULES,
["TSX"] = TypeScriptCodeRules.RULES,
["TypeScript"] = TypeScriptCodeRules.RULES,
}.ToFrozenDictionary(StringComparer.Ordinal);
/// <summary>
/// The names of the languages AI Studio highlights itself, as SourceCodeLanguages names them.
/// </summary>
internal static IEnumerable<string> LanguageNames => RULES_BY_LANGUAGE.Keys;
/// <summary>
/// Finds the rules for the language a model names behind the opening fence of a code block.
/// </summary>
/// <param name="fenceLanguage">The language behind the opening fence, as Markdig reads it.</param>
/// <param name="rules">The rules of the language, when AI Studio highlights it itself.</param>
/// <returns>Whether AI Studio highlights the language itself. Otherwise, MudBlazor.Markdown renders the
/// block, highlighted when it knows the language.</returns>
public static bool TryGetRules(string? fenceLanguage, [NotNullWhen(true)] out CodeLanguageRules? rules)
{
rules = null;
if (string.IsNullOrWhiteSpace(fenceLanguage))
return false;
return RULES_BY_LANGUAGE.TryGetValue(SourceCodeLanguages.FromCodeFenceLanguage(fenceLanguage).Name, out rules);
}
}
@@ -0,0 +1,266 @@
using System.Text.Json;
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// Recognizes the language of a code block which names none behind its opening fence.
/// </summary>
/// <remarks>
/// Users and models often leave the language out. Before AI Studio highlighted code itself, highlight.js
/// guessed it in the browser; now the app guesses, for the highlighting and for the file the block is
/// saved as, which therefore always agree. It guesses only among the languages it highlights, and only
/// when one of them clearly wins: a file saved under a wrong extension is worse than a text file. JSON
/// and markup are recognized by their shape. Every other language has markers, things which hint at
/// it, such as using System; for C#; each marker counts once, so that a long block does not win by
/// repeating one.
/// </remarks>
public static class CodeLanguageDetector
{
/// <summary>
/// The weight the best language needs at least.
/// </summary>
private const int MINIMUM_WEIGHT = 4;
/// <summary>
/// How far the best language has to be ahead of the next one.
/// </summary>
private const int MINIMUM_LEAD = 2;
/// <summary>
/// The weight the markers of an extended language, such as TypeScript, need to win over the language it extends.
/// </summary>
private const int MINIMUM_REFINEMENT_WEIGHT = 2;
private static readonly IReadOnlyList<CodeLanguageProfile> PROFILES =
[
new("python",
[
First("#!/usr/bin/env python", 4), First("#!/usr/bin/python", 4), Start("def ", 2), End(":", 1), Start("elif ", 3),
Anywhere(" import ", 2), Anywhere("self.", 2), Anywhere("__init__", 3), Anywhere("__name__", 3), Anywhere("print(", 1),
Anywhere("None", 1),
]),
new("bash",
[
First("#!/bin/bash", 4), First("#!/bin/sh", 4), First("#!/usr/bin/env bash", 4), First("#!/bin/zsh", 4),
Start("sudo ", 4), Start("npm ", 4), Start("npx ", 4), Start("pip install ", 4), Start("apt ", 4), Start("apt-get ", 4),
Start("brew ", 4), Start("git ", 4), Start("docker ", 4), Start("curl ", 4), Start("chmod ", 4), Start("cargo ", 4),
Start("dotnet ", 4), Start("cd ", 3), Start("mkdir ", 3), Start("echo ", 2), Start("export ", 1), Line("fi", 3),
Line("done", 3), Line("esac", 3), Anywhere("; then", 3), Anywhere("; do", 3), Anywhere(" | grep ", 3), Anywhere("${", 1),
]),
new("powershell",
[
Anywhere("Write-Host", 4), Anywhere("Write-Output", 4), Anywhere("-Object", 3), Anywhere("Get-", 2), Anywhere("Set-", 2),
Anywhere("New-", 2), Anywhere("$_", 3), Anywhere("$env:", 4), Anywhere("-ErrorAction", 4), Anywhere(" -eq ", 3),
Anywhere(" -ne ", 3), Anywhere(" -like ", 3), Start("param(", 3), Anywhere("[string]", 2),
]),
new("lua",
[
Start("local function ", 4), Start("local ", 2), Line("end", 2), End(" then", 2), Anywhere(" ~= ", 3), Anywhere("ipairs(", 4),
Anywhere("pairs(", 3), Anywhere(" .. ", 1), Anywhere("nil", 1),
]),
new("javascript",
[
Start("const ", 2), Start("let ", 2), Start("var ", 1), Start("function ", 2), Start("import ", 1), Start("export default ", 3),
Anywhere("=> ", 1), Anywhere("console.log(", 4), Anywhere("document.", 3), Anywhere("window.", 3), Anywhere("require(", 2),
Anywhere("module.exports", 4), Anywhere(" from '", 3), Anywhere(" from \"", 3), Anywhere("===", 3), Anywhere("!==", 3),
Anywhere(".then(", 2), Anywhere("addEventListener(", 3), Anywhere("JSON.stringify(", 3), Anywhere("undefined", 2),
], "typescript",
[
Anywhere(": string", 2), Anywhere(": number", 2), Anywhere(": boolean", 2), Start("interface ", 2), Start("export interface ", 2),
Start("type ", 1), Start("import type ", 2), Anywhere(" as const", 2), Anywhere("): void", 2), Anywhere("<T>", 1),
]),
new("c",
[
Start("#include <", 4), Start("#include \"", 4), Start("#define ", 3), Anywhere("printf(", 2), Anywhere("int main(", 3),
Anywhere("malloc(", 3), Anywhere("sizeof(", 2), Anywhere("->", 1),
], "cpp",
[
Anywhere("std::", 3), Anywhere("cout <<", 3), Anywhere("cin >>", 3), Start("template <", 3), Start("template<", 3),
Anywhere("nullptr", 2), Start("namespace ", 1), Start("class ", 1),
]),
new("csharp",
[
Start("using System", 4), Start("namespace ", 2), Anywhere("{ get; set; }", 4), Anywhere("{ get; }", 3),
Anywhere("Console.WriteLine(", 4), Anywhere("static void Main(", 4), Anywhere("async Task", 3), Anywhere("nameof(", 4),
Anywhere("<summary>", 4), Anywhere("Dictionary<", 2), Anywhere(".Select(", 2), Anywhere("new()", 2), Start("public class ", 2),
Start("var ", 1), Anywhere("=> ", 1),
]),
new("java",
[
Start("import java.", 4), Start("import javax.", 4), Anywhere("System.out.println(", 4), Anywhere("static void main(String[] args)", 4),
Start("package ", 2), Start("@Override", 3), Anywhere(" implements ", 2), Anywhere(" throws ", 2), Anywhere("ArrayList<", 2),
Start("public class ", 2), Anywhere(" extends ", 1),
]),
new("kotlin",
[
Anywhere("fun main(", 4), Start("data class ", 4), Start("import kotlin", 4), Anywhere("mutableListOf(", 4),
Anywhere("override fun ", 4), Start("fun ", 3), Start("val ", 2), Anywhere("listOf(", 3), Anywhere("?.let", 3),
Anywhere("println(", 2),
]),
new("go",
[
Start("package main", 4), Anywhere("fmt.Println(", 4), Anywhere("fmt.Printf(", 4), Anywhere("err != nil", 4),
Anywhere("go func(", 4), Start("func ", 3), Anywhere(" := ", 3), Start("import (", 3), Anywhere("chan ", 2),
]),
new("rust",
[
Start("pub fn ", 4), Start("use std::", 4), Anywhere("let mut ", 4), Anywhere("println!(", 4), Start("#[derive(", 4),
Start("fn ", 3), Start("impl ", 3), Anywhere("&str", 3), Anywhere("-> Result<", 3), Anywhere("unwrap()", 3),
Anywhere("Vec<", 2), Anywhere("Option<", 2),
]),
new("sql",
[
Start("INSERT INTO ", 4, true), Start("CREATE TABLE ", 4, true), Start("DELETE FROM ", 4, true), Start("ALTER TABLE ", 4, true),
Start("DROP TABLE ", 4, true), Start("SELECT ", 3, true), Anywhere("GROUP BY ", 3, true), Anywhere("ORDER BY ", 3, true),
Anywhere("INNER JOIN ", 3, true), Start("UPDATE ", 2, true), Start("WHERE ", 2, true), Anywhere(" SET ", 1, true),
Anywhere(" FROM ", 1, true),
]),
new("css",
[
Start("@media ", 4), Start(":root", 4), Start("body {", 3), Anywhere("!important", 3), Anywhere("font-size: ", 3),
Anywhere("px;", 3), Anywhere("rem;", 3), Anywhere("em;", 2), Anywhere("color: ", 2), Anywhere("margin: ", 2),
Anywhere("padding: ", 2), Anywhere("display: ", 2), Anywhere("background: ", 2), Anywhere("border: ", 2),
]),
new("yaml",
[
Start("apiVersion: ", 4), Start("runs-on: ", 4), Line("services:", 4), Start("kind: ", 3), Line("steps:", 3),
Line("jobs:", 3), Start("uses: ", 3), Start("- uses: ", 3), Start("image: ", 2), Line("env:", 1),
]),
];
/// <summary>
/// The languages the detector recognizes, as a model would write them behind the fence.
/// </summary>
internal static IEnumerable<string> FenceLanguages => ["json", "html", "xml", ..PROFILES.Select(profile => profile.FenceLanguage), ..PROFILES.Where(profile => profile.Refinement is not null).Select(profile => profile.Refinement!)];
/// <summary>
/// The language of a code block: the one behind its fence or, when it names none, the one its code
/// is written in.
/// </summary>
/// <param name="fenceLanguage">The language behind the opening fence, as Markdig reads it.</param>
/// <param name="code">The code of the block.</param>
/// <returns>The language behind the fence, when there is one, even when it is text. Otherwise, the
/// language of the code, or null when it is not clear.</returns>
public static string? FenceLanguageOf(string? fenceLanguage, string code) => string.IsNullOrWhiteSpace(fenceLanguage) ? Detect(code) : fenceLanguage;
/// <summary>
/// Recognizes the language a code is written in.
/// </summary>
/// <param name="code">The code.</param>
/// <returns>The language as a model would write it behind the fence, for example csharp, or null when
/// no language clearly wins.</returns>
public static string? Detect(string code)
{
var text = code.AsSpan().Trim();
if (text.IsEmpty)
return null;
if (IsJson(text))
return "json";
if (text[0] is '<' && (text.Contains("</", StringComparison.Ordinal) || text.Contains("/>", StringComparison.Ordinal)))
return text.StartsWith("<?xml", StringComparison.OrdinalIgnoreCase) || text.Contains("xmlns", StringComparison.Ordinal) ? "xml" : "html";
string? bestLanguage = null;
var bestWeight = 0;
var secondWeight = 0;
foreach (var profile in PROFILES)
{
var refinementWeight = profile.RefinementMarkers is { } refinementMarkers ? Weigh(text, refinementMarkers) : 0;
var weight = Weigh(text, profile.Markers) + refinementWeight;
if (weight > bestWeight)
{
secondWeight = bestWeight;
bestWeight = weight;
bestLanguage = profile.Refinement is not null && refinementWeight >= MINIMUM_REFINEMENT_WEIGHT ? profile.Refinement : profile.FenceLanguage;
}
else if (weight > secondWeight)
secondWeight = weight;
}
return bestWeight >= MINIMUM_WEIGHT && bestWeight - secondWeight >= MINIMUM_LEAD ? bestLanguage : null;
}
private static bool IsJson(ReadOnlySpan<char> text)
{
if (text[0] is not ('{' or '[') || text[^1] is not ('}' or ']'))
return false;
try
{
using var document = JsonDocument.Parse(text.ToString());
return true;
}
catch (JsonException)
{
return false;
}
}
private static int Weigh(ReadOnlySpan<char> text, IReadOnlyList<CodeLanguageMarker> markers)
{
var weight = 0;
foreach (var marker in markers)
if (Occurs(text, marker))
weight += marker.Weight;
return weight;
}
private static bool Occurs(ReadOnlySpan<char> text, CodeLanguageMarker marker)
{
var comparison = marker.IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (marker.Place is CodeMarkerPlace.ANYWHERE)
return text.Contains(marker.Text, comparison);
foreach (var line in text.EnumerateLines())
{
var trimmed = line.Trim();
if (trimmed.IsEmpty)
continue;
var occurs = marker.Place switch
{
CodeMarkerPlace.LINE_START or CodeMarkerPlace.FIRST_LINE => trimmed.StartsWith(marker.Text, comparison),
CodeMarkerPlace.LINE_END => trimmed.EndsWith(marker.Text, comparison),
CodeMarkerPlace.WHOLE_LINE => trimmed.Equals(marker.Text, comparison),
_ => false,
};
if (occurs)
return true;
//
// A shebang counts only on the first line:
//
if (marker.Place is CodeMarkerPlace.FIRST_LINE)
return false;
}
return false;
}
private static CodeLanguageMarker Anywhere(string text, int weight, bool ignoreCase = false) => new(text, CodeMarkerPlace.ANYWHERE, weight, ignoreCase);
private static CodeLanguageMarker Start(string text, int weight, bool ignoreCase = false) => new(text, CodeMarkerPlace.LINE_START, weight, ignoreCase);
private static CodeLanguageMarker End(string text, int weight) => new(text, CodeMarkerPlace.LINE_END, weight);
private static CodeLanguageMarker Line(string text, int weight) => new(text, CodeMarkerPlace.WHOLE_LINE, weight);
private static CodeLanguageMarker First(string text, int weight) => new(text, CodeMarkerPlace.FIRST_LINE, weight);
}
@@ -0,0 +1,11 @@
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// Something which hints at a language when it appears in a code block, such as using System; for C#.
/// </summary>
/// <param name="Text">What has to appear.</param>
/// <param name="Place">Where it has to appear.</param>
/// <param name="Weight">How strongly it hints at the language: 4 alone suffices, as only this language
/// writes it so; 1 hints only together with others.</param>
/// <param name="IgnoreCase">Whether it matches in any case, as the keywords of SQL do.</param>
public sealed record CodeLanguageMarker(string Text, CodeMarkerPlace Place, int Weight, bool IgnoreCase = false);
@@ -0,0 +1,11 @@
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// What hints at a language in a code block which names none, see CodeLanguageDetector.
/// </summary>
/// <param name="FenceLanguage">The language as a model would write it behind the fence, for example csharp.</param>
/// <param name="Markers">What hints at the language.</param>
/// <param name="Refinement">A language which extends this one, as TypeScript extends JavaScript, or null.</param>
/// <param name="RefinementMarkers">What hints at the extended language. They count for this language,
/// too, and when they reach a weight of 2, the code is in the extended one.</param>
public sealed record CodeLanguageProfile(string FenceLanguage, IReadOnlyList<CodeLanguageMarker> Markers, string? Refinement = null, IReadOnlyList<CodeLanguageMarker>? RefinementMarkers = null);
@@ -0,0 +1,161 @@
using System.Collections.Frozen;
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// How a language looks to the CodeTokenizer: its words, comments, strings, and the few habits of its
/// own, such as the dollar sign of a shell variable.
/// </summary>
/// <remarks>
/// The rules describe what a reader recognizes at a glance, not the grammar of the language. Whatever
/// they leave out stays plain, which is no error.
/// </remarks>
public sealed class CodeLanguageRules
{
private static readonly FrozenSet<string> NO_WORDS = Words();
/// <summary>
/// The keywords, such as if or return.
/// </summary>
public FrozenSet<string> Keywords { get; init; } = NO_WORDS;
/// <summary>
/// The names of built-in types, such as int.
/// </summary>
public FrozenSet<string> Types { get; init; } = NO_WORDS;
/// <summary>
/// The words which stand for a value, such as true or null.
/// </summary>
public FrozenSet<string> Literals { get; init; } = NO_WORDS;
/// <summary>
/// The keywords after which the name of a function follows, such as def. Each of them has to be one
/// of the keywords, too.
/// </summary>
public FrozenSet<string> FunctionDeclarations { get; init; } = NO_WORDS;
/// <summary>
/// The keywords after which the name of a type follows, such as class. Each of them has to be one of
/// the keywords, too.
/// </summary>
public FrozenSet<string> TypeDeclarations { get; init; } = NO_WORDS;
/// <summary>
/// What starts a comment to the end of its line, such as //.
/// </summary>
public IReadOnlyList<string> LineComments { get; init; } = [];
/// <summary>
/// Whether a line comment starts only at the beginning of a word, as the # of a shell does: in $# or
/// a#b, it starts none.
/// </summary>
public bool LineCommentsNeedSeparator { get; init; }
/// <summary>
/// The kinds of block comments, such as /* … */.
/// </summary>
public IReadOnlyList<CodeDelimiters> BlockComments { get; init; } = [];
/// <summary>
/// The kinds of strings. The first one whose opening matches wins, so a longer opening, such as three
/// quotation marks, has to come before a shorter one.
/// </summary>
public IReadOnlyList<CodeStringRule> Strings { get; init; } = [];
/// <summary>
/// The kinds of quoted names, such as "column" in SQL. They stay plain, but no keyword within them
/// gets a color.
/// </summary>
public IReadOnlyList<CodeStringRule> QuotedNames { get; init; } = [];
/// <summary>
/// The words which may stand right before a string and belong to it, such as the f of an f-string in
/// Python.
/// </summary>
public FrozenSet<string> StringPrefixes { get; init; } = NO_WORDS;
/// <summary>
/// Whether the language writes long strings and comments in brackets, as Lua does with [[ … ]] and
/// --[==[ … ]==].
/// </summary>
public bool HasLongBrackets { get; init; }
/// <summary>
/// The character which starts the name of a variable, such as $, or null when there is none.
/// </summary>
public char? VariablePrefix { get; init; }
/// <summary>
/// The characters which may appear within the name of a variable beyond letters, digits, and the
/// underscore, such as the colon of $env:PATH.
/// </summary>
public string VariableNameCharacters { get; init; } = string.Empty;
/// <summary>
/// The character which starts an annotation or decorator, such as @, or null when there is none.
/// </summary>
public char? AnnotationPrefix { get; init; }
/// <summary>
/// Whether a # at the start of a line begins a directive to the end of the line, as #include does.
/// </summary>
public bool HasPreprocessor { get; init; }
/// <summary>
/// The characters which may start a word beyond letters and the underscore, such as the $ of
/// JavaScript. A hyphen starts a word only when a letter, an underscore, or another hyphen follows it.
/// </summary>
public string WordStartCharacters { get; init; } = string.Empty;
/// <summary>
/// The characters which may appear within a word beyond letters, digits, and the underscore, such as
/// the hyphen of a CSS property.
/// </summary>
public string WordCharacters { get; init; } = string.Empty;
/// <summary>
/// Whether a name followed directly by an opening parenthesis is a function.
/// </summary>
public bool HighlightFunctionCalls { get; init; } = true;
/// <summary>
/// Whether a name in PascalCase is a type, as in Java.
/// </summary>
public bool HighlightPascalCaseTypes { get; init; }
/// <summary>
/// Whether a word of the form Verb-Noun is a command, as in PowerShell.
/// </summary>
public bool HighlightCommandNames { get; init; }
/// <summary>
/// The operators written as a word after a hyphen, such as -eq in PowerShell. Any other word after a
/// hyphen is the name of a parameter and stays plain.
/// </summary>
public FrozenSet<string> HyphenOperators { get; init; } = NO_WORDS;
/// <summary>
/// Whether a name in square brackets is a type, as [string] in PowerShell.
/// </summary>
public bool HasBracketTypes { get; init; }
/// <summary>
/// Whether the code is a style sheet: selectors, properties, and values instead of statements.
/// </summary>
public bool IsStyleSheet { get; init; }
/// <summary>
/// A set of words which match exactly as they are written.
/// </summary>
/// <param name="words">The words.</param>
/// <returns>The set of words.</returns>
public static FrozenSet<string> Words(params string[] words) => words.ToFrozenSet(StringComparer.Ordinal);
/// <summary>
/// A set of words which match in any case, for the languages which ignore it, such as SQL.
/// </summary>
/// <param name="words">The words.</param>
/// <returns>The set of words.</returns>
public static FrozenSet<string> WordsInAnyCase(params string[] words) => words.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
}
@@ -0,0 +1,32 @@
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// Where a marker of a language has to appear in a code block, see CodeLanguageMarker.
/// </summary>
public enum CodeMarkerPlace
{
/// <summary>
/// Anywhere in the code, also across a line break.
/// </summary>
ANYWHERE,
/// <summary>
/// At the start of a line, after its indentation.
/// </summary>
LINE_START,
/// <summary>
/// At the end of a line, before any space behind it.
/// </summary>
LINE_END,
/// <summary>
/// As a line of its own, apart from its indentation, such as the fi of a shell script.
/// </summary>
WHOLE_LINE,
/// <summary>
/// At the start of the first line which is not empty, such as a shebang.
/// </summary>
FIRST_LINE,
}
@@ -0,0 +1,14 @@
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// How a language writes one kind of string, such as one in double quotation marks.
/// </summary>
/// <param name="Open">What opens the string, for example a quotation mark or three of them.</param>
/// <param name="Close">What closes it.</param>
/// <param name="Escape">The character which makes the next one a part of the string, even when it would
/// close the string, or null when the string knows no escapes.</param>
/// <param name="IsMultiline">Whether the string may run over several lines. Otherwise, it ends with its
/// line, closed or not.</param>
/// <param name="DoubledCloseIsLiteral">Whether the closing mark written twice stands for itself, as two
/// apostrophes do within a string of SQL.</param>
public sealed record CodeStringRule(string Open, string Close, char? Escape = '\\', bool IsMultiline = false, bool DoubledCloseIsLiteral = false);
@@ -0,0 +1,9 @@
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// A part of a code block which gets a color of its own.
/// </summary>
/// <param name="Start">Where the part starts in the code.</param>
/// <param name="Length">How many characters the part spans.</param>
/// <param name="Kind">What the part is.</param>
public readonly record struct CodeToken(int Start, int Length, CodeTokenKind Kind);
@@ -0,0 +1,19 @@
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// What a part of a code block is, which decides its color.
/// </summary>
public enum CodeTokenKind
{
KEYWORD,
TYPE,
LITERAL,
STRING,
COMMENT,
NUMBER,
FUNCTION,
META,
VARIABLE,
ATTRIBUTE,
SELECTOR,
}
@@ -0,0 +1,27 @@
namespace AIStudio.Tools.CodeHighlighting;
public static class CodeTokenKindExtensions
{
/// <summary>
/// The CSS class which MudBlazor.Markdown gives this kind of part, so that it takes its color from
/// the palette of the theme, just as the languages the library highlights itself.
/// </summary>
/// <param name="kind">What the part is.</param>
/// <returns>The CSS class of the part.</returns>
public static string ToCssClass(this CodeTokenKind kind) => kind switch
{
CodeTokenKind.KEYWORD => "hljs-keyword",
CodeTokenKind.TYPE => "hljs-type",
CodeTokenKind.LITERAL => "hljs-literal",
CodeTokenKind.STRING => "hljs-string",
CodeTokenKind.COMMENT => "hljs-comment",
CodeTokenKind.NUMBER => "hljs-number",
CodeTokenKind.FUNCTION => "hljs-title function_",
CodeTokenKind.META => "hljs-meta",
CodeTokenKind.VARIABLE => "hljs-variable",
CodeTokenKind.ATTRIBUTE => "hljs-attr",
CodeTokenKind.SELECTOR => "hljs-selector-class",
_ => string.Empty,
};
}
@@ -0,0 +1,628 @@
using System.Buffers;
using System.Collections.Frozen;
namespace AIStudio.Tools.CodeHighlighting;
/// <summary>
/// Finds the parts of a code block which get a color, following the rules of its language.
/// </summary>
/// <remarks>
/// The chat highlights a code block anew with every chunk of a streamed answer, and AI Studio runs on
/// small computers such as a Raspberry Pi, too. Hence, the tokenizer reads the code once from its start
/// to its end, without regular expressions, and looks words up without a string for each of them. It
/// never fails: what the rules do not know stays plain, and a string or comment which is still open,
/// because the model has not written its end yet, runs to the end of its line or of the code.
/// </remarks>
public sealed class CodeTokenizer
{
/// <summary>
/// The characters which make a variable of their own right after its prefix, such as $? in a shell.
/// </summary>
private const string SPECIAL_VARIABLE_NAMES = "?#@*!$-";
private static readonly SearchValues<char> HEX_DIGITS = SearchValues.Create("0123456789abcdefABCDEF");
private readonly string code;
private readonly CodeLanguageRules rules;
private readonly List<CodeToken> tokens = [];
private readonly FrozenSet<string>.AlternateLookup<ReadOnlySpan<char>> keywords;
private readonly FrozenSet<string>.AlternateLookup<ReadOnlySpan<char>> types;
private readonly FrozenSet<string>.AlternateLookup<ReadOnlySpan<char>> literals;
private readonly FrozenSet<string>.AlternateLookup<ReadOnlySpan<char>> functionDeclarations;
private readonly FrozenSet<string>.AlternateLookup<ReadOnlySpan<char>> typeDeclarations;
private readonly FrozenSet<string>.AlternateLookup<ReadOnlySpan<char>> stringPrefixes;
private readonly FrozenSet<string>.AlternateLookup<ReadOnlySpan<char>> hyphenOperators;
private int position;
/// <summary>
/// What the next word names, after a keyword such as def or class.
/// </summary>
private CodeTokenKind? expectedName;
/// <summary>
/// How deep the blocks of a style sheet nest at the current position.
/// </summary>
private int blockDepth;
private CodeTokenizer(string code, CodeLanguageRules rules)
{
this.code = code;
this.rules = rules;
this.keywords = rules.Keywords.GetAlternateLookup<ReadOnlySpan<char>>();
this.types = rules.Types.GetAlternateLookup<ReadOnlySpan<char>>();
this.literals = rules.Literals.GetAlternateLookup<ReadOnlySpan<char>>();
this.functionDeclarations = rules.FunctionDeclarations.GetAlternateLookup<ReadOnlySpan<char>>();
this.typeDeclarations = rules.TypeDeclarations.GetAlternateLookup<ReadOnlySpan<char>>();
this.stringPrefixes = rules.StringPrefixes.GetAlternateLookup<ReadOnlySpan<char>>();
this.hyphenOperators = rules.HyphenOperators.GetAlternateLookup<ReadOnlySpan<char>>();
}
/// <summary>
/// Finds the parts of the code which get a color.
/// </summary>
/// <param name="code">The code of the block.</param>
/// <param name="rules">The rules of its language.</param>
/// <returns>The parts in the order of the code, none of them overlapping another. The code between
/// them stays plain.</returns>
public static IReadOnlyList<CodeToken> Tokenize(string code, CodeLanguageRules rules)
{
var tokenizer = new CodeTokenizer(code, rules);
tokenizer.Run();
return tokenizer.tokens;
}
private void Run()
{
while (this.position < this.code.Length)
{
if (char.IsWhiteSpace(this.code[this.position]))
{
this.position++;
continue;
}
if (this.TryReadComment() || this.TryReadWord())
continue;
//
// Anything else, such as a string or a parenthesis, ends what a keyword like def expected:
//
this.expectedName = null;
if (this.TryReadPreprocessorDirective() || this.TryReadString() || this.TryReadQuotedName() || this.TryReadVariable()
|| this.TryReadAnnotation() || this.TryReadStyleSheetPart() || this.TryReadHyphenWord() || this.TryReadBracketType()
|| this.TryReadNumber())
continue;
this.position++;
}
}
private bool TryReadComment()
{
if (this.rules.HasLongBrackets && this.StartsWithAt(this.position, "--") && this.TryFindLongBracketEnd(this.position + 2, out var longEnd))
{
this.AddAndMove(this.position, longEnd, CodeTokenKind.COMMENT);
return true;
}
foreach (var comment in this.rules.BlockComments)
{
if (!this.StartsWithAt(this.position, comment.Open))
continue;
var close = this.code.IndexOf(comment.Close, this.position + comment.Open.Length, StringComparison.Ordinal);
this.AddAndMove(this.position, close < 0 ? this.code.Length : close + comment.Close.Length, CodeTokenKind.COMMENT);
return true;
}
foreach (var comment in this.rules.LineComments)
{
if (!this.StartsWithAt(this.position, comment))
continue;
if (this.rules.LineCommentsNeedSeparator && this.position > 0 && !IsSeparator(this.code[this.position - 1]))
continue;
this.AddAndMove(this.position, this.LineEnd(this.position), CodeTokenKind.COMMENT);
return true;
}
return false;
}
private bool TryReadWord()
{
var start = this.position;
if (!this.IsWordStart(start))
return false;
var end = this.WordEnd(start + 1);
var word = this.code.AsSpan(start, end - start);
//
// A prefix, such as the f of an f-string, belongs to the string right behind it:
//
if (this.stringPrefixes.Contains(word) && this.TryReadStringAt(end, start))
{
this.expectedName = null;
return true;
}
var expected = this.expectedName;
this.expectedName = null;
if (this.keywords.Contains(word))
{
if (this.functionDeclarations.Contains(word))
this.expectedName = CodeTokenKind.FUNCTION;
else if (this.typeDeclarations.Contains(word))
this.expectedName = CodeTokenKind.TYPE;
this.AddAndMove(start, end, CodeTokenKind.KEYWORD);
return true;
}
if (this.ClassifyName(word, end, expected) is { } kind)
this.AddAndMove(start, end, kind);
else
this.position = end;
return true;
}
private CodeTokenKind? ClassifyName(ReadOnlySpan<char> word, int end, CodeTokenKind? expected)
{
if (this.literals.Contains(word))
return CodeTokenKind.LITERAL;
//
// In function M.greet, the function is greet, and M is the table or object it belongs to. The
// parenthesis after greet makes it a function then:
//
if (expected is CodeTokenKind.FUNCTION && end < this.code.Length && this.code[end] is '.' or ':')
return null;
if (expected is not null)
return expected;
if (this.types.Contains(word))
return CodeTokenKind.TYPE;
if (this.rules.IsStyleSheet && this.blockDepth > 0 && this.IsPropertyName(end))
return CodeTokenKind.ATTRIBUTE;
if (this.rules.HighlightFunctionCalls && end < this.code.Length && this.code[end] == '(')
return CodeTokenKind.FUNCTION;
if (this.rules.HighlightCommandNames && IsCommandName(word))
return CodeTokenKind.FUNCTION;
if (this.rules.HighlightPascalCaseTypes && IsPascalCase(word))
return CodeTokenKind.TYPE;
return null;
}
private bool TryReadPreprocessorDirective()
{
if (!this.rules.HasPreprocessor || this.code[this.position] != '#' || !this.IsAtLineStart(this.position))
return false;
this.AddAndMove(this.position, this.LineEnd(this.position), CodeTokenKind.META);
return true;
}
private bool TryReadString()
{
if (this.TryReadStringAt(this.position, this.position))
return true;
if (!this.rules.HasLongBrackets || !this.TryFindLongBracketEnd(this.position, out var end))
return false;
this.AddAndMove(this.position, end, CodeTokenKind.STRING);
return true;
}
/// <summary>
/// Reads a string which opens at a position, perhaps behind a prefix.
/// </summary>
/// <param name="openAt">Where the string would open.</param>
/// <param name="tokenStart">Where its part starts: at its prefix, when it has one.</param>
/// <returns>Whether a string opens there.</returns>
private bool TryReadStringAt(int openAt, int tokenStart)
{
foreach (var rule in this.rules.Strings)
{
if (!this.StartsWithAt(openAt, rule.Open))
continue;
this.AddAndMove(tokenStart, this.FindStringEnd(openAt + rule.Open.Length, rule), CodeTokenKind.STRING);
return true;
}
return false;
}
private bool TryReadQuotedName()
{
foreach (var rule in this.rules.QuotedNames)
{
if (!this.StartsWithAt(this.position, rule.Open))
continue;
this.position = this.FindStringEnd(this.position + rule.Open.Length, rule);
return true;
}
return false;
}
private int FindStringEnd(int from, CodeStringRule rule)
{
var index = from;
while (index < this.code.Length)
{
var character = this.code[index];
if (character == rule.Escape)
{
index += 2;
continue;
}
if (!rule.IsMultiline && character is '\n')
return this.LineEnd(index);
if (this.StartsWithAt(index, rule.Close))
{
if (rule.DoubledCloseIsLiteral && this.StartsWithAt(index + rule.Close.Length, rule.Close))
{
index += 2 * rule.Close.Length;
continue;
}
return index + rule.Close.Length;
}
index++;
}
return this.code.Length;
}
/// <summary>
/// Finds the end of a long bracket of Lua, such as [[ … ]] or [==[ … ]==], which opens at a position.
/// </summary>
/// <param name="openAt">Where the bracket would open.</param>
/// <param name="end">Where it ends, which is the end of the code when it was never closed.</param>
/// <returns>Whether a long bracket opens there.</returns>
private bool TryFindLongBracketEnd(int openAt, out int end)
{
end = openAt;
if (openAt >= this.code.Length || this.code[openAt] != '[')
return false;
var level = 0;
while (openAt + 1 + level < this.code.Length && this.code[openAt + 1 + level] == '=')
level++;
var contentStart = openAt + 2 + level;
if (contentStart > this.code.Length || this.code[contentStart - 1] != '[')
return false;
var close = $"]{new string('=', level)}]";
var closeAt = this.code.IndexOf(close, contentStart, StringComparison.Ordinal);
end = closeAt < 0 ? this.code.Length : closeAt + close.Length;
return true;
}
private bool TryReadVariable()
{
if (this.rules.VariablePrefix is not { } prefix || this.code[this.position] != prefix || this.position + 1 >= this.code.Length)
return false;
var next = this.code[this.position + 1];
int end;
if (next is '{')
{
var close = this.code.IndexOf('}', this.position + 2);
var lineEnd = this.LineEnd(this.position);
end = close < 0 || close > lineEnd ? lineEnd : close + 1;
}
else if (char.IsLetter(next) || next is '_')
{
end = this.position + 2;
while (end < this.code.Length && (char.IsLetterOrDigit(this.code[end]) || this.code[end] is '_' || this.rules.VariableNameCharacters.Contains(this.code[end])))
end++;
}
else if (char.IsAsciiDigit(next) || SPECIAL_VARIABLE_NAMES.Contains(next))
end = this.position + 2;
else
return false;
this.AddAndMove(this.position, end, CodeTokenKind.VARIABLE);
return true;
}
private bool TryReadAnnotation()
{
if (this.rules.AnnotationPrefix is not { } prefix || this.code[this.position] != prefix || this.position + 1 >= this.code.Length)
return false;
var next = this.code[this.position + 1];
if (!char.IsLetter(next) && next is not '_')
return false;
var end = this.position + 2;
while (end < this.code.Length && (char.IsLetterOrDigit(this.code[end]) || this.code[end] is '_' or '.'))
end++;
this.AddAndMove(this.position, end, CodeTokenKind.META);
return true;
}
private bool TryReadStyleSheetPart()
{
if (!this.rules.IsStyleSheet)
return false;
switch (this.code[this.position])
{
case '{':
this.blockDepth++;
this.position++;
return true;
case '}':
this.blockDepth = Math.Max(0, this.blockDepth - 1);
this.position++;
return true;
case '.' when this.IsWordStart(this.position + 1):
this.AddAndMove(this.position, this.WordEnd(this.position + 1), CodeTokenKind.SELECTOR);
return true;
case '@' when this.IsWordStart(this.position + 1):
this.AddAndMove(this.position, this.WordEnd(this.position + 1), CodeTokenKind.KEYWORD);
return true;
case '!' when this.StartsWithAt(this.position + 1, "important"):
this.AddAndMove(this.position, this.position + 1 + "important".Length, CodeTokenKind.KEYWORD);
return true;
//
// Within a block, #fff is a color. Before it, #main is the ID a selector names:
//
case '#' when this.position + 1 < this.code.Length && this.IsWordCharacter(this.code[this.position + 1]):
var end = this.WordEnd(this.position + 1);
var isColor = this.blockDepth > 0 && IsHexColor(this.code.AsSpan(this.position + 1, end - this.position - 1));
this.AddAndMove(this.position, end, isColor ? CodeTokenKind.NUMBER : CodeTokenKind.SELECTOR);
return true;
default:
return false;
}
}
/// <summary>
/// Reads a word after a hyphen, such as -eq or -Path in PowerShell. An operator gets a color, the
/// name of a parameter stays plain.
/// </summary>
private bool TryReadHyphenWord()
{
if (this.rules.HyphenOperators.Count is 0 || this.code[this.position] != '-' || this.position + 1 >= this.code.Length || !char.IsLetter(this.code[this.position + 1]))
return false;
if (this.position > 0 && this.IsWordCharacter(this.code[this.position - 1]))
return false;
var end = this.position + 2;
while (end < this.code.Length && char.IsLetterOrDigit(this.code[end]))
end++;
if (this.hyphenOperators.Contains(this.code.AsSpan(this.position + 1, end - this.position - 1)))
this.AddAndMove(this.position, end, CodeTokenKind.KEYWORD);
else
this.position = end;
return true;
}
/// <summary>
/// Reads a type in square brackets, such as [string] or [int[]] in PowerShell. Brackets right after
/// a name or a closing bracket index into it instead, as in $list[0].
/// </summary>
private bool TryReadBracketType()
{
if (!this.rules.HasBracketTypes || this.code[this.position] != '[' || this.position + 1 >= this.code.Length || !char.IsLetter(this.code[this.position + 1]))
return false;
if (this.position > 0 && (this.IsWordCharacter(this.code[this.position - 1]) || this.code[this.position - 1] is ']' or ')'))
return false;
var end = this.position + 2;
while (end < this.code.Length && (char.IsLetterOrDigit(this.code[end]) || this.code[end] is '.' or '_'))
end++;
if (this.StartsWithAt(end, "[]"))
end += 2;
if (end >= this.code.Length || this.code[end] != ']')
return false;
this.tokens.Add(new(this.position + 1, end - this.position - 1, CodeTokenKind.TYPE));
this.position = end + 1;
return true;
}
private bool TryReadNumber()
{
var start = this.position;
var character = this.code[start];
var startsWithPoint = character is '.' && start + 1 < this.code.Length && char.IsAsciiDigit(this.code[start + 1]) && (start is 0 || this.code[start - 1] != '.');
if (!char.IsAsciiDigit(character) && !startsWithPoint)
return false;
int end;
if (startsWithPoint)
end = this.DigitsEnd(start + 1);
else if (character is '0' && start + 1 < this.code.Length && this.code[start + 1] is 'x' or 'X' or 'b' or 'B' or 'o' or 'O')
{
end = start + 2;
while (end < this.code.Length && (char.IsAsciiHexDigit(this.code[end]) || this.code[end] is '_'))
end++;
}
else
{
end = this.DigitsEnd(start);
if (end + 1 < this.code.Length && this.code[end] is '.' && char.IsAsciiDigit(this.code[end + 1]))
end = this.DigitsEnd(end + 1);
}
if (end < this.code.Length && this.code[end] is 'e' or 'E')
{
var exponent = end + 1;
if (exponent < this.code.Length && this.code[exponent] is '+' or '-')
exponent++;
if (exponent < this.code.Length && char.IsAsciiDigit(this.code[exponent]))
end = this.DigitsEnd(exponent);
}
//
// A suffix, such as the f of 1.5f, the UL of 10UL, or the px of 10px, belongs to the number:
//
while (end < this.code.Length && char.IsAsciiLetterOrDigit(this.code[end]))
end++;
if (this.rules.IsStyleSheet && end < this.code.Length && this.code[end] is '%')
end++;
this.AddAndMove(start, end, CodeTokenKind.NUMBER);
return true;
}
/// <summary>
/// Whether a word inside a block of a style sheet names a property: a colon follows it, and its
/// line goes on to a semicolon or to the end of the block, not to a block of its own, as the
/// selector a:hover { does.
/// </summary>
private bool IsPropertyName(int end)
{
var index = end;
while (index < this.code.Length && this.code[index] is ' ' or '\t')
index++;
if (index >= this.code.Length || this.code[index] != ':')
return false;
var lineEnd = this.LineEnd(index);
for (var next = index + 1; next < lineEnd; next++)
{
switch (this.code[next])
{
case '{':
return false;
case ';' or '}':
return true;
}
}
return true;
}
private bool IsWordStart(int index)
{
if (index >= this.code.Length)
return false;
var character = this.code[index];
if (char.IsLetter(character) || character is '_')
return true;
if (!this.rules.WordStartCharacters.Contains(character))
return false;
//
// A hyphen alone is a minus sign, as in 10px - 2px or -2px:
//
if (character is not '-')
return true;
return index + 1 < this.code.Length && (char.IsLetter(this.code[index + 1]) || this.code[index + 1] is '_' or '-');
}
private bool IsWordCharacter(char character) => char.IsLetterOrDigit(character) || character is '_' || this.rules.WordCharacters.Contains(character);
private int WordEnd(int from)
{
var end = from;
while (end < this.code.Length && this.IsWordCharacter(this.code[end]))
end++;
return end;
}
private int DigitsEnd(int from)
{
var end = from;
while (end < this.code.Length && (char.IsAsciiDigit(this.code[end]) || this.code[end] is '_'))
end++;
return end;
}
/// <summary>
/// Where the line of a position ends, before its line break.
/// </summary>
private int LineEnd(int from)
{
var lineBreak = this.code.IndexOf('\n', from);
if (lineBreak < 0)
return this.code.Length;
return lineBreak > from && this.code[lineBreak - 1] is '\r' ? lineBreak - 1 : lineBreak;
}
private bool IsAtLineStart(int index)
{
for (var previous = index - 1; previous >= 0; previous--)
{
switch (this.code[previous])
{
case '\n':
return true;
case not (' ' or '\t'):
return false;
}
}
return true;
}
private bool StartsWithAt(int index, string value) => index <= this.code.Length && this.code.AsSpan(index).StartsWith(value, StringComparison.Ordinal);
private void AddAndMove(int start, int end, CodeTokenKind kind)
{
this.tokens.Add(new(start, end - start, kind));
this.position = end;
}
private static bool IsSeparator(char character) => char.IsWhiteSpace(character) || character is ';' or '(' or ')' or '{' or '}' or '|' or '&';
private static bool IsPascalCase(ReadOnlySpan<char> word) => word.Length > 1 && char.IsUpper(word[0]) && word.ContainsAnyInRange('a', 'z');
/// <summary>
/// Whether a word has the form Verb-Noun, as the commands of PowerShell do.
/// </summary>
private static bool IsCommandName(ReadOnlySpan<char> word)
{
var hyphen = word.IndexOf('-');
return hyphen > 0 && hyphen < word.Length - 1 && char.IsLetter(word[hyphen - 1]) && char.IsLetter(word[hyphen + 1]);
}
private static bool IsHexColor(ReadOnlySpan<char> value) => value.Length is 3 or 4 or 6 or 8 && !value.ContainsAnyExcept(HEX_DIGITS);
}
@@ -0,0 +1,42 @@
namespace AIStudio.Tools.CodeHighlighting.Languages;
/// <summary>
/// How C and C++ code looks to the tokenizer. C++ knows all the words of C, so both share one set of
/// rules.
/// </summary>
/// <remarks>
/// A directive of the preprocessor, such as #include, runs to the end of its line. A raw string of C++,
/// such as R"(…)", reads like any other string, which differs only when it holds a quotation mark.
/// </remarks>
public static class CppCodeRules
{
public static readonly CodeLanguageRules RULES = new()
{
Keywords = CodeLanguageRules.Words(
"alignas", "alignof", "asm", "auto", "break", "case", "catch", "class", "co_await", "co_return", "co_yield",
"concept", "const", "const_cast", "consteval", "constexpr", "constinit", "continue", "decltype", "default",
"delete", "do", "dynamic_cast", "else", "enum", "explicit", "export", "extern", "for", "friend", "goto", "if",
"inline", "mutable", "namespace", "new", "noexcept", "operator", "private", "protected", "public", "register",
"reinterpret_cast", "requires", "return", "sizeof", "static", "static_assert", "static_cast", "struct",
"switch", "template", "this", "thread_local", "throw", "try", "typedef", "typeid", "typename", "union", "using",
"virtual", "volatile", "while"),
Types = CodeLanguageRules.Words(
"bool", "char", "char8_t", "char16_t", "char32_t", "double", "float", "int", "int8_t", "int16_t", "int32_t",
"int64_t", "intptr_t", "long", "ptrdiff_t", "short", "signed", "size_t", "uint8_t", "uint16_t", "uint32_t",
"uint64_t", "uintptr_t", "unsigned", "void", "wchar_t"),
Literals = CodeLanguageRules.Words("true", "false", "nullptr", "NULL"),
TypeDeclarations = CodeLanguageRules.Words("class", "enum", "struct", "union"),
LineComments = ["//"],
BlockComments = [new("/*", "*/")],
Strings =
[
new("\"", "\""),
new("'", "'"),
],
StringPrefixes = CodeLanguageRules.Words("L", "u", "U", "u8", "R", "LR", "uR", "UR", "u8R"),
HasPreprocessor = true,
};
}
@@ -0,0 +1,26 @@
namespace AIStudio.Tools.CodeHighlighting.Languages;
/// <summary>
/// How a style sheet in CSS looks to the tokenizer.
/// </summary>
/// <remarks>
/// A style sheet has no statements: it has selectors, such as .card or #main, and blocks of properties
/// with their values. The tokenizer tells them apart by the braces. At-rules, such as @media, and
/// !important get the color of keywords, and a function, such as var(), that of functions.
/// </remarks>
public static class CssCodeRules
{
public static readonly CodeLanguageRules RULES = new()
{
IsStyleSheet = true,
BlockComments = [new("/*", "*/")],
Strings =
[
new("\"", "\""),
new("'", "'"),
],
WordStartCharacters = "-",
WordCharacters = "-",
};
}
@@ -0,0 +1,37 @@
namespace AIStudio.Tools.CodeHighlighting.Languages;
/// <summary>
/// How Java code looks to the tokenizer.
/// </summary>
/// <remarks>
/// Java names its classes in PascalCase, such as String or ArrayList, so such a name is a type, and a
/// constant in capitals, such as MAX_SIZE, is none. A text block in three quotation marks may run over
/// several lines.
/// </remarks>
public static class JavaCodeRules
{
public static readonly CodeLanguageRules RULES = new()
{
Keywords = CodeLanguageRules.Words(
"abstract", "assert", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", "enum",
"extends", "final", "finally", "for", "goto", "if", "implements", "import", "instanceof", "interface", "native",
"new", "package", "permits", "private", "protected", "public", "record", "return", "sealed", "static",
"strictfp", "super", "switch", "synchronized", "this", "throw", "throws", "transient", "try", "var",
"volatile", "while", "yield"),
Types = CodeLanguageRules.Words("boolean", "byte", "char", "double", "float", "int", "long", "short", "void"),
Literals = CodeLanguageRules.Words("true", "false", "null"),
TypeDeclarations = CodeLanguageRules.Words("class", "enum", "interface", "record"),
LineComments = ["//"],
BlockComments = [new("/*", "*/")],
Strings =
[
new("\"\"\"", "\"\"\"", IsMultiline: true),
new("\"", "\""),
new("'", "'"),
],
AnnotationPrefix = '@',
HighlightPascalCaseTypes = true,
};
}
@@ -0,0 +1,51 @@
namespace AIStudio.Tools.CodeHighlighting.Languages;
/// <summary>
/// How JavaScript code looks to the tokenizer, JSX included, whose tags stay plain.
/// </summary>
/// <remarks>
/// A template literal stays one string, including the expressions within it. A regular expression
/// literal stays plain: telling it from a division needs the grammar of the language.
/// </remarks>
public static class JavaScriptCodeRules
{
/// <summary>
/// The keywords of JavaScript, which TypeScript has, too.
/// </summary>
internal static readonly string[] KEYWORDS =
[
"async", "await", "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do",
"else", "export", "extends", "finally", "for", "from", "function", "if", "import", "in", "instanceof", "let",
"new", "of", "return", "static", "super", "switch", "this", "throw", "try", "typeof", "var", "void", "while",
"with", "yield",
];
/// <summary>
/// The words of JavaScript which stand for a value, which TypeScript has, too.
/// </summary>
internal static readonly string[] LITERALS = ["true", "false", "null", "undefined", "NaN", "Infinity"];
/// <summary>
/// The strings of JavaScript, which TypeScript has, too.
/// </summary>
internal static readonly IReadOnlyList<CodeStringRule> STRINGS =
[
new("`", "`", IsMultiline: true),
new("\"", "\""),
new("'", "'"),
];
public static readonly CodeLanguageRules RULES = new()
{
Keywords = CodeLanguageRules.Words(KEYWORDS),
Literals = CodeLanguageRules.Words(LITERALS),
FunctionDeclarations = CodeLanguageRules.Words("function"),
TypeDeclarations = CodeLanguageRules.Words("class"),
LineComments = ["//"],
BlockComments = [new("/*", "*/")],
Strings = STRINGS,
AnnotationPrefix = '@',
WordStartCharacters = "$",
WordCharacters = "$",
};
}
@@ -0,0 +1,27 @@
namespace AIStudio.Tools.CodeHighlighting.Languages;
/// <summary>
/// How Lua code looks to the tokenizer, the language of the plugins of AI Studio, too.
/// </summary>
/// <remarks>
/// Lua writes long strings and comments in brackets, such as [[ … ]] and --[==[ … ]==].
/// </remarks>
public static class LuaCodeRules
{
public static readonly CodeLanguageRules RULES = new()
{
Keywords = CodeLanguageRules.Words(
"and", "break", "do", "else", "elseif", "end", "for", "function", "goto", "if", "in", "local", "not", "or",
"repeat", "return", "then", "until", "while"),
Literals = CodeLanguageRules.Words("nil", "true", "false"),
FunctionDeclarations = CodeLanguageRules.Words("function"),
LineComments = ["--"],
HasLongBrackets = true,
Strings =
[
new("\"", "\""),
new("'", "'"),
],
};
}
@@ -0,0 +1,46 @@
namespace AIStudio.Tools.CodeHighlighting.Languages;
/// <summary>
/// How a PowerShell script looks to the tokenizer.
/// </summary>
/// <remarks>
/// PowerShell ignores the case of its keywords and operators. A command such as Get-ChildItem has the
/// form Verb-Noun, an operator such as -eq is a word after a hyphen, and a type stands in square
/// brackets, as in [string]. The backtick escapes within double quotation marks; two apostrophes stand
/// for one within single ones.
/// </remarks>
public static class PowerShellCodeRules
{
public static readonly CodeLanguageRules RULES = new()
{
Keywords = CodeLanguageRules.WordsInAnyCase(
"begin", "break", "catch", "class", "continue", "do", "dynamicparam", "else", "elseif", "end", "enum", "exit",
"filter", "finally", "for", "foreach", "function", "if", "in", "param", "process", "return", "switch", "throw",
"trap", "try", "until", "using", "while"),
FunctionDeclarations = CodeLanguageRules.WordsInAnyCase("function", "filter"),
TypeDeclarations = CodeLanguageRules.WordsInAnyCase("class", "enum"),
LineComments = ["#"],
LineCommentsNeedSeparator = true,
BlockComments = [new("<#", "#>")],
Strings =
[
new("@\"", "\"@", Escape: '`', IsMultiline: true),
new("@'", "'@", Escape: null, IsMultiline: true),
new("\"", "\"", Escape: '`', IsMultiline: true),
new("'", "'", Escape: null, IsMultiline: true, DoubledCloseIsLiteral: true),
],
VariablePrefix = '$',
VariableNameCharacters = ":",
WordCharacters = "-",
HighlightCommandNames = true,
HyphenOperators = CodeLanguageRules.WordsInAnyCase(
"and", "as", "band", "bnot", "bor", "bxor", "ceq", "clike", "cmatch", "cne", "contains", "creplace", "eq", "f",
"ge", "gt", "ieq", "ilike", "imatch", "in", "ine", "ireplace", "is", "isnot", "join", "le", "like", "lt",
"match", "ne", "not", "notcontains", "notin", "notlike", "notmatch", "or", "replace", "shl", "shr", "split",
"xor"),
HasBracketTypes = true,
};
}
@@ -0,0 +1,34 @@
namespace AIStudio.Tools.CodeHighlighting.Languages;
/// <summary>
/// How Python code looks to the tokenizer.
/// </summary>
/// <remarks>
/// The soft keywords match, case, and type stay out: they are names everywhere else, as in re.match.
/// </remarks>
public static class PythonCodeRules
{
public static readonly CodeLanguageRules RULES = new()
{
Keywords = CodeLanguageRules.Words(
"and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del", "elif", "else", "except",
"finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass",
"raise", "return", "try", "while", "with", "yield"),
Types = CodeLanguageRules.Words("bool", "bytearray", "bytes", "complex", "dict", "float", "frozenset", "int", "list", "object", "set", "str", "tuple"),
Literals = CodeLanguageRules.Words("True", "False", "None"),
FunctionDeclarations = CodeLanguageRules.Words("def"),
TypeDeclarations = CodeLanguageRules.Words("class"),
LineComments = ["#"],
Strings =
[
new("\"\"\"", "\"\"\"", IsMultiline: true),
new("'''", "'''", IsMultiline: true),
new("\"", "\""),
new("'", "'"),
],
StringPrefixes = CodeLanguageRules.WordsInAnyCase("r", "u", "b", "f", "t", "rb", "br", "fr", "rf", "tr", "rt"),
AnnotationPrefix = '@',
};
}
@@ -0,0 +1,30 @@
namespace AIStudio.Tools.CodeHighlighting.Languages;
/// <summary>
/// How a script of a Unix shell, such as Bash, looks to the tokenizer.
/// </summary>
/// <remarks>
/// A # starts a comment only at the start of a word, so $# and ${#list[@]} stay variables. Strings may
/// run over several lines, and the variables within double quotation marks stay a part of the string.
/// </remarks>
public static class ShellCodeRules
{
public static readonly CodeLanguageRules RULES = new()
{
Keywords = CodeLanguageRules.Words(
"break", "case", "continue", "declare", "do", "done", "elif", "else", "esac", "exit", "export", "fi", "for",
"function", "if", "in", "local", "readonly", "return", "select", "source", "then", "time", "until", "unset",
"while"),
FunctionDeclarations = CodeLanguageRules.Words("function"),
LineComments = ["#"],
LineCommentsNeedSeparator = true,
Strings =
[
new("\"", "\"", IsMultiline: true),
new("'", "'", Escape: null, IsMultiline: true),
],
VariablePrefix = '$',
};
}
@@ -0,0 +1,40 @@
namespace AIStudio.Tools.CodeHighlighting.Languages;
/// <summary>
/// How SQL looks to the tokenizer, across its dialects, such as PostgreSQL, MySQL, or T-SQL.
/// </summary>
/// <remarks>
/// SQL ignores the case of its keywords. A string stands in apostrophes, two of which stand for one. A
/// name in quotation marks, backticks, or square brackets, such as "order", stays plain, so that no
/// keyword within it gets a color.
/// </remarks>
public static class SqlCodeRules
{
public static readonly CodeLanguageRules RULES = new()
{
Keywords = CodeLanguageRules.WordsInAnyCase(
"add", "all", "alter", "and", "as", "asc", "begin", "between", "by", "case", "cast", "check", "column", "commit",
"constraint", "create", "cross", "database", "default", "delete", "desc", "distinct", "drop", "else", "end",
"exists", "foreign", "from", "full", "group", "having", "if", "in", "index", "inner", "insert", "into", "is",
"join", "key", "left", "like", "limit", "not", "offset", "on", "or", "order", "outer", "over", "partition",
"primary", "references", "replace", "returning", "right", "rollback", "select", "set", "table", "then", "top",
"transaction", "trigger", "truncate", "union", "unique", "update", "using", "values", "view", "when", "where",
"with"),
Types = CodeLanguageRules.WordsInAnyCase(
"bigint", "binary", "bit", "blob", "boolean", "char", "date", "datetime", "decimal", "double", "float", "int",
"integer", "interval", "json", "jsonb", "money", "nchar", "numeric", "nvarchar", "real", "serial", "smallint",
"text", "time", "timestamp", "tinyint", "uuid", "varbinary", "varchar"),
Literals = CodeLanguageRules.WordsInAnyCase("null", "true", "false"),
LineComments = ["--"],
BlockComments = [new("/*", "*/")],
Strings = [new("'", "'", Escape: null, IsMultiline: true, DoubledCloseIsLiteral: true)],
QuotedNames =
[
new("\"", "\"", Escape: null, DoubledCloseIsLiteral: true),
new("`", "`", Escape: null),
new("[", "]", Escape: null),
],
};
}
@@ -0,0 +1,33 @@
namespace AIStudio.Tools.CodeHighlighting.Languages;
/// <summary>
/// How TypeScript code looks to the tokenizer, TSX included: JavaScript with types.
/// </summary>
/// <remarks>
/// TypeScript names its own types in PascalCase, such as Promise or User, so a name in PascalCase is a
/// type here, unlike in JavaScript.
/// </remarks>
public static class TypeScriptCodeRules
{
public static readonly CodeLanguageRules RULES = new()
{
Keywords = CodeLanguageRules.Words(
[
..JavaScriptCodeRules.KEYWORDS,
"abstract", "as", "declare", "enum", "implements", "infer", "interface", "is", "keyof", "namespace", "override",
"private", "protected", "public", "readonly", "satisfies", "type",
]),
Types = CodeLanguageRules.Words("any", "bigint", "boolean", "never", "number", "object", "string", "symbol", "unknown"),
Literals = CodeLanguageRules.Words(JavaScriptCodeRules.LITERALS),
FunctionDeclarations = CodeLanguageRules.Words("function"),
TypeDeclarations = CodeLanguageRules.Words("class", "enum", "interface", "type"),
LineComments = ["//"],
BlockComments = [new("/*", "*/")],
Strings = JavaScriptCodeRules.STRINGS,
AnnotationPrefix = '@',
WordStartCharacters = "$",
WordCharacters = "$",
HighlightPascalCaseTypes = true,
};
}
@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using AIStudio.Tools.CodeHighlighting;
using AIStudio.Tools.PluginSystem; using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust; using AIStudio.Tools.Rust;
using AIStudio.Tools.Services; using AIStudio.Tools.Services;
@@ -124,7 +125,9 @@ public static class PlainFileExport
/// <summary> /// <summary>
/// Turns a fenced code block into a file: in the format the model marked it as, when we write /// Turns a fenced code block into a file: in the format the model marked it as, when we write
/// that format, and as code in its language otherwise. /// that format, and as code in its language otherwise. A block which names no language is saved in
/// the one its code is written in, see CodeLanguageDetector, so that it gets the same language as
/// its highlighting.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// A block the model never closed is left out. That happens when an answer broke off, at the /// A block the model never closed is left out. That happens when an answer broke off, at the
@@ -137,8 +140,9 @@ public static class PlainFileExport
return null; return null;
var content = block.Lines.ToString(); var content = block.Lines.ToString();
if (!FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format)) var language = CodeLanguageDetector.FenceLanguageOf(block.Info, content);
return new(string.Empty, FileExportFormat.SOURCE_CODE, SourceCodeLanguages.FromCodeFenceLanguage(block.Info), content); if (!FileExportFormatExtensions.TryFromCodeFenceLanguage(language, out var format))
return new(string.Empty, FileExportFormat.SOURCE_CODE, SourceCodeLanguages.FromCodeFenceLanguage(language), content);
if (!format.IsTabular()) if (!format.IsTabular())
return new(string.Empty, format, SourceCodeLanguage.NONE, content); return new(string.Empty, format, SourceCodeLanguage.NONE, content);
+7 -7
View File
@@ -92,12 +92,12 @@
}, },
"MudBlazor.Markdown": { "MudBlazor.Markdown": {
"type": "Direct", "type": "Direct",
"requested": "[9.0.0, )", "requested": "[9.10.0, )",
"resolved": "9.0.0", "resolved": "9.10.0",
"contentHash": "zu+K7epXvygGOV5HarFPGpzup3irQetUZcfUKY52iJ90CF5DPtFrNaBsYklCAGR8r2Z9YU0DuDMtlWnzySeSCw==", "contentHash": "/uO8FrwS7NANxw6tI5F1fnHTD9r1g/1Xn5SBF2s2jdr6oS4CCnIGC8HSip6qNR/mzZaEx0Ioc0s7abYcMM50Wg==",
"dependencies": { "dependencies": {
"Markdig": "0.45.0", "Markdig": "1.4.0",
"MudBlazor": "9.0.0" "MudBlazor": "9.10.0"
} }
}, },
"ReverseMarkdown": { "ReverseMarkdown": {
@@ -144,8 +144,8 @@
}, },
"Markdig": { "Markdig": {
"type": "Transitive", "type": "Transitive",
"resolved": "0.45.0", "resolved": "1.4.0",
"contentHash": "ObNLcA1b+0lpNNoEg256g9faMeJZi35wZW0AnKJ4nGPJe+5qkwnV26kUvQTHuanFnSX9SdvPzOO41BVJ6XarAg==" "contentHash": "lLhaiI/mNDTNzgLqnmknhf4LjgTqOAyjx5+GNz0X3AxYMxUhmFppF2WVMdER+SDqwhE5wUF1pzs/cuLwFxs7xA=="
}, },
"Microsoft.EntityFrameworkCore": { "Microsoft.EntityFrameworkCore": {
"type": "Transitive", "type": "Transitive",
@@ -6,6 +6,7 @@
- Improved how reliably AI Studio reads and stores your settings, chats, and backups. - Improved how reliably AI Studio reads and stores your settings, chats, and backups.
- Improved how reliably AI Studio exchanges data with AI providers, external data sources, and web search services. - Improved how reliably AI Studio exchanges data with AI providers, external data sources, and web search services.
- Improved how reliably the Visual Briefing assistant stores your briefings, and how reliably the assistants of AI Studio read the answers of AI models. - Improved how reliably the Visual Briefing assistant stores your briefings, and how reliably the assistants of AI Studio read the answers of AI models.
- Improved how AI Studio colors code blocks: it now highlights them itself instead of through an extra script, which makes the app smaller. This works for Python, JavaScript, TypeScript, Java, C, C++, C#, Go, Kotlin, Rust, SQL, shell scripts, PowerShell, Lua, CSS, HTML, XML, JSON, and YAML; code in other languages, such as Ruby or PHP, appears without colors. When a code block names no language, AI Studio recognizes it from the code, colors the block, and suggests the matching file extension when you save it.
- Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka. - Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka.
- Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again. - Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again.
- Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration. - Fixed configuration plugins you installed yourself being able to change the tool settings of your organization, such as the minimum provider confidence of a tool. These settings now follow the same rules as all other settings, which also gives you back your own values once your organization removes its configuration.
@@ -1 +1 @@
.mud-markdown-body code:not(.hljs){background:var(--mud-palette-action-disabled-background);color:var(--mud-palette-text-primary)!important;padding:2.5px 7.5px;border-radius:5px}pre code.hljs{display:block!important;overflow-x:auto;padding:1em;width:100%}.mud-markdown-body .mud-markdown-code-highlight{position:relative!important}.mud-markdown-body .mud-markdown-code-highlight:hover>.mud-markdown-code-highlight-copybtn{display:block!important}.mud-markdown-code-highlight-copybtn{position:absolute;display:none;top:0;right:0}.mud-markdown-code-highlight-sticky pre code.hljs{padding-top:0!important}.mud-markdown-body .mud-markdown-code-highlight-sticky .mud-markdown-code-highlight-copybtn-sticky{position:sticky;overflow:hidden;z-index:calc(var(--mud-zindex-appbar) - 2);top:calc(var(--mud-appbar-height) + 16px);float:right}.mud-markdown-body blockquote{border-left:.25em solid var(--mud-palette-text-disabled);color:var(--mud-palette-text-secondary);background-color:var(--mud-palette-table-hover);padding:.25em 1em;margin:.5em 0 1.25em}.mud-markdown-body blockquote p{margin-bottom:0!important}.mud-markdown-body .mud-table{margin:0 0 1.25em 0}.mud-markdown-body table p{margin-bottom:0!important}.mud-markdown-body .mud-link:hover{cursor:pointer!important}.mud-markdown-body ul{list-style-type:disc}.mud-markdown-body ol,.mud-markdown-body ul{padding-left:2em;margin-bottom:1.25em!important}.mud-markdown-body ul ul{list-style-type:circle;margin-bottom:0!important}.mud-markdown-body ul ul ul{list-style-type:square;margin-bottom:0!important}.mud-markdown-body li{display:list-item!important;text-align:-webkit-match-parent}.mud-markdown-body h1,.mud-markdown-body h2{border-bottom:1px solid var(--mud-palette-text-disabled);padding-bottom:.125em;margin-bottom:.4em}.mud-markdown-body h1,.mud-markdown-body h2,.mud-markdown-body h3,.mud-markdown-body h4,.mud-markdown-body h5,.mud-markdown-body h6{scroll-margin-top:5rem;margin-top:.25em;word-wrap:break-word;margin-bottom:.3em!important}.mud-markdown-body .mud-divider{margin:.5em 0;height:.25em}.mud-markdown-body p{margin-bottom:1.25em!important}.mud-markdown-body .mud-expand-panel p,.mud-markdown-body li p{margin-bottom:0!important}.mud-markdown-body p .mud-markdown-error{white-space:pre}.mud-markdown-body img{max-width:100%}.mud-markdown-toc{position:relative}.mud-markdown-toc-drawer{position:sticky;overflow:hidden;z-index:calc(var(--mud-zindex-appbar) - 2);top:calc(var(--mud-appbar-height) + 16px);float:right}.mud-markdown-toc-drawer-content{width:var(--mud-drawer-width-right);opacity:0;visibility:collapse;transition:opacity .3s ease,visibility .3s ease}.mud-markdown-toc-drawer-content.open{opacity:1;visibility:visible}.mud-markdown-toc-content{margin-right:0;transition:margin-left .3s ease}.mud-markdown-toc-content.open{margin-right:calc(var(--mud-drawer-width-right) + 16px)}.mud-markdown-toc-btn-toggle{position:absolute;right:0;z-index:calc(var(--mud-zindex-appbar) - 1)}.mud-markdown-toc-nav-menu .mud-nav-link{padding:4px 16px!important}.mud-markdown-toc-nav-menu .mud-nav-link-h2 .mud-nav-link-text{margin-left:20px!important}.mud-markdown-toc-nav-menu .mud-nav-link-h3 .mud-nav-link-text{margin-left:28px!important}.mud-markdown-toc-nav-menu .mud-nav-item{border-left:4px solid var(--mud-palette-text-disabled)}.mud-markdown-toc-nav-menu .mud-nav-item.active{border-left-color:var(--mud-palette-primary)!important;color:var(--mud-palette-primary)!important}.mud-markdown-body .mud-expand-panel-header{padding:16px 12px!important}.mud-markdown-body .mud-expand-panel-content{padding:0 12px 16px 12px!important} .hljs{color:var(--mud-markdown-codehightlight-text);background:var(--mud-markdown-codehightlight-background)}.hljs-subst{color:var(--mud-markdown-codehightlight-text)}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-template-tag,.hljs-template-variable,.hljs-variable.language_{color:var(--mud-markdown-codehightlight-keyword)}.hljs-type{color:var(--mud-markdown-codehightlight-type)}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:var(--mud-markdown-codehightlight-function)}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-symbol,.hljs-variable{color:var(--mud-markdown-codehightlight-number)}.hljs-string{color:var(--mud-markdown-codehightlight-string)}.hljs-code,.hljs-comment,.hljs-formula{color:var(--mud-markdown-codehightlight-comment)}.hljs-meta{color:var(--mud-markdown-codehightlight-meta)}.mud-markdown-body code:not(.hljs){background:var(--mud-palette-action-disabled-background);color:var(--mud-palette-text-primary)!important;padding:2.5px 7.5px;border-radius:5px}pre code.hljs{display:block!important;overflow-x:auto;padding:1em;width:100%}.mud-markdown-body .mud-markdown-code-highlight,.mud-markdown-body .mud-markdown-code-highlight-sticky{border:1px solid var(--mud-palette-lines-default);border-radius:var(--mud-default-borderradius)}.mud-markdown-body .mud-markdown-code-highlight{position:relative!important}.mud-markdown-body .mud-markdown-code-highlight:hover>.mud-markdown-code-highlight-copybtn{display:block!important}.mud-markdown-code-highlight-copybtn{position:absolute;display:none;top:0;right:0}.mud-markdown-body .mud-markdown-code-highlight-sticky{position:relative;display:flex;flex-direction:column}.mud-markdown-body .mud-markdown-code-highlight-sticky .mud-markdown-code-highlight-copybtn-sticky{position:sticky;align-self:flex-end;z-index:calc(var(--mud-zindex-appbar) - 2);top:calc(var(--mud-appbar-height) + 16px);margin-bottom:-3.5rem}.mud-markdown-body blockquote{border-left:.25em solid var(--mud-palette-text-disabled);color:var(--mud-palette-text-secondary);background-color:var(--mud-palette-table-hover);padding:.25em 1em;margin:.5em 0 1.25em}.mud-markdown-body blockquote p{margin-bottom:0!important}.mud-markdown-body .mud-table{margin:0 0 1.25em 0}.mud-markdown-body table p{margin-bottom:0!important}.mud-markdown-body .mud-link:hover{cursor:pointer!important}.mud-markdown-body ul{list-style-type:disc}.mud-markdown-body ol,.mud-markdown-body ul{padding-left:2em;margin-bottom:1.25em!important}.mud-markdown-body ul ul{list-style-type:circle;margin-bottom:0!important}.mud-markdown-body ul ul ul{list-style-type:square;margin-bottom:0!important}.mud-markdown-body li{display:list-item!important;text-align:-webkit-match-parent}.mud-markdown-body h1,.mud-markdown-body h2{border-bottom:1px solid var(--mud-palette-text-disabled);padding-bottom:.125em;margin-bottom:.4em}.mud-markdown-body h1,.mud-markdown-body h2,.mud-markdown-body h3,.mud-markdown-body h4,.mud-markdown-body h5,.mud-markdown-body h6{scroll-margin-top:5rem;margin-top:.25em;word-wrap:break-word;margin-bottom:.3em!important}.mud-markdown-body .mud-divider{margin:.5em 0;height:.25em}.mud-markdown-body p{margin-bottom:1.25em!important}.mud-markdown-body .mud-expand-panel p,.mud-markdown-body li p{margin-bottom:0!important}.mud-markdown-body p .mud-markdown-error{white-space:pre}.mud-markdown-body img{max-width:100%}.mud-markdown-toc{position:relative}.mud-markdown-toc-drawer{position:sticky;overflow:visible;z-index:calc(var(--mud-zindex-appbar) - 2);top:calc(var(--mud-appbar-height) + 16px);float:right}.mud-markdown-toc-drawer-content{width:0;overflow:hidden;opacity:0;visibility:collapse;transition:opacity .3s ease,visibility .3s ease}.mud-markdown-toc-drawer-content.open{width:var(--mud-drawer-width-right);opacity:1;visibility:visible}.mud-markdown-toc-content{margin-right:0;transition:margin-left .3s ease}.mud-markdown-toc-content.open{margin-right:calc(var(--mud-drawer-width-right) + 16px)}.mud-markdown-toc-btn-toggle{position:absolute;right:0;z-index:calc(var(--mud-zindex-appbar) - 1)}.mud-markdown-toc-nav-menu .mud-nav-link{padding:4px 16px!important}.mud-markdown-toc-nav-menu .mud-nav-link-h2 .mud-nav-link-text{margin-left:20px!important}.mud-markdown-toc-nav-menu .mud-nav-link-h3 .mud-nav-link-text{margin-left:28px!important}.mud-markdown-toc-nav-menu .mud-nav-item{border-left:4px solid var(--mud-palette-text-disabled)}.mud-markdown-toc-nav-menu .mud-nav-item.active{border-left-color:var(--mud-palette-primary)!important;color:var(--mud-palette-primary)!important}.mud-markdown-body .mud-expand-panel-header{padding:16px 12px!important}.mud-markdown-body .mud-expand-panel-content{padding:0 12px 16px 12px!important}
@@ -1 +1 @@
(()=>{var e={2224:e=>{e.exports=function(e){var t="built_in",a={},n={begin:"}",className:t,endsParent:!0},r={begin:"{",end:"}",contains:[e.QUOTE_STRING_MODE,"self"]},i=e.COMMENT("@\\*","\\*@",{relevance:10}),o={begin:"@[A-Za-z0-9\\._:-]+",returnBegin:!0,end:"(\\r|\\n|<|\\s|\"|')",subLanguage:"csharp",contains:[{begin:"@",className:t},{begin:"\\[",end:"\\]",skip:!0},{begin:"\\(",end:"\\)",skip:!0}],returnEnd:!0},s={begin:"[@]{0,1}<text>",returnBegin:!0,end:"</text>",returnEnd:!0,subLanguage:"cshtml-razor",contains:[{begin:"[@]{0,1}<text>",className:t},{begin:"</text>",className:t,endsParent:!0}]},l={begin:"@\\(",end:"\\)",returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{begin:"@\\(",className:t},{begin:"\\(",end:"\\)",subLanguage:"csharp",contains:[e.QUOTE_STRING_MODE,"self",s]},s,{begin:"\\)",className:t,endsParent:!0}]},c=function(e,t){var a={endsWithParent:!0,illegal:/</,relevance:0,contains:[{className:"attr",begin:"[A-Za-z0-9\\._:-]+",relevance:0},{begin:/=\s*/,relevance:0,contains:[{className:"string",variants:[{begin:/"/,end:/"/,contains:t},{begin:/'/,end:/'/,contains:t},{begin:/[^\s"'=<>`]+/}]}]}]};return[{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},e.COMMENT("\x3c!--","--\x3e",{relevance:10}),{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10},{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:"<style(?=\\s|>|$)",end:">",keywords:{name:"style"},contains:[a],starts:{end:"</style>",returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:"<script(?=\\s|>|$)",end:">",keywords:{name:"script"},contains:[a],starts:{end:"<\/script>",returnEnd:!0,subLanguage:["actionscript","javascript","handlebars","xml"]}},{className:"tag",begin:"</?",end:"/?>",contains:[{className:"name",begin:/[^\/><\s]+/,relevance:0},a]}].concat(t)}(e,[o,l]),_="^\\s*@(page|model|using|inherits|inject|layout)",d={begin:_+"[^\\r\\n{\\(]*$",end:"$",returnBegin:!0,returnEnd:!0,contains:[{begin:_,className:t},{variants:[{begin:"\\r|\\n",endsParent:!0},{begin:"\\s[^\\r\\n]+",end:"$"},{begin:"$"}],className:"type",endsParent:!0}]},m={variants:[{begin:"@\\{",end:"}"},{begin:"@code\\s*\\{",end:"}"}],returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{begin:"@(code\\s*)?\\{",className:t},a,{begin:"{",end:"}",contains:["self"],skip:!0},n]},p={begin:"^\\s*@helper[\\s]*[^{]+[\\s]*{",returnBegin:!0,returnEnd:!0,end:"}",subLanguage:"cshtml-razor",contains:[{begin:"@helper",className:t},{begin:"{",className:t},n]},u=[{begin:"@for[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@if[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@switch[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@while[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@using[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@lock[\\s]*\\([^{]+[\\s]*{",end:"}"},{begin:"@foreach[\\s]*\\([^{]+[\\s]*{",end:"}"}],g={variants:u,returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{variants:u.map((function(e){return{begin:e.begin}})),returnBegin:!0,contains:[{begin:"@",className:t},{variants:u.map((function(e){return{begin:e.begin.substr(1,e.begin.length-2)}})),subLanguage:"csharp"},{begin:"{",className:t}]},a,{variants:[{begin:"}[\\s]*else\\sif[\\s]*\\([^{]+[\\s]*{"},{begin:"}[\\s]*else[\\s]*{"}],returnBegin:!0,contains:[{begin:"}",className:t},{variants:[{begin:"[\\s]*else\\sif[\\s]*\\([^{]+[\\s]*{"},{begin:"[\\s]*else[\\s]*"}],subLanguage:"csharp"},{begin:"{",className:t}]},r,n]},E={begin:"@try[\\s]*{",end:"}",returnBegin:!0,returnEnd:!0,subLanguage:"csharp",contains:[{begin:"@",className:t},{begin:"try[\\s]*{",subLanguage:"csharp"},{variants:[{begin:"}[\\s]*catch[\\s]*\\([^\\)]+\\)[\\s]*{"},{begin:"}[\\s]*finally[\\s]*{"}],returnBegin:!0,contains:[{begin:"}",className:t},{variants:[{begin:"[\\s]*catch[\\s]*\\([^\\)]+\\)[\\s]*"},{begin:"[\\s]*finally[\\s]*"}],subLanguage:"csharp"},{begin:"{",className:t}]},a,r,n]},S="@section[\\s]+[a-zA-Z0-9]+[\\s]*{",b=[d,p,m,g,{begin:S,returnBegin:!0,returnEnd:!0,end:"}",subLanguage:"cshtml-razor",contains:[{begin:S,className:t},r,n]},{begin:"@await ",returnBegin:!0,subLanguage:"csharp",end:"(\\r|\\n|<|\\s)",contains:[{begin:"@await ",className:t},{begin:"[<\\r\\n]",endsParent:!0}]},E,{variants:[{begin:"@@"},{begin:"[a-zA-Z]+@"}],skip:!0},s,i,l,{className:"meta",begin:"<!DOCTYPE",end:">",relevance:10,contains:[{begin:"\\[",end:"\\]"}]},{begin:"<\\!\\[CDATA\\[",end:"\\]\\]>",relevance:10}].concat(c);return[m,g,E].forEach((function(e){var t=b.filter((function(t){return t!==e})),n=e.contains.indexOf(a);e.contains.splice.apply(e.contains,[n,1].concat(t))})),{aliases:["cshtml","razor","razor-cshtml","cshtml-razor"],contains:b}}},8416:e=>{function t(e){return e instanceof Map?e.clear=e.delete=e.set=function(){throw new Error("map is read-only")}:e instanceof Set&&(e.add=e.clear=e.delete=function(){throw new Error("set is read-only")}),Object.freeze(e),Object.getOwnPropertyNames(e).forEach((a=>{const n=e[a],r=typeof n;"object"!==r&&"function"!==r||Object.isFrozen(n)||t(n)})),e}class a{constructoLine truncated (()=>{function e(e){if(!e)return;const t=`#${e}`;if(window.location.hash!==t){const e=new URL(window.location.href);e.hash=t,history.replaceState(null,"",e)}}window.appendMathJaxScript=function(e){if(document.getElementById(e))return;const t=document.createElement("script");t.id=e,t.type="text/javascript",t.src="_content/MudBlazor.Markdown/MudBlazor.Markdown.MathJax.min.js",document.head.appendChild(t)},window.refreshMathJaxScript=function(){try{MathJax.typeset()}catch(e){}},window.MudBlazorMarkdown={scrollToElementId:function(t,n){const o=document.getElementById(t);o&&(e(t),n&&n.invokeMethodAsync("OnActiveElementChangedAsync",t),MudBlazorMarkdown.tableOfContents.scrollLock++,o.scrollIntoView({behavior:"smooth",block:"start",inline:"nearest"}),setTimeout((()=>{MudBlazorMarkdown.tableOfContents.scrollLock--,MudBlazorMarkdown.tableOfContents.scrollLock<0&&(MudBlazorMarkdown.tableOfContents.scrollLock=0)}),1e3))},copyTextToClipboard:async function(e){try{return await navigator.clipboard.writeText(e),!0}catch(e){return!1}},tableOfContents:{scrollLock:0,handleRefs:{},activeElementIds:{},startScrollSpy:function(t,n){if(!t)return;const o=document.getElementById(t);if(!o)return;const r=o.querySelectorAll(".mud-markdown-toc-heading");if(!r.length)return;const c=document.querySelector(".mud-appbar"),l=c?.getBoundingClientRect().height??0,a=()=>{if(MudBlazorMarkdown.tableOfContents.scrollLock>0)return;let o,c=-Number.MAX_VALUE;for(const e of r){const t=e.getBoundingClientRect().top-l;t>0||t<c||(c=t,o=e.id)}o||(o=r[0]?.id),o!==this.activeElementIds[t]&&(this.activeElementIds[t]=o,e(o),n.invokeMethodAsync("OnActiveElementChangedAsync",o))};this.handleRefs[t]=a,document.addEventListener("scroll",a,!0),document.addEventListener("resize",a,!0),a()},stopScrollSpy:function(e){if(!e)return;const t=this.handleRefs[e];t&&(document.removeEventListener("scroll",t,!0),window.removeEventListener("resize",t,!0),delete this.handleRefs[e],delete this.activeElementIds[e])}}}})();
@@ -0,0 +1,23 @@
using AIStudio.Components;
using AIStudio.Tools;
using Markdig.Syntax;
namespace AIStudio.Tests.Components;
/// <summary>
/// Checks the text which a code block AI Studio highlights itself shows and copies.
/// </summary>
[TestFixture]
public sealed class HighlightedMarkdownTests
{
[TestCase("```python\nprint(1)\n\nx = 2\n```", "print(1)\n\nx = 2")]
[TestCase("```python\r\nprint(1)\r\n\r\nx = 2\r\n```", "print(1)\r\n\r\nx = 2", Description = "Every line keeps its line break, the empty ones, too.")]
[TestCase("```python\n indented\n```", " indented", Description = "The indentation is a part of the code.")]
[TestCase("```python\n```", "", Description = "An empty block has no text.")]
public void ACodeBlockShowsItsLines(string markdown, string expected)
{
var block = Markdig.Markdown.Parse(markdown, Markdown.CHAT_MARKDOWN_PIPELINE).Descendants<FencedCodeBlock>().Single();
Assert.That(HighlightedMarkdown.TextOf(block), Is.EqualTo(expected));
}
}
@@ -0,0 +1,85 @@
using AIStudio.Tools;
using AIStudio.Tools.CodeHighlighting;
namespace AIStudio.Tests.Tools.CodeHighlighting;
/// <summary>
/// Checks which languages AI Studio highlights itself, judged by what a model writes behind the opening
/// fence of a code block.
/// </summary>
[TestFixture]
public sealed class CodeHighlightersTests
{
[TestCase("python")]
[TestCase("py")]
[TestCase("Python3")]
[TestCase("bash")]
[TestCase("sh")]
[TestCase("pwsh")]
[TestCase("lua")]
[TestCase("js")]
[TestCase("node")]
[TestCase("jsx")]
[TestCase("ts")]
[TestCase("tsx")]
[TestCase("css")]
[TestCase("java")]
[TestCase("c")]
[TestCase("cpp")]
[TestCase("C++")]
[TestCase("sql")]
[TestCase("postgresql")]
[TestCase("tsql")]
public void AnAliasFindsItsLanguage(string fenceLanguage)
{
Assert.That(CodeHighlighters.TryGetRules(fenceLanguage, out _), Is.True);
}
[TestCase("csharp", Description = "MudBlazor.Markdown highlights it itself.")]
[TestCase("foo", Description = "A language nobody knows stays plain.")]
[TestCase("text")]
[TestCase("")]
[TestCase(null)]
public void OtherLanguagesAreLeftToTheLibrary(string? fenceLanguage)
{
Assert.That(CodeHighlighters.TryGetRules(fenceLanguage, out _), Is.False);
}
/// <summary>
/// A language is found by the name SourceCodeLanguages gives it. A name it does not know would make
/// the rules unreachable, without any error.
/// </summary>
[Test]
public void EveryLanguageIsOneSourceCodeLanguagesKnows()
{
var knownNames = SourceCodeLanguages.Languages.Select(language => language.Name).ToHashSet(StringComparer.Ordinal);
Assert.That(CodeHighlighters.LanguageNames, Is.SubsetOf(knownNames));
}
/// <summary>
/// A model writes anything, and a streamed answer stops anywhere. Whatever the code, the parts stay
/// in order, inside the code, and never overlap.
/// </summary>
[Test]
public void AnyCodeGivesPartsInOrder([ValueSource(nameof(LanguageNames))] string languageName)
{
const string CHARACTERS = "\"'`\\/*#$@{}()[]=-.:;!%<>abcdefLrXYZ019_ \n\r\t";
Assert.That(CodeHighlighters.TryGetRules(languageName, out var rules), Is.True, "The name of a language is one of its aliases, too.");
var random = new Random(languageName.Length);
for (var sample = 0; sample < 500; sample++)
{
var code = new string(Enumerable.Range(0, random.Next(1, 160)).Select(_ => CHARACTERS[random.Next(CHARACTERS.Length)]).ToArray());
var end = 0;
foreach (var token in CodeTokenizer.Tokenize(code, rules!))
{
Assert.That(token.Start, Is.GreaterThanOrEqualTo(end), $"A part overlaps the one before it in: {code}");
Assert.That(token.Length, Is.GreaterThan(0), $"A part is empty in: {code}");
end = token.Start + token.Length;
Assert.That(end, Is.LessThanOrEqualTo(code.Length), $"A part runs past the end of: {code}");
}
}
}
private static IEnumerable<string> LanguageNames() => CodeHighlighters.LanguageNames;
}
@@ -0,0 +1,87 @@
using AIStudio.Tools;
using AIStudio.Tools.CodeHighlighting;
namespace AIStudio.Tests.Tools.CodeHighlighting;
/// <summary>
/// Checks which language AI Studio recognizes in a code block which names none.
/// </summary>
[TestFixture]
public sealed class CodeLanguageDetectorTests
{
/// <summary>
/// The languages MudBlazor.Markdown highlights itself, as a model writes them behind the fence.
/// </summary>
private static readonly string[] LIBRARY_LANGUAGES = ["csharp", "go", "kotlin", "rust", "json", "xml", "html", "yaml"];
[TestCase("import os\n\ndef greet(name):\n if name is None:\n return \"nobody\"\n print(f\"Hello {name}\")", "python")]
[TestCase("#!/bin/bash\nfor file in *.txt; do\n echo \"$file\"\ndone", "bash")]
[TestCase("npm install express", "bash", Description = "A single command is enough when only a shell runs it.")]
[TestCase("Get-ChildItem -Path . | ForEach-Object {\n Write-Host $_.Name\n}", "powershell")]
[TestCase("local function greet(name)\n if name ~= nil then\n print(\"Hello \" .. name)\n end\nend", "lua")]
[TestCase("const express = require('express');\nconst app = express();\napp.get('/', (req, res) => res.send('Hi'));", "javascript")]
[TestCase("interface User {\n name: string;\n age: number;\n}\nconst user: User = { name: 'Ada', age: 36 };", "typescript")]
[TestCase("#include <stdio.h>\n\nint main(void) {\n printf(\"Hello\\n\");\n return 0;\n}", "c")]
[TestCase("#include <iostream>\n\nint main() {\n std::cout << \"Hello\" << std::endl;\n}", "cpp")]
[TestCase("using System;\n\npublic class Greeter\n{\n public string Name { get; set; }\n public void Greet() => Console.WriteLine($\"Hello {Name}\");\n}", "csharp")]
[TestCase("import java.util.List;\n\npublic class Main {\n public static void main(String[] args) {\n System.out.println(\"Hello\");\n }\n}", "java")]
[TestCase("data class User(val name: String)\n\nfun main() {\n val user = User(\"Ada\")\n println(user.name)\n}", "kotlin")]
[TestCase("package main\n\nimport \"fmt\"\n\nfunc main() {\n msg := \"Hello\"\n fmt.Println(msg)\n}", "go")]
[TestCase("use std::collections::HashMap;\n\nfn main() {\n let mut map = HashMap::new();\n println!(\"{:?}\", map);\n}", "rust")]
[TestCase("SELECT name, COUNT(*) AS total\nFROM orders\nWHERE created_at > '2026-01-01'\nGROUP BY name\nORDER BY total DESC;", "sql")]
[TestCase("select * from users;", "sql", Description = "SQL ignores the case of its keywords.")]
[TestCase(".card {\n color: #333;\n margin: 0 auto;\n padding: 1rem;\n}", "css")]
[TestCase("services:\n web:\n image: nginx\n ports:\n - \"80:80\"", "yaml")]
[TestCase("{\"name\": \"Ada\", \"age\": 36}", "json")]
[TestCase("<div class=\"card\">\n <p>Hello</p>\n</div>", "html")]
[TestCase("<?xml version=\"1.0\"?>\n<root><item/></root>", "xml")]
public void TheCodeNamesItsLanguage(string code, string expected)
{
Assert.That(CodeLanguageDetector.Detect(code), Is.EqualTo(expected));
}
[TestCase("Just some words in a block.", TestName = "Prose")]
[TestCase("hello", TestName = "A single word")]
[TestCase("x = 1", TestName = "A line many languages write alike")]
[TestCase("let x = 5", TestName = "A line with a single weak hint")]
[TestCase("{\"name\": \"Ada\",", TestName = "JSON a model has not finished yet")]
[TestCase("", TestName = "Nothing")]
[TestCase(" \n ", TestName = "Only space")]
public void UnclearCodeStaysText(string code)
{
Assert.That(CodeLanguageDetector.Detect(code), Is.Null, "A file saved under a wrong extension is worse than a text file.");
}
[TestCase("text")]
[TestCase("python")]
[TestCase("foo")]
public void TheLanguageBehindTheFenceWins(string fenceLanguage)
{
Assert.That(CodeLanguageDetector.FenceLanguageOf(fenceLanguage, "using System;\nConsole.WriteLine(\"Hi\");"), Is.EqualTo(fenceLanguage));
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void WithoutALanguageTheCodeDecides(string? fenceLanguage)
{
Assert.That(CodeLanguageDetector.FenceLanguageOf(fenceLanguage, "using System;\nConsole.WriteLine(\"Hi\");"), Is.EqualTo("csharp"));
}
/// <summary>
/// A recognized language has to be one the export saves under its extension, and one which gets
/// colors. Otherwise, the guess would help neither.
/// </summary>
[Test]
public void EveryLanguageIsSavedAndHighlighted([ValueSource(nameof(FenceLanguages))] string fenceLanguage)
{
using (Assert.EnterMultipleScope())
{
var isFormat = FileExportFormatExtensions.TryFromCodeFenceLanguage(fenceLanguage, out _);
Assert.That(isFormat || SourceCodeLanguages.Aliases.Contains(fenceLanguage, StringComparer.OrdinalIgnoreCase), Is.True, "The export would save it as a text file.");
Assert.That(CodeHighlighters.TryGetRules(fenceLanguage, out _) || LIBRARY_LANGUAGES.Contains(fenceLanguage), Is.True, "Nobody highlights it.");
}
}
private static IEnumerable<string> FenceLanguages() => CodeLanguageDetector.FenceLanguages;
}
@@ -0,0 +1,27 @@
using System.Text;
using AIStudio.Tools.CodeHighlighting;
namespace AIStudio.Tests.Tools.CodeHighlighting;
/// <summary>
/// Writes code with its highlighted parts marked, such as {keyword:def}, so that a test reads like the
/// code it checks.
/// </summary>
internal static class CodeMarkup
{
public static string Of(string code, CodeLanguageRules rules)
{
var markup = new StringBuilder();
var position = 0;
foreach (var token in CodeTokenizer.Tokenize(code, rules))
{
markup.Append(code, position, token.Start - position);
markup.Append('{').Append(token.Kind.ToString().ToLowerInvariant()).Append(':').Append(code, token.Start, token.Length).Append('}');
position = token.Start + token.Length;
}
markup.Append(code, position, code.Length - position);
return markup.ToString();
}
}
@@ -0,0 +1,21 @@
using AIStudio.Tools;
using AIStudio.Tools.CodeHighlighting;
namespace AIStudio.Tests.Tools.CodeHighlighting;
/// <summary>
/// Checks that every kind of part takes its color from the style sheet of MudBlazor.Markdown, which
/// colors the code blocks with the palette of the theme.
/// </summary>
[TestFixture]
public sealed class CodeTokenKindExtensionsTests
{
[Test]
public async Task EveryKindHasAClassWhichTheStyleSheetColors([Values] CodeTokenKind kind)
{
var styleSheet = await new WebRootFiles().ReadTextAsync("system/MudBlazor.Markdown/MudBlazor.Markdown.min.css");
var cssClass = kind.ToCssClass().Split(' ')[0];
Assert.That(styleSheet, Does.Contain($".{cssClass}"), "A library which renames its classes would leave this kind without color.");
}
}
@@ -0,0 +1,183 @@
using AIStudio.Tools.CodeHighlighting;
namespace AIStudio.Tests.Tools.CodeHighlighting;
/// <summary>
/// Checks how the tokenizer reads code, with a small language made up for the tests. The tests of
/// each real language follow in their own files.
/// </summary>
[TestFixture]
public sealed class CodeTokenizerTests
{
private static readonly CodeLanguageRules TEST_LANGUAGE = new()
{
Keywords = CodeLanguageRules.Words("def", "class", "if", "return"),
Types = CodeLanguageRules.Words("int"),
Literals = CodeLanguageRules.Words("true", "null"),
FunctionDeclarations = CodeLanguageRules.Words("def"),
TypeDeclarations = CodeLanguageRules.Words("class"),
LineComments = ["//"],
BlockComments = [new("/*", "*/")],
Strings =
[
new("\"\"\"", "\"\"\"", IsMultiline: true),
new("\"", "\""),
new("'", "'", Escape: null, DoubledCloseIsLiteral: true),
],
StringPrefixes = CodeLanguageRules.Words("r"),
VariablePrefix = '$',
AnnotationPrefix = '@',
};
/// <summary>
/// The same language with all habits of the real languages switched on, for the test which feeds
/// it random characters.
/// </summary>
private static readonly CodeLanguageRules TEST_LANGUAGE_WITH_ALL_HABITS = new()
{
Keywords = CodeLanguageRules.Words("def", "class", "if", "return"),
Literals = CodeLanguageRules.Words("true"),
FunctionDeclarations = CodeLanguageRules.Words("def"),
TypeDeclarations = CodeLanguageRules.Words("class"),
LineComments = ["#", "--"],
LineCommentsNeedSeparator = true,
BlockComments = [new("/*", "*/"), new("<#", "#>")],
Strings = [new("\"\"\"", "\"\"\"", IsMultiline: true), new("\"", "\"", Escape: '`'), new("'", "'", Escape: null, DoubledCloseIsLiteral: true)],
QuotedNames = [new("`", "`", Escape: null)],
StringPrefixes = CodeLanguageRules.Words("r", "L"),
HasLongBrackets = true,
VariablePrefix = '$',
VariableNameCharacters = ":",
AnnotationPrefix = '@',
HasPreprocessor = true,
WordStartCharacters = "-$",
WordCharacters = "-",
HighlightPascalCaseTypes = true,
HighlightCommandNames = true,
HyphenOperators = CodeLanguageRules.WordsInAnyCase("eq", "ne"),
HasBracketTypes = true,
IsStyleSheet = true,
};
[TestCase("def greet(name):", "{keyword:def} {function:greet}(name):")]
[TestCase("class Point:", "{keyword:class} {type:Point}:")]
[TestCase("def (x)", "{keyword:def} (x)", Description = "What a keyword expects ends at anything but a name.")]
[TestCase("if x return true", "{keyword:if} x {keyword:return} {literal:true}")]
[TestCase("int count = 42", "{type:int} count = {number:42}")]
[TestCase("print(x)", "{function:print}(x)")]
[TestCase("print (x)", "print (x)", Description = "Only a parenthesis right after a name makes it a call.")]
[TestCase("Define x", "Define x", Description = "A keyword matches only as it is written.")]
[TestCase("x1 = y2", "x1 = y2", Description = "Digits within a name make no number.")]
public void WordsGetTheColorOfWhatTheyAre(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, TEST_LANGUAGE), Is.EqualTo(expected));
}
[TestCase("a // note\nb", "a {comment:// note}\nb")]
[TestCase("a // note\r\nb", "a {comment:// note}\r\nb", Description = "The line break is no part of the comment.")]
[TestCase("a /* one\ntwo */ b", "a {comment:/* one\ntwo */} b")]
[TestCase("a /* never closed\nb", "a {comment:/* never closed\nb}", Description = "A model which is still writing has not closed it yet.")]
[TestCase("\"a // b\" // c", "{string:\"a // b\"} {comment:// c}", Description = "Within a string, nothing starts a comment.")]
public void CommentsRunToTheirEnd(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, TEST_LANGUAGE), Is.EqualTo(expected));
}
[TestCase("\"a\\\"b\" c", "{string:\"a\\\"b\"} c", Description = "An escaped quotation mark does not close the string.")]
[TestCase("'it''s' x", "{string:'it''s'} x", Description = "Two apostrophes stand for one.")]
[TestCase("'a\\' x", "{string:'a\\'} x", Description = "A string without escapes ends at its first closing mark.")]
[TestCase("\"open\nnext", "{string:\"open}\nnext", Description = "A string of one line ends with its line, closed or not.")]
[TestCase("\"\"\"one\ntwo\"\"\" x", "{string:\"\"\"one\ntwo\"\"\"} x")]
[TestCase("\"\"\"never closed\nx", "{string:\"\"\"never closed\nx}")]
[TestCase("r\"raw\" r = 1", "{string:r\"raw\"} r = {number:1}", Description = "A prefix belongs to the string only right before it.")]
[TestCase("\"if\"", "{string:\"if\"}", Description = "A keyword within a string stays a part of it.")]
public void StringsRunToTheirEnd(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, TEST_LANGUAGE), Is.EqualTo(expected));
}
[TestCase("42", "{number:42}")]
[TestCase("0x1F", "{number:0x1F}")]
[TestCase("0b1010", "{number:0b1010}")]
[TestCase("1.5e-3", "{number:1.5e-3}")]
[TestCase("1_000", "{number:1_000}")]
[TestCase("10UL 1.5f", "{number:10UL} {number:1.5f}")]
[TestCase(".5", "{number:.5}")]
[TestCase("1..5", "{number:1}..{number:5}", Description = "A range is no decimal point.")]
[TestCase("a.b", "a.b")]
public void NumbersIncludeTheirNotation(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, TEST_LANGUAGE), Is.EqualTo(expected));
}
[TestCase("$name", "{variable:$name}")]
[TestCase("${a b}", "{variable:${a b}}")]
[TestCase("$? $1", "{variable:$?} {variable:$1}")]
[TestCase("$(cmd)", "$(cmd)", Description = "A command substitution is no variable.")]
[TestCase("@Override", "{meta:@Override}")]
[TestCase("@app.route('/')", "{meta:@app.route}({string:'/'})")]
[TestCase("a @ b", "a @ b", Description = "An operator is no annotation.")]
public void PrefixesMarkVariablesAndAnnotations(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, TEST_LANGUAGE), Is.EqualTo(expected));
}
[TestCase("# note", "{comment:# note}")]
[TestCase("echo $#", "echo {variable:$#}")]
[TestCase("a#b", "a#b")]
[TestCase("a; # note", "a; {comment:# note}")]
public void ACommentWhichNeedsASeparatorStartsOnlyAtTheStartOfAWord(string code, string expected)
{
var shellLike = new CodeLanguageRules { LineComments = ["#"], LineCommentsNeedSeparator = true, VariablePrefix = '$' };
Assert.That(CodeMarkup.Of(code, shellLike), Is.EqualTo(expected));
}
[TestCase("#include <a.h>\nint x;", "{meta:#include <a.h>}\n{type:int} x;")]
[TestCase(" #define X 1", " {meta:#define X 1}")]
[TestCase("x # y", "x # y", Description = "Only a # at the start of a line begins a directive.")]
public void ADirectiveRunsToTheEndOfItsLine(string code, string expected)
{
var cLike = new CodeLanguageRules { Types = CodeLanguageRules.Words("int"), HasPreprocessor = true };
Assert.That(CodeMarkup.Of(code, cLike), Is.EqualTo(expected));
}
[TestCase("HashMap map", "{type:HashMap} map")]
[TestCase("URL x", "URL x", Description = "A constant in capitals is no type.")]
public void PascalCaseNamesTypesWhereTheLanguageSaysSo(string code, string expected)
{
var javaLike = new CodeLanguageRules { HighlightPascalCaseTypes = true };
Assert.That(CodeMarkup.Of(code, javaLike), Is.EqualTo(expected));
}
[Test]
public void EmptyCodeHasNoParts()
{
Assert.That(CodeTokenizer.Tokenize(string.Empty, TEST_LANGUAGE), Is.Empty);
}
/// <summary>
/// A model writes anything, and a streamed answer stops anywhere. Whatever the code, the parts stay
/// in order, inside the code, and never overlap.
/// </summary>
[Test]
public void AnyCodeGivesPartsInOrder([Values(1, 2, 3, 4, 5, 6, 7, 8)] int seed)
{
const string CHARACTERS = "\"'`\\/*#$@{}()[]=-.:;!%<>abcdefLrXYZ019_ \n\r\t";
var random = new Random(seed);
foreach (var rules in new[] { TEST_LANGUAGE, TEST_LANGUAGE_WITH_ALL_HABITS })
{
for (var sample = 0; sample < 200; sample++)
{
var code = new string(Enumerable.Range(0, random.Next(1, 120)).Select(_ => CHARACTERS[random.Next(CHARACTERS.Length)]).ToArray());
var end = 0;
foreach (var token in CodeTokenizer.Tokenize(code, rules))
{
Assert.That(token.Start, Is.GreaterThanOrEqualTo(end), $"A part overlaps the one before it in: {code}");
Assert.That(token.Length, Is.GreaterThan(0), $"A part is empty in: {code}");
end = token.Start + token.Length;
Assert.That(end, Is.LessThanOrEqualTo(code.Length), $"A part runs past the end of: {code}");
}
}
}
}
}
@@ -0,0 +1,20 @@
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tests.Tools.CodeHighlighting;
[TestFixture]
public sealed class CppCodeRulesTests
{
[TestCase("#include <stdio.h>", "{meta:#include <stdio.h>}")]
[TestCase("int main(void) {", "{type:int} {function:main}({type:void}) {")]
[TestCase("std::vector<int> v{1, 2};", "std::vector<{type:int}> v{{number:1}, {number:2}};")]
[TestCase("enum class Color { Red };", "{keyword:enum} {keyword:class} {type:Color} { Red };")]
[TestCase("auto p = nullptr; const char* s = u8\"x\";", "{keyword:auto} p = {literal:nullptr}; {keyword:const} {type:char}* s = {string:u8\"x\"};")]
[TestCase("unsigned long x = 0xFFUL; float f = 1.5f;", "{type:unsigned} {type:long} x = {number:0xFFUL}; {type:float} f = {number:1.5f};")]
[TestCase("template <typename T> T max(T a, T b) { return a > b ? a : b; }", "{keyword:template} <{keyword:typename} T> T {function:max}(T a, T b) { {keyword:return} a > b ? a : b; }")]
[TestCase("struct point { int x; };", "{keyword:struct} {type:point} { {type:int} x; };")]
public void CAndCppGetTheirColors(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, CppCodeRules.RULES), Is.EqualTo(expected));
}
}
@@ -0,0 +1,20 @@
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tests.Tools.CodeHighlighting;
[TestFixture]
public sealed class CssCodeRulesTests
{
[TestCase(":root { --main-color: #06c; }", ":root { {attribute:--main-color}: {number:#06c}; }")]
[TestCase(".card:hover > a { color: var(--main-color) !important; }", "{selector:.card}:hover > a { {attribute:color}: {function:var}(--main-color) {keyword:!important}; }")]
[TestCase("#main { margin: 0 auto; }", "{selector:#main} { {attribute:margin}: {number:0} auto; }", Description = "Before a block, # names an ID, not a color.")]
[TestCase("@media (max-width: 600px) {", "{keyword:@media} (max-width: {number:600px}) {")]
[TestCase("p { width: calc(100% - 2rem); }", "p { {attribute:width}: {function:calc}({number:100%} - {number:2rem}); }")]
[TestCase("@media print {\n a:hover { color: red; }\n}", "{keyword:@media} print {\n a:hover { {attribute:color}: red; }\n}", Description = "Within an at-rule, a:hover is a selector, not a property.")]
[TestCase("p { font-family: \"Inter\", sans-serif; margin: -2px .5em; }", "p { {attribute:font-family}: {string:\"Inter\"}, sans-serif; {attribute:margin}: -{number:2px} {number:.5em}; }")]
[TestCase("/* note */ a {}", "{comment:/* note */} a {}")]
public void StyleSheetsGetTheirColors(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, CssCodeRules.RULES), Is.EqualTo(expected));
}
}
@@ -0,0 +1,19 @@
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tests.Tools.CodeHighlighting;
[TestFixture]
public sealed class JavaCodeRulesTests
{
[TestCase("@Override public String toString() {", "{meta:@Override} {keyword:public} {type:String} {function:toString}() {")]
[TestCase("public record Point(int x, int y) {}", "{keyword:public} {keyword:record} {type:Point}({type:int} x, {type:int} y) {}")]
[TestCase("List<String> names = new ArrayList<>();", "{type:List}<{type:String}> names = {keyword:new} {type:ArrayList}<>();")]
[TestCase("char c = 'a'; long n = 10L; double d = 1.5e3;", "{type:char} c = {string:'a'}; {type:long} n = {number:10L}; {type:double} d = {number:1.5e3};")]
[TestCase("String s = \"\"\"\n text\n \"\"\";", "{type:String} s = {string:\"\"\"\n text\n \"\"\"};")]
[TestCase("if (x == null) return;", "{keyword:if} (x == {literal:null}) {keyword:return};")]
[TestCase("System.out.println(MAX_SIZE);", "{type:System}.out.{function:println}(MAX_SIZE);", Description = "A constant in capitals is no type.")]
public void JavaGetsItsColors(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, JavaCodeRules.RULES), Is.EqualTo(expected));
}
}
@@ -0,0 +1,21 @@
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tests.Tools.CodeHighlighting;
[TestFixture]
public sealed class JavaScriptCodeRulesTests
{
[TestCase("import { useState } from 'react';", "{keyword:import} { useState } {keyword:from} {string:'react'};")]
[TestCase("const add = (a, b) => a + b;", "{keyword:const} add = (a, b) => a + b;")]
[TestCase("function greet(name) { return `Hi ${name}`; }", "{keyword:function} {function:greet}(name) { {keyword:return} {string:`Hi ${name}`}; }")]
[TestCase("class Dog extends Animal {}", "{keyword:class} {type:Dog} {keyword:extends} Animal {}")]
[TestCase("if (x === null || y === undefined) console.log(NaN);", "{keyword:if} (x === {literal:null} || y === {literal:undefined}) console.{function:log}({literal:NaN});")]
[TestCase("let $el = $('#id');", "{keyword:let} $el = {function:$}({string:'#id'});")]
[TestCase("const n = 0xFF + 1e3 + 10n;", "{keyword:const} n = {number:0xFF} + {number:1e3} + {number:10n};")]
[TestCase("// note /* not a block */", "{comment:// note /* not a block */}")]
[TestCase("@Component class A {}", "{meta:@Component} {keyword:class} {type:A} {}")]
public void JavaScriptGetsItsColors(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, JavaScriptCodeRules.RULES), Is.EqualTo(expected));
}
}
@@ -0,0 +1,20 @@
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tests.Tools.CodeHighlighting;
[TestFixture]
public sealed class LuaCodeRulesTests
{
[TestCase("local function greet(name)", "{keyword:local} {keyword:function} {function:greet}(name)")]
[TestCase("function M.greet(name)", "{keyword:function} M.{function:greet}(name)", Description = "The function is greet, not the table it belongs to.")]
[TestCase("-- note", "{comment:-- note}")]
[TestCase("--[[ long\ncomment ]] x", "{comment:--[[ long\ncomment ]]} x")]
[TestCase("local s = [==[ a ]] b ]==]", "{keyword:local} s = {string:[==[ a ]] b ]==]}")]
[TestCase("if x == nil then return true end", "{keyword:if} x == {literal:nil} {keyword:then} {keyword:return} {literal:true} {keyword:end}")]
[TestCase("t[1] = \"a\"", "t[{number:1}] = {string:\"a\"}")]
[TestCase("obj:method()", "obj:{function:method}()")]
public void LuaGetsItsColors(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, LuaCodeRules.RULES), Is.EqualTo(expected));
}
}
@@ -0,0 +1,22 @@
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tests.Tools.CodeHighlighting;
[TestFixture]
public sealed class PowerShellCodeRulesTests
{
[TestCase("function Get-Greeting {", "{keyword:function} {function:Get-Greeting} {")]
[TestCase("param([string]$Name = 'World')", "{keyword:param}([{type:string}]{variable:$Name} = {string:'World'})")]
[TestCase("if ($Name -eq $null) { Write-Host \"Hi $Name\" -ForegroundColor Green }", "{keyword:if} ({variable:$Name} {keyword:-eq} {variable:$null}) { {function:Write-Host} {string:\"Hi $Name\"} -ForegroundColor Green }")]
[TestCase("IF ($a) { RETURN }", "{keyword:IF} ({variable:$a}) { {keyword:RETURN} }", Description = "PowerShell ignores the case of its keywords.")]
[TestCase("<# block #> $env:PATH # note", "{comment:<# block #>} {variable:$env:PATH} {comment:# note}")]
[TestCase("@\"\nmulti\n\"@", "{string:@\"\nmulti\n\"@}")]
[TestCase("'it''s' \"a`\"b\"", "{string:'it''s'} {string:\"a`\"b\"}")]
[TestCase("$list[0]", "{variable:$list}[{number:0}]", Description = "Brackets after a name index into it.")]
[TestCase("[int[]]$numbers", "[{type:int[]}]{variable:$numbers}")]
[TestCase("Get-ChildItem -Path . | ForEach-Object { $_.Name }", "{function:Get-ChildItem} -Path . | {function:ForEach-Object} { {variable:$_}.Name }")]
public void PowerShellGetsItsColors(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, PowerShellCodeRules.RULES), Is.EqualTo(expected));
}
}
@@ -0,0 +1,23 @@
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tests.Tools.CodeHighlighting;
[TestFixture]
public sealed class PythonCodeRulesTests
{
[TestCase("@dataclass", "{meta:@dataclass}")]
[TestCase("class Point(Base):", "{keyword:class} {type:Point}(Base):")]
[TestCase(" \"\"\"A point.\"\"\"", " {string:\"\"\"A point.\"\"\"}")]
[TestCase("x: int = 0", "x: {type:int} = {number:0}")]
[TestCase("def move(self, dx: float) -> None:", "{keyword:def} {function:move}(self, dx: {type:float}) -> {literal:None}:")]
[TestCase("# shift it", "{comment:# shift it}")]
[TestCase("print(f\"moved {dx}\")", "{function:print}({string:f\"moved {dx}\"})")]
[TestCase("rb'\\x00' if x else B\"\"", "{string:rb'\\x00'} {keyword:if} x {keyword:else} {string:B\"\"}")]
[TestCase("async def main(): await run()", "{keyword:async} {keyword:def} {function:main}(): {keyword:await} {function:run}()")]
[TestCase("z = 1_000.5e3j", "z = {number:1_000.5e3j}")]
[TestCase("re.match(p, s)", "re.{function:match}(p, s)", Description = "The soft keyword match is a name everywhere else.")]
public void PythonGetsItsColors(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, PythonCodeRules.RULES), Is.EqualTo(expected));
}
}
@@ -0,0 +1,19 @@
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tests.Tools.CodeHighlighting;
[TestFixture]
public sealed class ShellCodeRulesTests
{
[TestCase("#!/bin/bash", "{comment:#!/bin/bash}")]
[TestCase("if [ -f \"$FILE\" ]; then", "{keyword:if} [ -f {string:\"$FILE\"} ]; {keyword:then}")]
[TestCase("for f in *.txt; do echo $f; done", "{keyword:for} f {keyword:in} *.txt; {keyword:do} echo {variable:$f}; {keyword:done}")]
[TestCase("greet() { local name=$1; }", "{function:greet}() { {keyword:local} name={variable:$1}; }")]
[TestCase("echo ${#items[@]} $# # count", "echo {variable:${#items[@]}} {variable:$#} {comment:# count}")]
[TestCase("x=$(date +%s)", "x=$(date +%s)")]
[TestCase("echo 'a\\'", "echo {string:'a\\'}", Description = "Within single quotation marks, nothing escapes.")]
public void ShellScriptsGetTheirColors(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, ShellCodeRules.RULES), Is.EqualTo(expected));
}
}
@@ -0,0 +1,17 @@
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tests.Tools.CodeHighlighting;
[TestFixture]
public sealed class SqlCodeRulesTests
{
[TestCase("SELECT name, COUNT(*) FROM users WHERE age >= 18 GROUP BY name;", "{keyword:SELECT} name, {function:COUNT}(*) {keyword:FROM} users {keyword:WHERE} age >= {number:18} {keyword:GROUP} {keyword:BY} name;")]
[TestCase("select * from t where x is not null", "{keyword:select} * {keyword:from} t {keyword:where} x {keyword:is} {keyword:not} {literal:null}", Description = "SQL ignores the case of its keywords.")]
[TestCase("INSERT INTO t (\"order\", `group`) VALUES ('it''s', 1.5);", "{keyword:INSERT} {keyword:INTO} t (\"order\", `group`) {keyword:VALUES} ({string:'it''s'}, {number:1.5});", Description = "A quoted name gets no color, even when it is a keyword.")]
[TestCase("CREATE TABLE [user] (id INT PRIMARY KEY, name VARCHAR(100));", "{keyword:CREATE} {keyword:TABLE} [user] (id {type:INT} {keyword:PRIMARY} {keyword:KEY}, name {type:VARCHAR}({number:100}));")]
[TestCase("-- note\n/* block */", "{comment:-- note}\n{comment:/* block */}")]
public void SqlGetsItsColors(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, SqlCodeRules.RULES), Is.EqualTo(expected));
}
}
@@ -0,0 +1,17 @@
using AIStudio.Tools.CodeHighlighting.Languages;
namespace AIStudio.Tests.Tools.CodeHighlighting;
[TestFixture]
public sealed class TypeScriptCodeRulesTests
{
[TestCase("interface User { name: string; age?: number }", "{keyword:interface} {type:User} { name: {type:string}; age?: {type:number} }")]
[TestCase("type Id = string | number;", "{keyword:type} {type:Id} = {type:string} | {type:number};")]
[TestCase("function find(id: Id): Promise<User> {", "{keyword:function} {function:find}(id: {type:Id}): {type:Promise}<{type:User}> {")]
[TestCase("private readonly cache = new Map<string, User>();", "{keyword:private} {keyword:readonly} cache = {keyword:new} {type:Map}<{type:string}, {type:User}>();")]
[TestCase("const ok: boolean = true;", "{keyword:const} ok: {type:boolean} = {literal:true};")]
public void TypeScriptGetsItsColors(string code, string expected)
{
Assert.That(CodeMarkup.Of(code, TypeScriptCodeRules.RULES), Is.EqualTo(expected));
}
}
+33 -1
View File
@@ -77,7 +77,6 @@ public sealed class PlainFileExportTests
[TestCase("```css", "CSS (.css)")] [TestCase("```css", "CSS (.css)")]
[TestCase("~~~bash", "Shell (.sh)")] [TestCase("~~~bash", "Shell (.sh)")]
[TestCase("```foo", "foo (.txt)", Description = "A language AI Studio does not know still names the block.")] [TestCase("```foo", "foo (.txt)", Description = "A language AI Studio does not know still names the block.")]
[TestCase("```", "Text (.txt)", Description = "A block without a language is plain text.")]
public void ACodeBlockInAnyOtherLanguageIsOfferedAsCode(string openingFence, string expectedName) public void ACodeBlockInAnyOtherLanguageIsOfferedAsCode(string openingFence, string expectedName)
{ {
var files = PlainFileExport.ExtractFiles(Lines(openingFence, "print(42)", openingFence[..3]), ','); var files = PlainFileExport.ExtractFiles(Lines(openingFence, "print(42)", openingFence[..3]), ',');
@@ -91,6 +90,39 @@ public sealed class PlainFileExportTests
} }
} }
[TestCase("using System;\n\nConsole.WriteLine(\"Hello\");", "C# (.cs)")]
[TestCase("def greet(name):\n print(f\"Hello {name}\")\n\nif __name__ == \"__main__\":\n greet(\"Ada\")", "Python (.py)")]
[TestCase("{\"name\": \"Ada\", \"age\": 36}", "JSON (.json)")]
[TestCase("Just a few words the model put into a block.", "Text (.txt)", Description = "A block whose language is not clear stays text.")]
public void ACodeBlockWithoutALanguageIsOfferedInTheLanguageOfItsCode(string code, string expectedName)
{
var files = PlainFileExport.ExtractFiles(Lines("```", code, "```"), ',');
using (Assert.EnterMultipleScope())
{
Assert.That(files, Has.Count.EqualTo(1));
Assert.That(files[0].Format, Is.EqualTo(FileExportFormat.SOURCE_CODE));
Assert.That(files[0].ToName(), Is.EqualTo(expectedName));
Assert.That(files[0].Content, Is.EqualTo(code));
}
}
[Test]
public void ACodeBlockMarkedAsTextStaysText()
{
var files = PlainFileExport.ExtractFiles(Lines("```text", "using System;", string.Empty, "Console.WriteLine(\"Hello\");", "```"), ',');
Assert.That(files.Select(file => file.ToName()), Is.EqualTo(new[] { "Text (.txt)" }), "Whoever marks a block as text gets a text file, whatever its code looks like.");
}
[Test]
public void AWebPageWithoutALanguageIsOfferedAsAWebPage()
{
var files = PlainFileExport.ExtractFiles(Lines("```", "<div class=\"card\">", " <p>Hello</p>", "</div>", "```"), ',');
Assert.That(files.Select(file => file.Format), Is.EqualTo(new[] { FileExportFormat.HTML }));
}
[Test] [Test]
public void AFormulaIsNoCodeBlock() public void AFormulaIsNoCodeBlock()
{ {