From 6b20ba564f45d44e0fd32b5605b90e9538a9fa0c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 10:30:41 +0000 Subject: [PATCH] Add ILanguageGeneratorResolver to look up a generator by id or file extension [minor] AddLanguageGenerators now registers a resolver over the generators, with TryGetByLanguageId and TryGetByFileExtension. Both ignore case; the extension lookup takes a bare or dotted extension or a file path. When two generators claim the same id or extension, the first registered wins. The editor's preview lookup goes through it instead of its own FirstOrDefault. Fixes #184 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012KjGQ13RexM4WYjuEaBtE6 --- Coder.Editor/CoderEditorApp.cs | 8 +- .../LanguageGeneratorResolverTests.cs | 127 ++++++++++++++++++ Coder/Languages/ILanguageGeneratorResolver.cs | 44 ++++++ Coder/Languages/LanguageGeneratorResolver.cs | 67 +++++++++ Coder/ServiceCollectionExtensions.cs | 6 +- 5 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 Coder.Test/Languages/LanguageGeneratorResolverTests.cs create mode 100644 Coder/Languages/ILanguageGeneratorResolver.cs create mode 100644 Coder/Languages/LanguageGeneratorResolver.cs diff --git a/Coder.Editor/CoderEditorApp.cs b/Coder.Editor/CoderEditorApp.cs index eca86e5..0c2f25f 100644 --- a/Coder.Editor/CoderEditorApp.cs +++ b/Coder.Editor/CoderEditorApp.cs @@ -32,7 +32,7 @@ public sealed class CoderEditorApp( IEnumerable generators, EditorSettings settings) { - private readonly List generators = [.. generators]; + private readonly LanguageGeneratorResolver resolver = new(generators); private string pathBuffer = string.Empty; /// @@ -384,7 +384,7 @@ private void DrawCodePane() { ImGui.TextUnformatted("Generated code"); - foreach (ILanguageGenerator generator in generators) + foreach (ILanguageGenerator generator in resolver.Generators) { ImGui.SameLine(); if (ImGui.RadioButton(generator.DisplayName, string.Equals(Settings.PreviewLanguageId, generator.LanguageId, StringComparison.Ordinal))) @@ -545,8 +545,8 @@ public bool CopyGeneratedCode() /// Gets the generator whose output the pane is showing, or null when the remembered language is /// one this build does not have. /// - private ILanguageGenerator? PreviewGenerator => generators.FirstOrDefault( - g => string.Equals(g.LanguageId, Settings.PreviewLanguageId, StringComparison.Ordinal)); + private ILanguageGenerator? PreviewGenerator => + resolver.TryGetByLanguageId(Settings.PreviewLanguageId, out ILanguageGenerator? generator) ? generator : null; private void DrawStatusBar() { diff --git a/Coder.Test/Languages/LanguageGeneratorResolverTests.cs b/Coder.Test/Languages/LanguageGeneratorResolverTests.cs new file mode 100644 index 0000000..fdd399e --- /dev/null +++ b/Coder.Test/Languages/LanguageGeneratorResolverTests.cs @@ -0,0 +1,127 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Test.Languages; + +using System; +using ktsu.Coder; +using ktsu.Coder.Ast; +using ktsu.Coder.Languages; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests for : one lookup by language id or file extension, +/// so consumers agree on case, the leading dot, and which generator wins a tie. +/// +[TestClass] +public class LanguageGeneratorResolverTests +{ + private static ILanguageGeneratorResolver RegisteredResolver() + { + ServiceCollection services = new(); + services.AddLanguageGenerators(); + return services.BuildServiceProvider().GetRequiredService(); + } + + [TestMethod] + [DataRow("python", typeof(PythonGenerator))] + [DataRow("csharp", typeof(CSharpGenerator))] + [DataRow("javascript", typeof(JavaScriptGenerator))] + [DataRow("cpp", typeof(CppGenerator))] + [DataRow("c", typeof(CGenerator))] + [DataRow("rust", typeof(RustGenerator))] + [DataRow("go", typeof(GoGenerator))] + [DataRow("Python", typeof(PythonGenerator))] + [DataRow("CSHARP", typeof(CSharpGenerator))] + public void TryGetByLanguageId_ResolvesEachBuiltInIdIgnoringCase(string languageId, Type expected) + { + Assert.IsTrue(RegisteredResolver().TryGetByLanguageId(languageId, out ILanguageGenerator? generator)); + Assert.IsInstanceOfType(generator, expected); + } + + [TestMethod] + [DataRow("py", typeof(PythonGenerator))] + [DataRow("cs", typeof(CSharpGenerator))] + [DataRow("js", typeof(JavaScriptGenerator))] + [DataRow("cpp", typeof(CppGenerator))] + [DataRow("c", typeof(CGenerator))] + [DataRow("rs", typeof(RustGenerator))] + [DataRow("go", typeof(GoGenerator))] + [DataRow(".py", typeof(PythonGenerator))] + [DataRow(".RS", typeof(RustGenerator))] + [DataRow("model.py", typeof(PythonGenerator))] + [DataRow("out/model.CS", typeof(CSharpGenerator))] + [DataRow(@"out\nested.dir\model.go", typeof(GoGenerator))] + public void TryGetByFileExtension_AcceptsABareOrDottedExtensionOrAPath(string extensionOrPath, Type expected) + { + Assert.IsTrue(RegisteredResolver().TryGetByFileExtension(extensionOrPath, out ILanguageGenerator? generator)); + Assert.IsInstanceOfType(generator, expected); + } + + [TestMethod] + [DataRow("")] + [DataRow("cobol")] + [DataRow("py ")] + [DataRow("pyt")] + public void TryGetByLanguageId_ReturnsFalseForAnUnknownId(string languageId) + { + Assert.IsFalse(RegisteredResolver().TryGetByLanguageId(languageId, out ILanguageGenerator? generator)); + Assert.IsNull(generator); + } + + [TestMethod] + [DataRow("")] + [DataRow(".")] + [DataRow("cob")] + [DataRow("model")] + [DataRow("out/model")] + [DataRow("out.py/model")] + [DataRow("model.py.bak")] + public void TryGetByFileExtension_ReturnsFalseForAnUnknownExtension(string extensionOrPath) + { + Assert.IsFalse(RegisteredResolver().TryGetByFileExtension(extensionOrPath, out ILanguageGenerator? generator)); + Assert.IsNull(generator); + } + + [TestMethod] + public void Generators_ListsEveryRegisteredGenerator() + { + Assert.HasCount(7, RegisteredResolver().Generators); + } + + [TestMethod] + public void AClaimedIdOrExtension_ResolvesToTheFirstGeneratorGiven() + { + ILanguageGenerator first = new StubGenerator("python", "py"); + ILanguageGenerator second = new StubGenerator("PYTHON", ".PY"); + LanguageGeneratorResolver resolver = new([first, second]); + + Assert.IsTrue(resolver.TryGetByLanguageId("python", out ILanguageGenerator? byId)); + Assert.AreSame(first, byId); + Assert.IsTrue(resolver.TryGetByFileExtension("py", out ILanguageGenerator? byExtension)); + Assert.AreSame(first, byExtension); + } + + [TestMethod] + public void AGeneratorDeclaringItsExtensionWithADot_StillResolves() + { + ILanguageGenerator dotted = new StubGenerator("dotted", ".dt"); + LanguageGeneratorResolver resolver = new([dotted]); + + Assert.IsTrue(resolver.TryGetByFileExtension("dt", out ILanguageGenerator? generator)); + Assert.AreSame(dotted, generator); + } + + private sealed class StubGenerator(string languageId, string fileExtension) : ILanguageGenerator + { + public string LanguageId { get; } = languageId; + + public string DisplayName => LanguageId; + + public string FileExtension { get; } = fileExtension; + + public string Generate(AstNode astNode) => string.Empty; + + public bool CanGenerate(AstNode astNode) => false; + } +} diff --git a/Coder/Languages/ILanguageGeneratorResolver.cs b/Coder/Languages/ILanguageGeneratorResolver.cs new file mode 100644 index 0000000..7da1325 --- /dev/null +++ b/Coder/Languages/ILanguageGeneratorResolver.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Languages; + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +/// +/// Finds a registered by the name a caller has for its language. +/// +/// +/// A caller has one of two names for a language: the id it was told (--language py, a +/// remembered setting) or the extension of the file it is writing. Both lookups live here so every +/// consumer agrees on case and on the leading dot, rather than each writing its own +/// FirstOrDefault. +/// +public interface ILanguageGeneratorResolver +{ + /// + /// Gets every generator the resolver can return, in registration order. + /// + public IReadOnlyList Generators { get; } + + /// + /// Finds the generator whose is , + /// ignoring case. + /// + /// The language id, such as python. + /// The generator, or null when none has that id. + /// True when a generator was found; otherwise, false. + public bool TryGetByLanguageId(string languageId, [NotNullWhen(true)] out ILanguageGenerator? generator); + + /// + /// Finds the generator whose matches + /// , ignoring case. + /// + /// + /// An extension with or without its leading dot (py, .py), or a file path whose + /// extension is used (out/model.py). + /// + /// The generator, or null when none writes that extension. + /// True when a generator was found; otherwise, false. + public bool TryGetByFileExtension(string extensionOrPath, [NotNullWhen(true)] out ILanguageGenerator? generator); +} diff --git a/Coder/Languages/LanguageGeneratorResolver.cs b/Coder/Languages/LanguageGeneratorResolver.cs new file mode 100644 index 0000000..86206e3 --- /dev/null +++ b/Coder/Languages/LanguageGeneratorResolver.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Coder.Languages; + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; + +/// +/// Resolves a language generator from the generators it is given. +/// +/// +/// When two generators claim the same id or extension, the first one given wins, so the answer +/// does not depend on anything but registration order. +/// +public sealed class LanguageGeneratorResolver : ILanguageGeneratorResolver +{ + /// + /// Initializes a new instance of the class. + /// + /// The generators to resolve from. + public LanguageGeneratorResolver(IEnumerable generators) + { + Ensure.NotNull(generators); + Generators = [.. generators]; + } + + /// + public IReadOnlyList Generators { get; } + + /// + public bool TryGetByLanguageId(string languageId, [NotNullWhen(true)] out ILanguageGenerator? generator) + { + Ensure.NotNull(languageId); + + generator = Generators.FirstOrDefault( + g => string.Equals(g.LanguageId, languageId, StringComparison.OrdinalIgnoreCase)); + return generator is not null; + } + + /// + public bool TryGetByFileExtension(string extensionOrPath, [NotNullWhen(true)] out ILanguageGenerator? generator) + { + Ensure.NotNull(extensionOrPath); + + string extension = ExtensionOf(extensionOrPath); + generator = extension.Length == 0 + ? null + : Generators.FirstOrDefault( + g => string.Equals(g.FileExtension.TrimStart('.'), extension, StringComparison.OrdinalIgnoreCase)); + return generator is not null; + } + + /// + /// Reads the extension, without its dot, out of a bare extension or a path. + /// + /// A bare extension (py), a dotted one (.py) or a path. + /// The extension without its dot, or empty when there is none. + private static string ExtensionOf(string extensionOrPath) + { + string trimmed = extensionOrPath.Trim(); + bool isBare = trimmed.IndexOfAny(['.', '/', '\\']) < 0; + return isBare ? trimmed : Path.GetExtension(trimmed).TrimStart('.'); + } +} diff --git a/Coder/ServiceCollectionExtensions.cs b/Coder/ServiceCollectionExtensions.cs index 843ec0f..992a8ee 100644 --- a/Coder/ServiceCollectionExtensions.cs +++ b/Coder/ServiceCollectionExtensions.cs @@ -12,7 +12,8 @@ namespace ktsu.Coder; public static class ServiceCollectionExtensions { /// - /// Adds all available language generators to the service collection. + /// Adds all available language generators, and an over + /// them, to the service collection. /// /// The service collection to add services to. /// The service collection for method chaining. @@ -30,6 +31,9 @@ public static IServiceCollection AddLanguageGenerators(this IServiceCollection s services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + // One lookup by id or file extension, so consumers agree on case and the leading dot. + services.TryAddSingleton(); + return services; }