mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 21:07:21 +00:00
Added saving code blocks of every programming language (#1117)
This commit is contained in:
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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user