Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Coder.Editor/CoderEditorApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
IEnumerable<ILanguageGenerator> generators,
EditorSettings settings)
{
private readonly List<ILanguageGenerator> generators = [.. generators];
private readonly LanguageGeneratorResolver resolver = new(generators);
private string pathBuffer = string.Empty;

/// <summary>
Expand Down Expand Up @@ -141,7 +141,7 @@
{
ClassDeclaration declaration = new("Counter");

declaration.Members.Add(new FieldDeclaration("count", "int") { InitialValue = new LiteralExpression<int>(0) });

Check warning on line 144 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'count' 5 times.

Check warning on line 144 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'count' 5 times.

Check warning on line 144 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'count' 5 times.

Check warning on line 144 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Define a constant instead of using this literal 'count' 5 times.
declaration.Members.Add(new FieldDeclaration("step", "int") { InitialValue = new LiteralExpression<int>(1) });

FunctionDeclaration add = new("Add") { ReturnType = "int" };
Expand Down Expand Up @@ -323,7 +323,7 @@
/// Draws the application's File menu.
/// </summary>
/// <remarks>Called from inside the application's main menu bar, so it opens no bar of its own.</remarks>
public void DrawMenu()

Check warning on line 326 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

Check warning on line 326 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

Check warning on line 326 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.
{
if (ImGui.BeginMenu("File"))
{
Expand Down Expand Up @@ -365,7 +365,7 @@

if (Settings.RecentFiles.Count > 0 && ImGui.BeginMenu("Recent"))
{
foreach (string recent in Settings.RecentFiles.ToArray())

Check warning on line 368 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 368 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 368 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (ImGui.MenuItem(recent))
{
Expand All @@ -384,7 +384,7 @@
{
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)))
Expand Down Expand Up @@ -545,8 +545,8 @@
/// Gets the generator whose output the pane is showing, or null when the remembered language is
/// one this build does not have.
/// </summary>
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()
{
Expand Down
127 changes: 127 additions & 0 deletions Coder.Test/Languages/LanguageGeneratorResolverTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for <see cref="LanguageGeneratorResolver"/>: one lookup by language id or file extension,
/// so consumers agree on case, the leading dot, and which generator wins a tie.
/// </summary>
[TestClass]
public class LanguageGeneratorResolverTests
{
private static ILanguageGeneratorResolver RegisteredResolver()
{
ServiceCollection services = new();
services.AddLanguageGenerators();
return services.BuildServiceProvider().GetRequiredService<ILanguageGeneratorResolver>();
}

[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;
}
}
44 changes: 44 additions & 0 deletions Coder/Languages/ILanguageGeneratorResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Coder.Languages;

using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;

/// <summary>
/// Finds a registered <see cref="ILanguageGenerator"/> by the name a caller has for its language.
/// </summary>
/// <remarks>
/// A caller has one of two names for a language: the id it was told (<c>--language py</c>, 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
/// <c>FirstOrDefault</c>.
/// </remarks>
public interface ILanguageGeneratorResolver
{
/// <summary>
/// Gets every generator the resolver can return, in registration order.
/// </summary>
public IReadOnlyList<ILanguageGenerator> Generators { get; }

/// <summary>
/// Finds the generator whose <see cref="ILanguageGenerator.LanguageId"/> is <paramref name="languageId"/>,
/// ignoring case.
/// </summary>
/// <param name="languageId">The language id, such as <c>python</c>.</param>
/// <param name="generator">The generator, or null when none has that id.</param>
/// <returns>True when a generator was found; otherwise, false.</returns>
public bool TryGetByLanguageId(string languageId, [NotNullWhen(true)] out ILanguageGenerator? generator);

/// <summary>
/// Finds the generator whose <see cref="ILanguageGenerator.FileExtension"/> matches
/// <paramref name="extensionOrPath"/>, ignoring case.
/// </summary>
/// <param name="extensionOrPath">
/// An extension with or without its leading dot (<c>py</c>, <c>.py</c>), or a file path whose
/// extension is used (<c>out/model.py</c>).
/// </param>
/// <param name="generator">The generator, or null when none writes that extension.</param>
/// <returns>True when a generator was found; otherwise, false.</returns>
public bool TryGetByFileExtension(string extensionOrPath, [NotNullWhen(true)] out ILanguageGenerator? generator);
}
67 changes: 67 additions & 0 deletions Coder/Languages/LanguageGeneratorResolver.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Resolves a language generator from the generators it is given.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class LanguageGeneratorResolver : ILanguageGeneratorResolver
{
/// <summary>
/// Initializes a new instance of the <see cref="LanguageGeneratorResolver"/> class.
/// </summary>
/// <param name="generators">The generators to resolve from.</param>
public LanguageGeneratorResolver(IEnumerable<ILanguageGenerator> generators)
{
Ensure.NotNull(generators);
Generators = [.. generators];
}

/// <inheritdoc/>
public IReadOnlyList<ILanguageGenerator> Generators { get; }

/// <inheritdoc/>
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;
}

/// <inheritdoc/>
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;
}

/// <summary>
/// Reads the extension, without its dot, out of a bare extension or a path.
/// </summary>
/// <param name="extensionOrPath">A bare extension (<c>py</c>), a dotted one (<c>.py</c>) or a path.</param>
/// <returns>The extension without its dot, or empty when there is none.</returns>
private static string ExtensionOf(string extensionOrPath)
{
string trimmed = extensionOrPath.Trim();
bool isBare = trimmed.IndexOfAny(['.', '/', '\\']) < 0;
return isBare ? trimmed : Path.GetExtension(trimmed).TrimStart('.');
}
}
6 changes: 5 additions & 1 deletion Coder/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ namespace ktsu.Coder;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds all available language generators to the service collection.
/// Adds all available language generators, and an <see cref="ILanguageGeneratorResolver"/> over
/// them, to the service collection.
/// </summary>
/// <param name="services">The service collection to add services to.</param>
/// <returns>The service collection for method chaining.</returns>
Expand All @@ -30,6 +31,9 @@ public static IServiceCollection AddLanguageGenerators(this IServiceCollection s
services.TryAddEnumerable(ServiceDescriptor.Singleton<ILanguageGenerator, RustGenerator>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ILanguageGenerator, GoGenerator>());

// One lookup by id or file extension, so consumers agree on case and the leading dot.
services.TryAddSingleton<ILanguageGeneratorResolver, LanguageGeneratorResolver>();

return services;
}

Expand Down
Loading