Added saving code blocks of every programming language (#1117)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-27 15:23:42 +01:00
1 parent 1a23479f33
commit 03abd78a5f
25 files changed
+1209 -65

No files matched your search

@@ -15,4 +15,10 @@ public enum FileExportFormat
HTML,
CSV,
TSV,
/// <summary>
/// Code in any language, one code block of an answer. Its language says how the file is named,
/// see SourceCodeLanguage.
/// </summary>
SOURCE_CODE,
}
@@ -12,7 +12,9 @@ namespace AIStudio.Tools;
/// <remarks>
/// This is the single place where an export format is described. Adding another one means adding
/// an enum member and one line per method here; neither the exporters nor the export menu need
/// to know about it.
/// to know about it. Code is the one exception: SOURCE_CODE stands for code in every language, so
/// its file takes the extension and the save dialog filter from its language, see
/// SourceCodeLanguage, and the format itself names none.
/// </remarks>
public static class FileExportFormatExtensions
{
@@ -64,6 +66,7 @@ public static class FileExportFormatExtensions
FileExportFormat.HTML => TB("Webpage (.html)"),
FileExportFormat.CSV => TB("Table (.csv)"),
FileExportFormat.TSV => TB("Table (.tsv)"),
FileExportFormat.SOURCE_CODE => TB("Source code"),
_ => TB("Unknown format"),
};
@@ -81,6 +84,7 @@ public static class FileExportFormatExtensions
FileExportFormat.MARKDOWN => Icons.Material.Filled.TextFields,
FileExportFormat.HTML => Icons.Material.Filled.Html,
FileExportFormat.CSV or FileExportFormat.TSV => Icons.Material.Filled.TableChart,
FileExportFormat.SOURCE_CODE => Icons.Material.Filled.Code,
_ => Icons.Material.Filled.Help,
};
@@ -89,7 +93,8 @@ public static class FileExportFormatExtensions
/// Returns the file extension of the format, including the leading dot.
/// </summary>
/// <param name="format">The format.</param>
/// <returns>The file extension, or an empty string when the format writes no file.</returns>
/// <returns>The file extension, or an empty string when the format writes no file or, as code
/// does, takes the extension from its language.</returns>
public static string ToFileExtension(this FileExportFormat format) => format switch
{
FileExportFormat.MICROSOFT_WORD => ".docx",
@@ -111,7 +116,8 @@ public static class FileExportFormatExtensions
/// A model which answers with a finished file puts it into a code block and names its language,
/// as in ```html. Models do not agree on the spelling, so we accept the usual names of a format
/// in any case. Only formats which are plain text appear here: a code block holds text, never
/// a Word document.
/// a Word document. A block in any other language, or in none, is code, see
/// SourceCodeLanguages.FromCodeFenceLanguage.
/// </remarks>
/// <param name="language">The language behind the opening fence, as Markdig reads it into
/// FencedCodeBlock.Info.</param>
@@ -152,7 +158,7 @@ public static class FileExportFormatExtensions
/// </remarks>
/// <param name="format">The format.</param>
/// <returns>True, when a text written as it is makes a valid file of the format.</returns>
public static bool IsPlainText(this FileExportFormat format) => format is FileExportFormat.LATEX or FileExportFormat.MARKDOWN or FileExportFormat.HTML or FileExportFormat.CSV or FileExportFormat.TSV;
public static bool IsPlainText(this FileExportFormat format) => format is FileExportFormat.LATEX or FileExportFormat.MARKDOWN or FileExportFormat.HTML or FileExportFormat.CSV or FileExportFormat.TSV or FileExportFormat.SOURCE_CODE;
/// <summary>
/// Returns the file name the save dialog starts with.
@@ -166,10 +172,22 @@ public static class FileExportFormatExtensions
/// <param name="name">What the file is about, for example the heading above a table. Anything
/// a file name cannot hold is removed. Null or blank falls back to a generic name.</param>
/// <returns>The suggested file name, including its extension.</returns>
public static string ToSuggestedFileName(this FileExportFormat format, string? name = null)
public static string ToSuggestedFileName(this FileExportFormat format, string? name = null) => SuggestFileName(name, format.ToFileExtension());
/// <summary>
/// Returns the file name the save dialog starts with, for a file with the given extension.
/// </summary>
/// <remarks>
/// The same rules as for a format apply, see ToSuggestedFileName. Code blocks need this, since
/// their extension comes from the language of the block, not from a format.
/// </remarks>
/// <param name="name">What the file is about. Null or blank falls back to a generic name.</param>
/// <param name="fileExtension">The file extension, including the leading dot.</param>
/// <returns>The suggested file name, including its extension.</returns>
internal static string SuggestFileName(string? name, string fileExtension)
{
var fileName = ToFileNameFragment(name);
return $"{(fileName.Length is 0 ? "export" : fileName)}{format.ToFileExtension()}";
return $"{(fileName.Length is 0 ? "export" : fileName)}{fileExtension}";
}
/// <summary>
@@ -223,7 +241,8 @@ public static class FileExportFormatExtensions
/// Returns the filter which the save dialog offers for the format.
/// </summary>
/// <param name="format">The format.</param>
/// <returns>The filter, or null when the format cannot be written.</returns>
/// <returns>The filter, or null when the format cannot be written or, as code does, takes the
/// filter from its language.</returns>
public static FileTypeFilter? ToFileTypeFilter(this FileExportFormat format) => format switch
{
FileExportFormat.MICROSOFT_WORD => FileTypes.MS_WORD,
+54 -3
View File
@@ -1,14 +1,65 @@
using System.Text;
using AIStudio.Tools.Rust;
namespace AIStudio.Tools;
/// <summary>
/// A file found in a message, ready to be written: a table the model wrote, or a code block the
/// model marked as a format we write.
/// A file found in a message, ready to be written: a table the model wrote, or one of its code blocks.
/// </summary>
/// <remarks>
/// The export menu and the writer ask the file how it is named and written, never its format
/// directly: code in Python and code in Rust share the format SOURCE_CODE, and only the language
/// of the file tells them apart.
/// </remarks>
/// <param name="Ordinal">Which table or which code block of the message this is, counting from one
/// within its kind; the format tells the two kinds apart, see FileExportFormatExtensions.IsTabular.
/// This is what tells two files of one kind apart even when they carry the same heading.</param>
/// <param name="Position">Where the table or code block starts in the Markdown text the file was
/// read from, as the index of its first character. A block the chat renders finds its file by it:
/// the chat renders an answer in parts, around its formulas, and each part knows where it starts.</param>
/// <param name="Caption">What the file is about: the heading above it, or else the first column
/// heading of a table. Empty for a code block without a heading above it.</param>
/// <param name="Format">The format this content is written as.</param>
/// <param name="Language">The language of the code, when the format is SOURCE_CODE; NONE for
/// every other file.</param>
/// <param name="Content">The finished file content.</param>
public sealed record MessageFile(int Ordinal, string Caption, FileExportFormat Format, string Content);
public sealed record MessageFile(int Ordinal, int Position, string Caption, FileExportFormat Format, SourceCodeLanguage Language, string Content)
{
private bool IsSourceCode => this.Format is FileExportFormat.SOURCE_CODE;
/// <summary>
/// Returns the name of the file's type as shown to the user, for example "Webpage (.html)" or
/// "Python (.py)".
/// </summary>
public string ToName() => this.IsSourceCode ? this.Language.ToName() : this.Format.ToName();
/// <summary>
/// Returns the icon of the file's type.
/// </summary>
public string ToIcon() => this.Format.ToIcon();
/// <summary>
/// Returns what tells a file of this type apart: its extension, for example ".html", or the
/// fixed name a tool looks for, such as Dockerfile.
/// </summary>
public string ToFileExtensionOrName() => this.IsSourceCode ? this.Language.ToFileExtensionOrName() : this.Format.ToFileExtension();
/// <summary>
/// Returns the filter which the save dialog offers for the file.
/// </summary>
/// <returns>The filter, or null when AI Studio cannot write the file itself.</returns>
public FileTypeFilter? ToFileTypeFilter() => this.IsSourceCode ? this.Language.ToFileTypeFilter() : this.Format.ToFileTypeFilter();
/// <summary>
/// Returns the file name the save dialog starts with.
/// </summary>
/// <param name="name">What the file is about. Null or blank falls back to a generic name.</param>
/// <returns>The suggested file name.</returns>
public string ToSuggestedFileName(string? name = null) => this.IsSourceCode ? this.Language.ToSuggestedFileName(name) : this.Format.ToSuggestedFileName(name);
/// <summary>
/// Returns the encoding the file gets written with.
/// </summary>
public Encoding ToFileEncoding() => this.IsSourceCode ? this.Language.ToFileEncoding() : this.Format.ToFileEncoding();
}
+77 -23
View File
@@ -1,8 +1,10 @@
using System.Text;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Markdig.Extensions.Mathematics;
using Markdig.Extensions.Tables;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
@@ -21,13 +23,16 @@ public static class PlainFileExport
/// <remarks>
/// Two kinds of files end up in an answer. Almost always it is a Markdown table written with
/// pipes, which is what a model produces on its own; we turn its cells into a file. Besides,
/// a model answers with a fenced code block marked as a format we write, such as html, latex,
/// markdown, or csv, whenever it was asked for a web page, a document, or data. Such a block
/// already is the finished file: we hand it through untouched rather than taking it apart and
/// reassembling it. We do not judge what the block holds, either. A browser shows a fragment of
/// HTML just as well as an entire page, and a LaTeX fragment is still what the user asked for.
/// every fenced code block is a file. A model marks it as a format we write, such as html, latex,
/// markdown, or csv, whenever it was asked for a web page, a document, or data; any other block
/// is code, in whatever language the model named, or in none. Either way, the block already is
/// the finished file: we hand it through untouched rather than taking it apart and reassembling
/// it. We do not judge what the block holds, either. A browser shows a fragment of HTML just as
/// well as an entire page, and a LaTeX fragment or a few lines of code are still what the user
/// asked for.
/// </remarks>
/// <param name="markdown">The Markdown text of the message.</param>
/// <param name="markdown">The Markdown text of the message, as the chat renders it: the position
/// of each file refers to this text.</param>
/// <param name="separator">The separator to write a Markdown table with, see CsvWriter.SeparatorFor.</param>
/// <returns>The files, or an empty list when the message holds none.</returns>
public static IReadOnlyList<MessageFile> ExtractFiles(string markdown, char separator)
@@ -55,10 +60,16 @@ public static class PlainFileExport
.ToList();
var tables = document.Descendants<Table>()
.Select(table => (table.Line, Content: ToContent(table, separator)));
.Select(table => (table.Line, Position: table.Span.Start, File: Read(table, separator)));
//
// Markdig reads a formula between $$ as a MathBlock, which is a fenced code block as well.
// Nobody saves a formula on its own, and as a block without a language, every formula of an
// answer would end up in the menu as a text file:
//
var codeBlocks = document.Descendants<FencedCodeBlock>()
.Select(block => (block.Line, Content: ToContent(block)));
.Where(block => block is not MathBlock)
.Select(block => (block.Line, Position: block.Span.Start, File: Read(block)));
//
// Tables and code blocks are counted apart. The menu falls back to that number when a
@@ -68,22 +79,33 @@ public static class PlainFileExport
var numberOfTables = 0;
var numberOfCodeBlocks = 0;
return tables.Concat(codeBlocks)
.Where(entry => entry.Content is not null)
.Where(entry => entry.File is not null)
.OrderBy(entry => entry.Line)
.Select(entry => new MessageFile(
entry.Content!.Value.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks,
Caption: HeadingAbove(entry.Line) is { Length: > 0 } heading ? heading : entry.Content!.Value.Fallback,
entry.Content!.Value.Format,
entry.Content.Value.Text))
entry.File!.Format.IsTabular() ? ++numberOfTables : ++numberOfCodeBlocks,
entry.Position,
Caption: HeadingAbove(entry.Line) is { Length: > 0 } heading ? heading : entry.File!.Fallback,
entry.File!.Format,
entry.File!.Language,
entry.File!.Content))
.ToList();
string HeadingAbove(int line) => headings.LastOrDefault(heading => heading.Line < line).Text ?? string.Empty;
}
/// <summary>
/// A file read out of a table or a code block, before ExtractFiles knows where it stands in the message.
/// </summary>
/// <param name="Fallback">What the file is about when no heading stands above it.</param>
/// <param name="Format">The format the file is written as.</param>
/// <param name="Language">The language of the code, or NONE when the format is not SOURCE_CODE.</param>
/// <param name="Content">The finished file content.</param>
private sealed record FoundFile(string Fallback, FileExportFormat Format, SourceCodeLanguage Language, string Content);
/// <summary>
/// Turns a Markdown table into a file.
/// </summary>
private static (string Fallback, FileExportFormat Format, string Text)? ToContent(Table table, char separator)
private static FoundFile? Read(Table table, char separator)
{
var rows = table.OfType<TableRow>()
.Select(row => row.OfType<TableCell>().Select(ToPlainText).ToArray())
@@ -97,25 +119,29 @@ public static class PlainFileExport
foreach (var fields in rows)
text.AppendLine(CsvWriter.ToRow(separator, fields));
return (rows[0].FirstOrDefault() ?? string.Empty, FileExportFormat.CSV, text.ToString());
return new(rows[0].FirstOrDefault() ?? string.Empty, FileExportFormat.CSV, SourceCodeLanguage.NONE, text.ToString());
}
/// <summary>
/// Turns a fenced code block into a file, when the model marked it as a format 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.
/// </summary>
/// <remarks>
/// A block the model never closed is left out. That happens when an answer broke off, at the
/// output limit of the model for example, and the file would end wherever the answer did: half
/// a web page or half a table is nothing anybody wants to save.
/// a web page, half a table, or half a program is nothing anybody wants to save.
/// </remarks>
private static (string Fallback, FileExportFormat Format, string Text)? ToContent(FencedCodeBlock block)
private static FoundFile? Read(FencedCodeBlock block)
{
if (block.ClosingFencedCharCount is 0 || !FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format))
if (block.ClosingFencedCharCount is 0)
return null;
var content = block.Lines.ToString();
if (!FileExportFormatExtensions.TryFromCodeFenceLanguage(block.Info, out var format))
return new(string.Empty, FileExportFormat.SOURCE_CODE, SourceCodeLanguages.FromCodeFenceLanguage(block.Info), content);
if (!format.IsTabular())
return (string.Empty, format, content);
return new(string.Empty, format, SourceCodeLanguage.NONE, content);
var blockSeparator = format is FileExportFormat.TSV ? '\t' : ',';
var firstLine = content.AsSpan();
@@ -126,7 +152,7 @@ public static class PlainFileExport
var separatorPosition = firstLine.IndexOf(blockSeparator);
var fallback = (separatorPosition >= 0 ? firstLine[..separatorPosition] : firstLine).Trim().Trim('"').ToString();
return (fallback, format, content);
return new(fallback, format, SourceCodeLanguage.NONE, content);
}
/// <summary>
@@ -200,7 +226,35 @@ public static class PlainFileExport
if (!format.IsPlainText() || format.ToFileTypeFilter() is not { } fileTypeFilter)
throw new ArgumentOutOfRangeException(nameof(format), format, "AI Studio cannot write this format itself.");
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], format.ToSuggestedFileName(fileName));
return await WriteFile(rustService, dialogTitle, format, fileTypeFilter, format.ToSuggestedFileName(fileName), format.ToFileEncoding(), fileContent);
}
/// <summary>
/// Writes one file out of a message as it is and lets the user save it.
/// </summary>
/// <remarks>
/// Unlike an entire message, such a file knows more about itself than its format says, so the
/// file names its type, the name the save dialog suggests, and its encoding.
/// </remarks>
/// <param name="rustService">The Rust service, used for the save dialog.</param>
/// <param name="dialogTitle">The title of the save dialog.</param>
/// <param name="file">The file, as ExtractFiles read it out of the message. Its format must be a
/// plain text format, see FileExportFormatExtensions.IsPlainText.</param>
/// <param name="fileContent">The finished file, see IContentExtensions.ToExportContent.</param>
/// <param name="fileName">What the file is about, used to suggest a name in the save dialog.
/// Null falls back to a generic name.</param>
/// <returns>True, when the file was written.</returns>
public static async Task<bool> ToFile(RustService rustService, string dialogTitle, MessageFile file, string fileContent, string? fileName = null)
{
if (!file.Format.IsPlainText() || file.ToFileTypeFilter() is not { } fileTypeFilter)
throw new ArgumentOutOfRangeException(nameof(file), file.Format, "AI Studio cannot write this format itself.");
return await WriteFile(rustService, dialogTitle, file.Format, fileTypeFilter, file.ToSuggestedFileName(fileName), file.ToFileEncoding(), fileContent);
}
private static async Task<bool> WriteFile(RustService rustService, string dialogTitle, FileExportFormat format, FileTypeFilter fileTypeFilter, string suggestedFileName, Encoding encoding, string fileContent)
{
var response = await rustService.SaveFile(dialogTitle, [fileTypeFilter], suggestedFileName);
if (response.UserCancelled)
{
LOGGER.LogInformation("User cancelled the save dialog.");
@@ -213,7 +267,7 @@ public static class PlainFileExport
feedback.ShowRunning(response.SaveFilePath);
try
{
await File.WriteAllTextAsync(response.SaveFilePath, fileContent, format.ToFileEncoding());
await File.WriteAllTextAsync(response.SaveFilePath, fileContent, encoding);
await feedback.SucceededAsync(Icons.Material.Filled.CheckCircle, TB("The export succeeded."), response.SaveFilePath, true);
return true;
@@ -486,7 +486,11 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PromptFilePath, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.PreselectedPolicyId, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.OutputMode, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultFileFormat, this.Id, settingsTable, dryRun);
var resultFileFormatIsValid = ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultFileFormat, this.Id, settingsTable, dryRun, validator: DataBatchProcessing.IsValidResultFileFormat);
if (!resultFileFormatIsValid && settingsTable.TryGetValue("DataBatchProcessing.ResultFileFormat", out _))
LOG.LogWarning("The Batch Processing result file format configured by plugin {ConfigPluginId} must be one of {ResultFileFormats}.", this.Id, string.Join(", ", FileExportFormatExtensions.ANSWER_FORMATS));
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.CsvFileName, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.ResultColumnHeader, this.Id, settingsTable, dryRun);
ManagedConfiguration.TryProcessConfiguration(x => x.BatchProcessing, x => x.CsvSeparator, this.Id, settingsTable, dryRun);
@@ -0,0 +1,83 @@
using System.Text;
using AIStudio.Tools.Rust;
namespace AIStudio.Tools;
/// <summary>
/// A language a model names behind the opening fence of a code block, together with what a file
/// in that language needs.
/// </summary>
/// <remarks>
/// Most languages go by their extension. A few tools look for a fixed file name instead, such as
/// Docker for its Dockerfile, so such a language names the file and leaves the extension empty.
/// </remarks>
/// <param name="Name">The name of the language as shown to the user, for example Python. The names
/// of languages are proper names and stay untranslated.</param>
/// <param name="FileExtension">The file extension, including the leading dot. Empty when the
/// language has a fixed file name.</param>
/// <param name="FileName">The fixed file name a tool looks for, for example Dockerfile. Empty when
/// the language goes by its extension.</param>
/// <param name="NeedsByteOrderMark">Whether the file has to start with a byte order mark. Windows
/// PowerShell 5.1, which still ships with Windows, reads a script without one in the local ANSI code
/// page and garbles every umlaut.</param>
/// <param name="NeedsWindowsLineEndings">Whether the lines have to end with CR LF. The Windows
/// command prompt misses jump labels in a batch file whose lines end with LF alone.</param>
public sealed record SourceCodeLanguage(string Name, string FileExtension, string FileName = "", bool NeedsByteOrderMark = false, bool NeedsWindowsLineEndings = false)
{
/// <summary>
/// No language at all, for a file which is not a code block of this kind.
/// </summary>
public static readonly SourceCodeLanguage NONE = new(string.Empty, string.Empty);
private static readonly Encoding WITH_BYTE_ORDER_MARK = new UTF8Encoding(true);
private static readonly Encoding WITHOUT_BYTE_ORDER_MARK = new UTF8Encoding(false);
/// <summary>
/// Whether the language has a fixed file name rather than an extension.
/// </summary>
private bool HasFixedFileName => this.FileName.Length > 0;
/// <summary>
/// Returns the name of the language as shown to the user, together with its file.
/// </summary>
/// <returns>For example "Python (.py)", or the file name alone, such as "Dockerfile".</returns>
public string ToName() => this.HasFixedFileName ? this.FileName : $"{this.Name} ({this.FileExtension})";
/// <summary>
/// Returns what tells a file of the language apart: its extension, or its fixed file name.
/// </summary>
/// <returns>For example ".py" or "Dockerfile".</returns>
public string ToFileExtensionOrName() => this.HasFixedFileName ? this.FileName : this.FileExtension;
/// <summary>
/// Returns the filter which the save dialog offers for a file of the language.
/// </summary>
/// <remarks>
/// A filter by file name holds no extension, so the dialog shows every file, cf.
/// FileTypes.ToFileDialogFilter. That is what we want for a Dockerfile.
/// </remarks>
/// <returns>The filter.</returns>
public FileTypeFilter ToFileTypeFilter() => this.HasFixedFileName
? FileTypeFilter.ByFileName(this.Name, this.FileName)
: FileTypeFilter.Leaf(this.Name, this.FileExtension.TrimStart('.'));
/// <summary>
/// Returns the file name the save dialog starts with.
/// </summary>
/// <remarks>
/// A fixed file name wins over whatever the file is about: Docker finds a Dockerfile, but no
/// "Backend.Dockerfile" unless somebody tells it so.
/// </remarks>
/// <param name="name">What the file is about, for example the heading above the code block.
/// Null or blank falls back to a generic name.</param>
/// <returns>The suggested file name.</returns>
public string ToSuggestedFileName(string? name = null) => this.HasFixedFileName ? this.FileName : FileExportFormatExtensions.SuggestFileName(name, this.FileExtension);
/// <summary>
/// Returns the encoding the file gets written with: UTF-8, with a byte order mark only where
/// the language needs one.
/// </summary>
/// <returns>The encoding to write the file with.</returns>
public Encoding ToFileEncoding() => this.NeedsByteOrderMark ? WITH_BYTE_ORDER_MARK : WITHOUT_BYTE_ORDER_MARK;
}
@@ -0,0 +1,198 @@
using System.Collections.Frozen;
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools;
/// <summary>
/// The languages AI Studio recognizes behind the opening fence of a code block, so that the block
/// can be saved as a file of its own.
/// </summary>
/// <remarks>
/// Models do not agree on how they spell a language: Python comes as python, py, or Python3. The
/// catalog therefore knows the usual aliases, the way GitHub Linguist and highlight.js spell them,
/// in any case. The formats AI Studio writes as formats of their own, such as html, latex, markdown,
/// or csv, are missing on purpose: FileExportFormatExtensions.TryFromCodeFenceLanguage reads them
/// first, and a file in one of them is treated apart from code, it takes the sources of the answer
/// along, for example. Each language has exactly one extension; whoever needs another one renames
/// the file.
/// </remarks>
public static class SourceCodeLanguages
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SourceCodeLanguages).Namespace, nameof(SourceCodeLanguages));
/// <summary>
/// What a model writes behind the fence when it means plain text.
/// </summary>
private static readonly FrozenSet<string> PLAIN_TEXT_ALIASES = new[] { "text", "txt", "plaintext", "plain", "none", "nohighlight" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
private static readonly FrozenDictionary<string, SourceCodeLanguage> LANGUAGES_BY_ALIAS = BuildCatalog(
[
(new("ABAP", ".abap"), ["abap"]),
(new("Ada", ".adb"), ["ada"]),
(new("Apache", ".conf"), ["apache", "apacheconf"]),
(new("Arduino", ".ino"), ["arduino", "ino"]),
(new("AsciiDoc", ".adoc"), ["asciidoc", "adoc"]),
(new("Assembly", ".asm"), ["asm", "assembly", "nasm", "x86asm"]),
(new("AWK", ".awk"), ["awk"]),
(new("Batch", ".bat", NeedsWindowsLineEndings: true), ["bat", "batch", "batchfile", "cmd", "dos"]),
(new("BibTeX", ".bib"), ["bibtex", "biblatex", "bib"]),
(new("Bicep", ".bicep"), ["bicep"]),
(new("C", ".c"), ["c"]),
(new("C#", ".cs"), ["csharp", "cs", "c#"]),
(new("C++", ".cpp"), ["cpp", "c++", "cc", "cxx"]),
(new("Clojure", ".clj"), ["clojure", "clj"]),
(new("CMake", string.Empty, "CMakeLists.txt"), ["cmake"]),
(new("COBOL", ".cbl"), ["cobol"]),
(new("Containerfile", string.Empty, "Containerfile"), ["containerfile"]),
(new("CSS", ".css"), ["css"]),
(new("CUDA", ".cu"), ["cuda", "cu"]),
(new("Cypher", ".cypher"), ["cypher"]),
(new("D", ".d"), ["d"]),
(new("Dart", ".dart"), ["dart"]),
(new("Diff", ".diff"), ["diff", "udiff"]),
(new("Dockerfile", string.Empty, "Dockerfile"), ["dockerfile", "docker"]),
(new("Dotenv", ".env"), ["dotenv", "env"]),
(new("Elixir", ".ex"), ["elixir", "ex"]),
(new("Elm", ".elm"), ["elm"]),
(new("Emacs Lisp", ".el"), ["elisp", "emacs-lisp"]),
(new("Erlang", ".erl"), ["erlang", "erl"]),
(new("F#", ".fs"), ["fsharp", "fs", "f#"]),
(new("Fish", ".fish"), ["fish"]),
(new("Fortran", ".f90"), ["fortran", "f90", "f95", "f03", "f08"]),
(new("GDScript", ".gd"), ["gdscript", "gd"]),
(new("Gherkin", ".feature"), ["gherkin", "cucumber", "feature"]),
(new("GLSL", ".glsl"), ["glsl"]),
(new("Gnuplot", ".gp"), ["gnuplot"]),
(new("Go", ".go"), ["go", "golang"]),
(new("GraphQL", ".graphql"), ["graphql", "gql"]),
(new("Graphviz", ".dot"), ["graphviz", "dot", "gv"]),
(new("Groovy", ".groovy"), ["groovy", "gradle"]),
(new("Haskell", ".hs"), ["haskell", "hs"]),
(new("HCL", ".hcl"), ["hcl"]),
(new("HLSL", ".hlsl"), ["hlsl"]),
(new("HTTP", ".http"), ["http"]),
(new("INI", ".ini"), ["ini", "dosini"]),
(new("Java", ".java"), ["java"]),
(new("Java Properties", ".properties"), ["properties", "java-properties"]),
(new("JavaScript", ".js"), ["javascript", "js", "node", "nodejs"]),
(new("Jenkinsfile", string.Empty, "Jenkinsfile"), ["jenkinsfile", "jenkins"]),
(new("Jinja", ".j2"), ["jinja", "jinja2", "j2"]),
(new("JSON", ".json"), ["json"]),
(new("JSON5", ".json5"), ["json5"]),
(new("JSONC", ".jsonc"), ["jsonc"]),
(new("JSX", ".jsx"), ["jsx"]),
(new("Julia", ".jl"), ["julia", "jl"]),
(new("Kotlin", ".kt"), ["kotlin", "kt"]),
(new("Less", ".less"), ["less"]),
(new("Lisp", ".lisp"), ["lisp", "commonlisp", "common-lisp"]),
(new("Log", ".log"), ["log"]),
(new("Lua", ".lua"), ["lua"]),
(new("Makefile", string.Empty, "Makefile"), ["makefile", "make"]),
(new("Mathematica", ".wl"), ["mathematica", "wolfram", "wl"]),
(new("MATLAB", ".m"), ["matlab"]),
(new("Mermaid", ".mmd"), ["mermaid", "mmd"]),
(new("Modelica", ".mo"), ["modelica"]),
(new("Mojo", ".mojo"), ["mojo"]),
(new("Nginx", ".conf"), ["nginx", "nginxconf"]),
(new("Nim", ".nim"), ["nim"]),
(new("Nix", ".nix"), ["nix"]),
(new("Objective-C", ".m"), ["objectivec", "objective-c", "objc", "obj-c"]),
(new("OCaml", ".ml"), ["ocaml", "ml"]),
(new("Octave", ".m"), ["octave"]),
(new("Pascal", ".pas"), ["pascal", "delphi", "objectpascal", "pas"]),
(new("Patch", ".patch"), ["patch"]),
(new("Perl", ".pl"), ["perl", "pl"]),
(new("PHP", ".php"), ["php"]),
(new("PlantUML", ".puml"), ["plantuml", "puml"]),
(new("PowerShell", ".ps1", NeedsByteOrderMark: true), ["powershell", "ps1", "pwsh", "posh"]),
(new("Prisma", ".prisma"), ["prisma"]),
(new("Prolog", ".pl"), ["prolog"]),
(new("Protocol Buffers", ".proto"), ["protobuf", "proto", "proto3"]),
(new("Python", ".py"), ["python", "py", "python3", "py3"]),
(new("R", ".R"), ["r"]),
(new("Racket", ".rkt"), ["racket", "rkt"]),
(new("Razor", ".razor"), ["razor"]),
(new("reStructuredText", ".rst"), ["restructuredtext", "rst"]),
(new("Ruby", ".rb"), ["ruby", "rb"]),
(new("Rust", ".rs"), ["rust", "rs"]),
(new("SAS", ".sas"), ["sas"]),
(new("Sass", ".sass"), ["sass"]),
(new("Scala", ".scala"), ["scala"]),
(new("Scheme", ".scm"), ["scheme", "scm"]),
(new("SCSS", ".scss"), ["scss"]),
(new("sed", ".sed"), ["sed"]),
(new("Shell", ".sh"), ["bash", "sh", "shell", "ksh"]),
(new("Solidity", ".sol"), ["solidity", "sol"]),
(new("SPARQL", ".rq"), ["sparql", "rq"]),
(new("SQL", ".sql"), ["sql", "mysql", "mssql", "postgresql", "postgres", "pgsql", "plpgsql", "plsql", "sqlite", "tsql"]),
(new("Stata", ".do"), ["stata"]),
(new("Svelte", ".svelte"), ["svelte"]),
(new("SVG", ".svg"), ["svg"]),
(new("Swift", ".swift"), ["swift"]),
(new("SystemVerilog", ".sv"), ["systemverilog", "sv"]),
(new("Tcl", ".tcl"), ["tcl"]),
(new("Terraform", ".tf"), ["terraform", "tf"]),
(new("TOML", ".toml"), ["toml"]),
(new("TSX", ".tsx"), ["tsx"]),
(new("Turtle", ".ttl"), ["turtle", "ttl"]),
(new("TypeScript", ".ts"), ["typescript", "ts"]),
(new("Typst", ".typ"), ["typst", "typ"]),
(new("VB.NET", ".vb"), ["vbnet", "vb.net", "vb", "visualbasic"]),
(new("VBScript", ".vbs"), ["vbscript", "vbs"]),
(new("Verilog", ".v"), ["verilog"]),
(new("VHDL", ".vhd"), ["vhdl", "vhd"]),
(new("Vim script", ".vim"), ["vim", "vimscript", "viml"]),
(new("Vue", ".vue"), ["vue"]),
(new("WebAssembly", ".wat"), ["wat", "wast", "wasm"]),
(new("XAML", ".xaml"), ["xaml"]),
(new("XML", ".xml"), ["xml"]),
(new("XSLT", ".xslt"), ["xslt", "xsl"]),
(new("YAML", ".yaml"), ["yaml", "yml"]),
(new("Zig", ".zig"), ["zig"]),
(new("Zsh", ".zsh"), ["zsh"]),
]);
/// <summary>
/// Every alias the catalog knows, including the ones for plain text.
/// </summary>
internal static IEnumerable<string> Aliases => [..PLAIN_TEXT_ALIASES, ..LANGUAGES_BY_ALIAS.Keys];
/// <summary>
/// Every language the catalog knows.
/// </summary>
internal static IEnumerable<SourceCodeLanguage> Languages => LANGUAGES_BY_ALIAS.Values.Distinct();
/// <summary>
/// Reads which language a model means when it names one behind the opening fence of a code block.
/// </summary>
/// <remarks>
/// Every code block becomes a file. A language we do not know keeps its name and becomes a text
/// file, so the user still sees which block it is; a block without any language is plain text.
/// </remarks>
/// <param name="language">The language behind the opening fence, as Markdig reads it into
/// FencedCodeBlock.Info.</param>
/// <returns>The language, never NONE.</returns>
public static SourceCodeLanguage FromCodeFenceLanguage(string? language)
{
var name = language?.Trim() ?? string.Empty;
if (name.Length is 0 || PLAIN_TEXT_ALIASES.Contains(name))
return new(TB("Text"), ".txt");
return LANGUAGES_BY_ALIAS.TryGetValue(name, out var knownLanguage) ? knownLanguage : new(name, ".txt");
}
private static FrozenDictionary<string, SourceCodeLanguage> BuildCatalog(IEnumerable<(SourceCodeLanguage Language, string[] Aliases)> languages)
{
//
// Add throws for an alias named twice. A mistake in the list above therefore fails every test
// which touches the catalog, instead of quietly hiding one of the two languages:
//
var catalog = new Dictionary<string, SourceCodeLanguage>(StringComparer.OrdinalIgnoreCase);
foreach (var (language, aliases) in languages)
foreach (var alias in aliases)
catalog.Add(alias, language);
return catalog.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
}