From 108714295ee5bd6d412fd37b2b8753d4dc5498f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:15:04 +0200 Subject: [PATCH 01/18] Add reflection language metamodel --- .../Language/ILanguageElement.cs | 11 ++ .../Language/LanguageCompiler.cs | 182 ++++++++++++++++++ .../Language/LanguageDescriptors.cs | 35 ++++ .../Language/LanguageRegistry.cs | 57 ++---- .../Language/LanguageSnapshot.cs | 61 ++++++ .../Language/Metadata/Descriptors.cs | 25 +++ .../Language/Metadata/LanguageAttributes.cs | 71 +++++++ .../Language/Metadata/TypeShape.cs | 42 ++++ src/FluNet.Engine/Language/SentencePattern.cs | 26 ++- src/FluNet.Engine/Syntax/Core/IRole.cs | 26 +++ src/FluNet.Engine/Syntax/Core/VerbFamilies.cs | 15 ++ src/FluNet.Engine/Syntax/Nouns/IFrom.cs | 14 +- src/FluNet.Engine/Syntax/Nouns/IThen.cs | 14 +- src/FluNet.Engine/Syntax/Nouns/ITo.cs | 12 +- src/FluNet.Engine/Syntax/Nouns/IUsing.cs | 12 +- src/FluNet.Engine/Syntax/Nouns/IWhat.cs | 12 +- src/FluNet.Engine/Syntax/Nouns/IWith.cs | 12 +- tests/FluNET.Tests/LanguageMetadataTests.cs | 42 ++++ 18 files changed, 559 insertions(+), 110 deletions(-) create mode 100644 src/FluNet.Engine/Language/ILanguageElement.cs create mode 100644 src/FluNet.Engine/Language/LanguageCompiler.cs create mode 100644 src/FluNet.Engine/Language/LanguageDescriptors.cs create mode 100644 src/FluNet.Engine/Language/LanguageSnapshot.cs create mode 100644 src/FluNet.Engine/Language/Metadata/Descriptors.cs create mode 100644 src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs create mode 100644 src/FluNet.Engine/Language/Metadata/TypeShape.cs create mode 100644 src/FluNet.Engine/Syntax/Core/IRole.cs create mode 100644 src/FluNet.Engine/Syntax/Core/VerbFamilies.cs create mode 100644 tests/FluNET.Tests/LanguageMetadataTests.cs diff --git a/src/FluNet.Engine/Language/ILanguageElement.cs b/src/FluNet.Engine/Language/ILanguageElement.cs new file mode 100644 index 0000000..d9d04ec --- /dev/null +++ b/src/FluNet.Engine/Language/ILanguageElement.cs @@ -0,0 +1,11 @@ +namespace FluNET.Language; + +/// +/// Common identity contract for elements that become part of the compiled FluNET language. +/// Stable identifiers are intended for diagnostics, manifests, tooling and caches. +/// +public interface ILanguageElement +{ + string StableId { get; } + string Name { get; } +} diff --git a/src/FluNet.Engine/Language/LanguageCompiler.cs b/src/FluNet.Engine/Language/LanguageCompiler.cs new file mode 100644 index 0000000..ee1931b --- /dev/null +++ b/src/FluNet.Engine/Language/LanguageCompiler.cs @@ -0,0 +1,182 @@ +using FluNET.Language.Metadata; +using FluNET.Syntax.Core; +using FluNET.Syntax.Nouns; +using System.Reflection; + +namespace FluNET.Language; + +/// +/// Compiles CLR/reflection metadata into stable language descriptors. Reflection belongs +/// here (startup/build time), not in parser/binder hot paths. +/// +public sealed class LanguageCompiler +{ + private readonly NullabilityInfoContext _nullability = new(); + + public VerbDescriptor DescribeVerb( + Type verbType, + string text, + IReadOnlyList synonyms, + Func factory) + { + IReadOnlyList constructors = DescribeConstructors(verbType); + SentencePattern pattern = BuildPattern(verbType, text, constructors); + + return new VerbDescriptor(verbType, text, synonyms, pattern, factory) + { + Constructors = constructors, + ResultType = InferResultType(verbType), + FamilyType = InferFamilyType(verbType), + Capabilities = verbType.GetCustomAttributes(true) + .Select(x => x.Capability) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() + }; + } + + public IReadOnlyList DescribeConstructors(Type type) => + type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .Select(constructor => new ConstructorDescriptor( + constructor, + constructor.GetParameters().Select(parameter => DescribeParameter(type, parameter)).ToArray())) + .OrderByDescending(x => x.RoleParameterCount) + .ThenBy(x => x.ServiceParameterCount) + .ToArray(); + + private ParameterDescriptor DescribeParameter(Type verbType, ParameterInfo parameter) + { + ClauseKind? role = InferRole(parameter); + NullabilityInfo nullability = _nullability.Create(parameter); + bool isParams = parameter.GetCustomAttribute() != null; + bool optional = parameter.IsOptional + || parameter.HasDefaultValue + || parameter.GetCustomAttribute() != null + || nullability.ReadState == NullabilityState.Nullable; + + return new ParameterDescriptor( + parameter, + parameter.Name ?? $"arg{parameter.Position}", + parameter.ParameterType, + role, + InferDirection(verbType, parameter, role), + optional, + isParams, + parameter.GetCustomAttribute() != null, + nullability.ReadState, + nullability.WriteState, + TypeShape.Analyze(parameter.ParameterType)); + } + + private static ClauseKind? InferRole(ParameterInfo parameter) + { + RoleAttribute? explicitRole = parameter.GetCustomAttribute(); + if (explicitRole != null) + return explicitRole.Kind; + + return parameter.Name?.ToLowerInvariant() switch + { + "what" => ClauseKind.What, + "from" => ClauseKind.From, + "to" => ClauseKind.To, + "using" => ClauseKind.Using, + "with" => ClauseKind.With, + "then" => ClauseKind.Then, + _ => null + }; + } + + private static RoleDirection InferDirection(Type verbType, ParameterInfo parameter, ClauseKind? role) + { + if (parameter.GetCustomAttribute() != null) return RoleDirection.Output; + if (parameter.GetCustomAttribute() != null) return RoleDirection.InputOutput; + if (parameter.GetCustomAttribute() != null) return RoleDirection.Input; + + if (role == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get")) + return RoleDirection.Output; + + return RoleDirection.Input; + } + + private static SentencePattern BuildPattern( + Type verbType, + string text, + IReadOnlyList constructors) + { + ConstructorDescriptor? constructor = constructors.FirstOrDefault(x => x.RoleParameterCount > 0); + if (constructor != null) + { + ClauseDescriptor[] constructorClauses = constructor.Parameters + .Where(x => x.Role != null) + .Select(x => new ClauseDescriptor( + x.Role!.Value, + x.ParameterType, + !x.IsOptional, + x.Name, + x.Direction, + x.IsParams ? RoleCardinality.ZeroOrMore : (x.IsOptional ? RoleCardinality.ZeroOrOne : RoleCardinality.One), + x.Shape.ElementType)) + .ToArray(); + + if (constructorClauses.Length > 0) + return new SentencePattern(text.ToUpperInvariant(), constructorClauses); + } + + List clauses = []; + foreach (Type contract in verbType.GetInterfaces().Where(x => x.IsGenericType)) + { + Type definition = contract.GetGenericTypeDefinition(); + Type valueType = contract.GetGenericArguments()[0]; + ClauseKind? kind = RoleKindFor(definition); + if (kind == null) continue; + + TypeShape shape = TypeShape.Analyze(valueType); + RoleDirection direction = kind == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") + ? RoleDirection.Output + : RoleDirection.Input; + + clauses.Add(new ClauseDescriptor(kind.Value, valueType, true, null, direction, RoleCardinality.One, shape.ElementType)); + } + + return new SentencePattern(text.ToUpperInvariant(), clauses); + } + + private static ClauseKind? RoleKindFor(Type genericDefinition) + { + if (genericDefinition == typeof(IWhat<>)) return ClauseKind.What; + if (genericDefinition == typeof(IFrom<>)) return ClauseKind.From; + if (genericDefinition == typeof(ITo<>)) return ClauseKind.To; + if (genericDefinition == typeof(IUsing<>)) return ClauseKind.Using; + if (genericDefinition == typeof(IWith<>)) return ClauseKind.With; + if (genericDefinition == typeof(IThen<>)) return ClauseKind.Then; + return null; + } + + private static Type? InferResultType(Type verbType) + { + Type? genericVerb = verbType.GetInterfaces().FirstOrDefault(x => + x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<,>)); + return genericVerb?.GetGenericArguments()[0]; + } + + private static Type? InferFamilyType(Type verbType) + { + Type[] families = [typeof(IGet), typeof(ISave), typeof(ILoad), typeof(ISend), typeof(IDelete), typeof(IDownload), typeof(IPost), typeof(ITransform), typeof(ISay)]; + return families.FirstOrDefault(x => x.IsAssignableFrom(verbType)); + } + + private static bool IsFamily(Type verbType, Type marker, string legacyBaseName) + { + if (marker.IsAssignableFrom(verbType)) return true; + + Type? current = verbType.BaseType; + while (current != null && current != typeof(object)) + { + Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; + if (candidate.Name.StartsWith(legacyBaseName, StringComparison.OrdinalIgnoreCase)) + return true; + current = current.BaseType; + } + + return false; + } +} diff --git a/src/FluNet.Engine/Language/LanguageDescriptors.cs b/src/FluNet.Engine/Language/LanguageDescriptors.cs new file mode 100644 index 0000000..621b146 --- /dev/null +++ b/src/FluNet.Engine/Language/LanguageDescriptors.cs @@ -0,0 +1,35 @@ +using FluNET.Syntax.Core; +using FluNET.Language.Metadata; + +namespace FluNET.Language; + +public sealed record WordDescriptor( + Type WordType, + string Text, + IReadOnlyList Synonyms, + Func Factory) : ILanguageElement +{ + public string StableId => $"word:{Text.ToLowerInvariant()}:{WordType.FullName}"; + public string Name => Text; +} + +public sealed record VerbDescriptor( + Type VerbType, + string Text, + IReadOnlyList Synonyms, + SentencePattern Pattern, + Func Factory) : ILanguageElement +{ + public string StableId => $"verb:{Text.ToLowerInvariant()}:{VerbType.FullName}"; + public string Name => Text; + public IReadOnlyList Constructors { get; init; } = []; + public Type? ResultType { get; init; } + public Type? FamilyType { get; init; } + public IReadOnlyList Capabilities { get; init; } = []; +} + +public sealed record QualifierDescriptor(string Text, Type? ValueType = null) : ILanguageElement +{ + public string StableId => $"qualifier:{Text.ToLowerInvariant()}"; + public string Name => Text; +} diff --git a/src/FluNet.Engine/Language/LanguageRegistry.cs b/src/FluNet.Engine/Language/LanguageRegistry.cs index b50de7e..c135860 100644 --- a/src/FluNet.Engine/Language/LanguageRegistry.cs +++ b/src/FluNet.Engine/Language/LanguageRegistry.cs @@ -1,28 +1,12 @@ using FluNET.Keywords; using FluNET.Syntax.Core; -using FluNET.Syntax.Nouns; using System.Reflection; namespace FluNET.Language; -public sealed record WordDescriptor( - Type WordType, - string Text, - IReadOnlyList Synonyms, - Func Factory); - -public sealed record VerbDescriptor( - Type VerbType, - string Text, - IReadOnlyList Synonyms, - SentencePattern Pattern, - Func Factory); - -public sealed record QualifierDescriptor(string Text, Type? ValueType = null); - /// -/// The single language catalog for FluNET Classic. Reflection is confined to -/// registration time; token lookup itself is dictionary based and does not scan assemblies. +/// Mutable registration facade used while composing a FluNET language. Reflection is +/// centralized at registration/compilation time; consumers can obtain an immutable snapshot. /// public sealed class LanguageRegistry { @@ -30,6 +14,7 @@ public sealed class LanguageRegistry private readonly Dictionary _verbs = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _qualifiers = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _assemblies = []; + private readonly LanguageCompiler _compiler = new(); public LanguageRegistry() { @@ -41,6 +26,8 @@ public LanguageRegistry() public IReadOnlyCollection Verbs => _verbs.Values.DistinctBy(x => x.VerbType).ToArray(); public IReadOnlyCollection Qualifiers => _qualifiers.Values.ToArray(); + public LanguageSnapshot Snapshot => new(Words, Verbs, Qualifiers); + public void RegisterAssemblies(IEnumerable assemblies) { foreach (Assembly assembly in assemblies) @@ -94,6 +81,8 @@ public bool TryCreateWord(string text, out IWord? word) public bool TryGetVerb(string text, out VerbDescriptor? descriptor) => _verbs.TryGetValue(text, out descriptor); + public IReadOnlyList GetVerbOverloads(string text) => Snapshot.GetVerbOverloads(text); + public Type? GetVerbBaseType(string text) { if (!_verbs.TryGetValue(text, out VerbDescriptor? descriptor)) @@ -124,33 +113,13 @@ private void RegisterWord(Type type) if (prototype is IVerb) { - SentencePattern pattern = BuildPattern(type, keyword.Text); - var verbDescriptor = new VerbDescriptor(type, keyword.Text, synonyms, pattern, () => factory() as IVerb); - _verbs[keyword.Text] = verbDescriptor; + VerbDescriptor descriptor = _compiler.DescribeVerb(type, keyword.Text, synonyms, () => factory() as IVerb); + _verbs[keyword.Text] = descriptor; foreach (string synonym in synonyms) - _verbs[synonym] = verbDescriptor; + _verbs[synonym] = descriptor; } } - private static SentencePattern BuildPattern(Type verbType, string text) - { - List clauses = []; - foreach (Type contract in verbType.GetInterfaces().Where(x => x.IsGenericType)) - { - Type definition = contract.GetGenericTypeDefinition(); - Type valueType = contract.GetGenericArguments()[0]; - - if (definition == typeof(IWhat<>)) clauses.Add(new(ClauseKind.What, valueType)); - else if (definition == typeof(IFrom<>)) clauses.Add(new(ClauseKind.From, valueType)); - else if (definition == typeof(ITo<>)) clauses.Add(new(ClauseKind.To, valueType)); - else if (definition == typeof(IUsing<>)) clauses.Add(new(ClauseKind.Using, valueType)); - else if (definition == typeof(IWith<>)) clauses.Add(new(ClauseKind.With, valueType)); - else if (definition == typeof(IThen<>)) clauses.Add(new(ClauseKind.Then, valueType, false)); - } - - return new SentencePattern(text.ToUpperInvariant(), clauses); - } - private static object? CreatePrototype(Type type) { try @@ -180,7 +149,11 @@ private static SentencePattern BuildPattern(Type verbType, string text) private void RegisterStandardQualifiers() { - foreach (string qualifier in new[] { "TEXT", "JSON", "XML", "BINARY", "CSV", "HTML", "YAML", "IMAGE", "VIDEO", "AUDIO" }) + RegisterQualifier("TEXT", typeof(string)); + RegisterQualifier("JSON"); + RegisterQualifier("XML"); + RegisterQualifier("BINARY", typeof(byte[])); + foreach (string qualifier in new[] { "CSV", "HTML", "YAML", "IMAGE", "VIDEO", "AUDIO" }) RegisterQualifier(qualifier); } } diff --git a/src/FluNet.Engine/Language/LanguageSnapshot.cs b/src/FluNet.Engine/Language/LanguageSnapshot.cs new file mode 100644 index 0000000..6898a3c --- /dev/null +++ b/src/FluNet.Engine/Language/LanguageSnapshot.cs @@ -0,0 +1,61 @@ +namespace FluNET.Language; + +/// +/// Immutable compiled view of the language. Parsing, binding, tooling and runtime +/// should consume this snapshot rather than repeatedly reflecting over assemblies. +/// +public sealed class LanguageSnapshot +{ + private readonly IReadOnlyDictionary _words; + private readonly IReadOnlyDictionary> _verbs; + private readonly IReadOnlyDictionary _qualifiers; + + public LanguageSnapshot( + IEnumerable words, + IEnumerable verbs, + IEnumerable qualifiers) + { + var wordMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (WordDescriptor word in words) + { + wordMap[word.Text] = word; + foreach (string synonym in word.Synonyms) + wordMap[synonym] = word; + } + + var verbMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (VerbDescriptor verb in verbs) + { + AddVerb(verb.Text, verb); + foreach (string synonym in verb.Synonyms) + AddVerb(synonym, verb); + } + + var qualifierMap = qualifiers.ToDictionary(x => x.Text, StringComparer.OrdinalIgnoreCase); + + _words = wordMap; + _verbs = verbMap.ToDictionary( + x => x.Key, + x => (IReadOnlyList)x.Value.DistinctBy(v => v.VerbType).ToArray(), + StringComparer.OrdinalIgnoreCase); + _qualifiers = qualifierMap; + + void AddVerb(string key, VerbDescriptor descriptor) + { + if (!verbMap.TryGetValue(key, out List? set)) + verbMap[key] = set = []; + set.Add(descriptor); + } + } + + public IReadOnlyCollection Words => _words.Values.DistinctBy(x => x.WordType).ToArray(); + public IReadOnlyCollection Verbs => _verbs.Values.SelectMany(x => x).DistinctBy(x => x.VerbType).ToArray(); + public IReadOnlyCollection Qualifiers => _qualifiers.Values.ToArray(); + + public bool TryGetWord(string text, out WordDescriptor? descriptor) => _words.TryGetValue(text, out descriptor); + + public IReadOnlyList GetVerbOverloads(string text) => + _verbs.TryGetValue(text, out IReadOnlyList? descriptors) ? descriptors : []; + + public bool IsQualifier(string text) => _qualifiers.ContainsKey(text); +} diff --git a/src/FluNet.Engine/Language/Metadata/Descriptors.cs b/src/FluNet.Engine/Language/Metadata/Descriptors.cs new file mode 100644 index 0000000..b61a7cb --- /dev/null +++ b/src/FluNet.Engine/Language/Metadata/Descriptors.cs @@ -0,0 +1,25 @@ +using FluNET.Syntax.Core; +using System.Reflection; + +namespace FluNET.Language.Metadata; + +public sealed record ParameterDescriptor( + ParameterInfo Parameter, + string Name, + Type ParameterType, + ClauseKind? Role, + RoleDirection Direction, + bool IsOptional, + bool IsParams, + bool FromServices, + NullabilityState ReadState, + NullabilityState WriteState, + TypeShape Shape); + +public sealed record ConstructorDescriptor( + ConstructorInfo Constructor, + IReadOnlyList Parameters) +{ + public int RoleParameterCount => Parameters.Count(x => x.Role != null); + public int ServiceParameterCount => Parameters.Count(x => x.FromServices); +} diff --git a/src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs b/src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs new file mode 100644 index 0000000..836ddfe --- /dev/null +++ b/src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs @@ -0,0 +1,71 @@ +namespace FluNET.Language.Metadata; + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public class RoleAttribute(ClauseKind kind) : Attribute +{ + public ClauseKind Kind { get; } = kind; +} + +public sealed class WhatAttribute : RoleAttribute +{ + public WhatAttribute() : base(ClauseKind.What) { } +} + +public sealed class FromAttribute : RoleAttribute +{ + public FromAttribute() : base(ClauseKind.From) { } +} + +public sealed class ToAttribute : RoleAttribute +{ + public ToAttribute() : base(ClauseKind.To) { } +} + +public sealed class UsingAttribute : RoleAttribute +{ + public UsingAttribute() : base(ClauseKind.Using) { } +} + +public sealed class WithAttribute : RoleAttribute +{ + public WithAttribute() : base(ClauseKind.With) { } +} + +public sealed class ThenAttribute : RoleAttribute +{ + public ThenAttribute() : base(ClauseKind.Then) { } +} + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public sealed class OptionalRoleAttribute : Attribute { } + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public sealed class InputAttribute : Attribute { } + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public sealed class OutputAttribute : Attribute { } + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public sealed class InputOutputAttribute : Attribute { } + +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public sealed class FromServicesAttribute : Attribute { } + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true, Inherited = true)] +public sealed class AliasAttribute(string value) : Attribute +{ + public string Value { get; } = value; +} + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public sealed class QualifierAttribute(string text) : Attribute +{ + public string Text { get; } = text; + public Type? ValueType { get; init; } +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true, Inherited = true)] +public sealed class RequiresCapabilityAttribute(string capability) : Attribute +{ + public string Capability { get; } = capability; +} diff --git a/src/FluNet.Engine/Language/Metadata/TypeShape.cs b/src/FluNet.Engine/Language/Metadata/TypeShape.cs new file mode 100644 index 0000000..6dff43a --- /dev/null +++ b/src/FluNet.Engine/Language/Metadata/TypeShape.cs @@ -0,0 +1,42 @@ +namespace FluNET.Language.Metadata; + +public enum CollectionShapeKind +{ + Scalar, + Array, + Sequence +} + +/// +/// CLR value-shape metadata. Collection shape is deliberately separate from +/// syntactic role cardinality: User[] may still be one WHAT binding, while +/// 'params FileInfo[] from' represents repeated FROM values. +/// +public sealed record TypeShape( + Type ValueType, + Type? ElementType, + CollectionShapeKind Kind) +{ + public bool IsCollection => Kind != CollectionShapeKind.Scalar; + + public static TypeShape Analyze(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + if (type.IsArray) + return new(type, type.GetElementType(), CollectionShapeKind.Array); + + if (type != typeof(string)) + { + Type? enumerable = (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + ? type + : type.GetInterfaces().FirstOrDefault(x => + x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + + if (enumerable != null) + return new(type, enumerable.GetGenericArguments()[0], CollectionShapeKind.Sequence); + } + + return new(type, null, CollectionShapeKind.Scalar); + } +} diff --git a/src/FluNet.Engine/Language/SentencePattern.cs b/src/FluNet.Engine/Language/SentencePattern.cs index a5c6887..6e7df36 100644 --- a/src/FluNet.Engine/Language/SentencePattern.cs +++ b/src/FluNet.Engine/Language/SentencePattern.cs @@ -1,8 +1,9 @@ +using FluNET.Syntax.Core; + namespace FluNET.Language; /// -/// Semantic roles that make up a FluNET sentence. They intentionally mirror -/// the English-like surface syntax and are independent from the parser/runtime. +/// Semantic roles that make up a FluNET sentence. /// public enum ClauseKind { @@ -14,14 +15,26 @@ public enum ClauseKind Then } +public enum RoleCardinality +{ + One, + ZeroOrOne, + OneOrMore, + ZeroOrMore +} + public sealed record ClauseDescriptor( ClauseKind Kind, Type ValueType, - bool Required = true); + bool Required = true, + string? Name = null, + RoleDirection Direction = RoleDirection.Input, + RoleCardinality Cardinality = RoleCardinality.One, + Type? ElementType = null); /// -/// Declarative grammar of a single verb sentence, e.g. GET WHAT<string> FROM<FileInfo>. -/// The same model can be consumed by parsing, validation, tooling and documentation. +/// Declarative grammar of a single verb sentence. Patterns are compiled from +/// interfaces, CLR types, constructor signatures and optional attribute overrides. /// public sealed record SentencePattern( string Verb, @@ -31,4 +44,7 @@ public sealed record SentencePattern( public ClauseDescriptor? Find(ClauseKind kind) => Clauses.FirstOrDefault(x => x.Kind == kind); + + public IReadOnlyList FindAll(ClauseKind kind) => + Clauses.Where(x => x.Kind == kind).ToArray(); } diff --git a/src/FluNet.Engine/Syntax/Core/IRole.cs b/src/FluNet.Engine/Syntax/Core/IRole.cs new file mode 100644 index 0000000..d84bd69 --- /dev/null +++ b/src/FluNet.Engine/Syntax/Core/IRole.cs @@ -0,0 +1,26 @@ +namespace FluNET.Syntax.Core; + +/// +/// Direction of data flow represented by a semantic sentence role. +/// +public enum RoleDirection +{ + Input, + Output, + InputOutput +} + +/// +/// Marker for semantic roles such as WHAT, FROM, TO, USING and WITH. +/// +public interface IRole +{ +} + +/// +/// Strongly typed semantic role. The CLR type describes the value shape while +/// constructor metadata describes syntactic occurrence/cardinality. +/// +public interface IRole : IRole +{ +} diff --git a/src/FluNet.Engine/Syntax/Core/VerbFamilies.cs b/src/FluNet.Engine/Syntax/Core/VerbFamilies.cs new file mode 100644 index 0000000..d86b170 --- /dev/null +++ b/src/FluNet.Engine/Syntax/Core/VerbFamilies.cs @@ -0,0 +1,15 @@ +namespace FluNET.Syntax.Core; + +/// +/// Semantic verb-family markers. Concrete verbs may use these directly or inherit +/// from legacy abstract verb bases. The language compiler treats both forms as metadata. +/// +public interface IGet : IVerb { } +public interface ISave : IVerb { } +public interface ILoad : IVerb { } +public interface ISend : IVerb { } +public interface IDelete : IVerb { } +public interface IDownload : IVerb { } +public interface IPost : IVerb { } +public interface ITransform : IVerb { } +public interface ISay : IVerb { } diff --git a/src/FluNet.Engine/Syntax/Nouns/IFrom.cs b/src/FluNet.Engine/Syntax/Nouns/IFrom.cs index a6dc2ce..ee4f33d 100644 --- a/src/FluNet.Engine/Syntax/Nouns/IFrom.cs +++ b/src/FluNet.Engine/Syntax/Nouns/IFrom.cs @@ -3,16 +3,8 @@ namespace FluNET.Syntax.Nouns { - /// - /// Represents a source or origin preposition - where something comes from. - /// Example: In "GET data FROM [file.txt]", [file.txt] implements IFrom<FileInfo>. - /// - /// The type of the source/origin - public interface IFrom : INoun, IKeyword + public interface IFrom : INoun, IKeyword, IRole { - /// - /// The source or origin from which data is retrieved. - /// - TWhat From { get; } + TFrom From { get; } } -} \ No newline at end of file +} diff --git a/src/FluNet.Engine/Syntax/Nouns/IThen.cs b/src/FluNet.Engine/Syntax/Nouns/IThen.cs index c6b934f..627000d 100644 --- a/src/FluNet.Engine/Syntax/Nouns/IThen.cs +++ b/src/FluNet.Engine/Syntax/Nouns/IThen.cs @@ -3,18 +3,8 @@ namespace FluNET.Syntax.Nouns { - /// - /// Represents a continuation of a sentence that operates on the same data. - /// Allows chaining multiple operations on the same data source. - /// Example: GET [data] FROM source THEN SAVE TO file. - /// - /// The type of data being processed through the chain - public interface IThen : INoun, IKeyword + public interface IThen : INoun, IKeyword, IRole { - /// - /// Gets the data that is being passed to the next operation in the chain. - /// This is the result from the previous verb's execution. - /// TData Data { get; } } -} \ No newline at end of file +} diff --git a/src/FluNet.Engine/Syntax/Nouns/ITo.cs b/src/FluNet.Engine/Syntax/Nouns/ITo.cs index 13226f9..f39539d 100644 --- a/src/FluNet.Engine/Syntax/Nouns/ITo.cs +++ b/src/FluNet.Engine/Syntax/Nouns/ITo.cs @@ -3,16 +3,8 @@ namespace FluNET.Syntax.Nouns { - /// - /// Represents a destination preposition - where something goes to. - /// Example: In "SAVE data TO [output.txt]", [output.txt] implements ITo<FileInfo>. - /// - /// The type of the destination - public interface ITo : INoun, IKeyword + public interface ITo : INoun, IKeyword, IRole { - /// - /// The destination where data is sent or saved. - /// TTo To { get; } } -} \ No newline at end of file +} diff --git a/src/FluNet.Engine/Syntax/Nouns/IUsing.cs b/src/FluNet.Engine/Syntax/Nouns/IUsing.cs index 8eda6c5..d1a4120 100644 --- a/src/FluNet.Engine/Syntax/Nouns/IUsing.cs +++ b/src/FluNet.Engine/Syntax/Nouns/IUsing.cs @@ -3,16 +3,8 @@ namespace FluNET.Syntax.Nouns { - /// - /// Represents an instrument preposition - the tool, method, or means by which an action is performed. - /// Example: In "ENCRYPT data USING [AES256]", [AES256] implements IUsing<Algorithm>. - /// - /// The type of the instrument or method - public interface IUsing : INoun, IKeyword + public interface IUsing : INoun, IKeyword, IRole { - /// - /// The instrument, tool, or method used to perform the action. - /// TUsing Using { get; } } -} \ No newline at end of file +} diff --git a/src/FluNet.Engine/Syntax/Nouns/IWhat.cs b/src/FluNet.Engine/Syntax/Nouns/IWhat.cs index 4b318d1..9f2041c 100644 --- a/src/FluNet.Engine/Syntax/Nouns/IWhat.cs +++ b/src/FluNet.Engine/Syntax/Nouns/IWhat.cs @@ -3,16 +3,8 @@ namespace FluNET.Syntax.Nouns { - /// - /// Represents a direct object in a sentence - the thing being acted upon by a verb. - /// Example: In "GET [data] FROM file", [data] implements IWhat<string[]>. - /// - /// The type of the direct object - public interface IWhat : INoun, IKeyword + public interface IWhat : INoun, IKeyword, IRole { - /// - /// The direct object value being acted upon. - /// TWhat What { get; } } -} \ No newline at end of file +} diff --git a/src/FluNet.Engine/Syntax/Nouns/IWith.cs b/src/FluNet.Engine/Syntax/Nouns/IWith.cs index 5108cc0..b5119dd 100644 --- a/src/FluNet.Engine/Syntax/Nouns/IWith.cs +++ b/src/FluNet.Engine/Syntax/Nouns/IWith.cs @@ -3,16 +3,8 @@ namespace FluNET.Syntax.Nouns { - /// - /// Represents an accompaniment preposition - what accompanies or is used with an action. - /// Example: In "CONNECT TO server WITH [credentials]", [credentials] implements IWith<AuthToken>. - /// - /// The type of the accompanying element - public interface IWith : INoun, IKeyword + public interface IWith : INoun, IKeyword, IRole { - /// - /// The accompanying element used with the action. - /// TWith With { get; } } -} \ No newline at end of file +} diff --git a/tests/FluNET.Tests/LanguageMetadataTests.cs b/tests/FluNET.Tests/LanguageMetadataTests.cs new file mode 100644 index 0000000..ffedd4d --- /dev/null +++ b/tests/FluNET.Tests/LanguageMetadataTests.cs @@ -0,0 +1,42 @@ +using FluNET.Language; +using FluNET.Language.Metadata; + +namespace FluNET.Tests; + +public class LanguageMetadataTests +{ + [Fact] + public void Type_shape_distinguishes_scalar_from_collection_value() + { + TypeShape scalar = TypeShape.Analyze(typeof(FileInfo)); + TypeShape array = TypeShape.Analyze(typeof(FileInfo[])); + + Assert.False(scalar.IsCollection); + Assert.True(array.IsCollection); + Assert.Equal(typeof(FileInfo), array.ElementType); + } + + [Fact] + public void Constructor_metadata_uses_roles_and_params_for_syntactic_cardinality() + { + var compiler = new LanguageCompiler(); + + ConstructorDescriptor constructor = Assert.Single(compiler.DescribeConstructors(typeof(ReflectionFixture))); + ParameterDescriptor what = constructor.Parameters[0]; + ParameterDescriptor from = constructor.Parameters[1]; + + Assert.Equal(ClauseKind.What, what.Role); + Assert.False(what.IsParams); + Assert.Equal(ClauseKind.From, from.Role); + Assert.True(from.IsParams); + Assert.True(from.Shape.IsCollection); + Assert.Equal(typeof(FileInfo), from.Shape.ElementType); + } + + private sealed class ReflectionFixture + { + public ReflectionFixture([What] string what, [From] params FileInfo[] from) + { + } + } +} From de8a9d2a5bbf9206c87296cd62b333567e53b7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:16:29 +0200 Subject: [PATCH 02/18] Add overload sets and CLR value resolution --- src/FluNet.Engine/Binding/IValueResolver.cs | 7 +- .../Binding/ReflectionValueResolver.cs | 63 +++++++++++++ .../Binding/ResolutionContext.cs | 16 ++++ .../Binding/ValueResolverRegistry.cs | 89 ++++++++++++++++--- .../Language/LanguageRegistry.cs | 54 +++++++---- .../ValueResolverRegistryTests.cs | 35 ++++++++ 6 files changed, 234 insertions(+), 30 deletions(-) create mode 100644 src/FluNet.Engine/Binding/ReflectionValueResolver.cs create mode 100644 src/FluNet.Engine/Binding/ResolutionContext.cs create mode 100644 tests/FluNET.Tests/ValueResolverRegistryTests.cs diff --git a/src/FluNet.Engine/Binding/IValueResolver.cs b/src/FluNet.Engine/Binding/IValueResolver.cs index ac80753..fcf8f83 100644 --- a/src/FluNet.Engine/Binding/IValueResolver.cs +++ b/src/FluNet.Engine/Binding/IValueResolver.cs @@ -1,13 +1,16 @@ namespace FluNET.Binding; /// -/// Converts the textual surface language into CLR values. This separates value -/// binding (file.txt -> FileInfo, URL -> Uri) from verb execution. +/// Converts textual surface values into CLR values. Context-aware overloads preserve +/// compatibility with simple resolvers while allowing role/verb-sensitive resolution. /// public interface IValueResolver { bool CanResolve(Type targetType); object? Resolve(string value, Type targetType); + + bool CanResolve(Type targetType, ResolutionContext context) => CanResolve(targetType); + object? Resolve(string value, Type targetType, ResolutionContext context) => Resolve(value, targetType); } public abstract class ValueResolver : IValueResolver diff --git a/src/FluNet.Engine/Binding/ReflectionValueResolver.cs b/src/FluNet.Engine/Binding/ReflectionValueResolver.cs new file mode 100644 index 0000000..1a6a834 --- /dev/null +++ b/src/FluNet.Engine/Binding/ReflectionValueResolver.cs @@ -0,0 +1,63 @@ +using System.ComponentModel; +using System.Globalization; +using System.Reflection; + +namespace FluNET.Binding; + +/// +/// Convention fallback for CLR types. Resolution order is enum, TryParse, Parse, +/// TypeConverter and finally a public T(string) constructor. +/// +public sealed class ReflectionValueResolver : IValueResolver +{ + public bool CanResolve(Type targetType) => targetType != typeof(string); + + public object? Resolve(string value, Type targetType) => + Resolve(value, targetType, new ResolutionContext(targetType)); + + public object? Resolve(string value, Type targetType, ResolutionContext context) + { + Type actualType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (actualType.IsEnum) + return Enum.Parse(actualType, value, ignoreCase: true); + + MethodInfo? tryParse = actualType.GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(method => + { + ParameterInfo[] parameters = method.GetParameters(); + return method.Name == "TryParse" + && method.ReturnType == typeof(bool) + && parameters.Length == 2 + && parameters[0].ParameterType == typeof(string) + && parameters[1].IsOut + && parameters[1].ParameterType.GetElementType() == actualType; + }); + + if (tryParse != null) + { + object?[] args = [value, null]; + if (tryParse.Invoke(null, args) is true) + return args[1]; + } + + MethodInfo? parse = actualType.GetMethod( + "Parse", + BindingFlags.Public | BindingFlags.Static, + binder: null, + types: [typeof(string)], + modifiers: null); + if (parse != null && actualType.IsAssignableFrom(parse.ReturnType)) + return parse.Invoke(null, [value]); + + TypeConverter converter = TypeDescriptor.GetConverter(actualType); + if (converter.CanConvertFrom(typeof(string))) + return converter.ConvertFrom(null, context.EffectiveCulture, value); + + ConstructorInfo? stringConstructor = actualType.GetConstructor([typeof(string)]); + if (stringConstructor != null) + return stringConstructor.Invoke([value]); + + return null; + } +} diff --git a/src/FluNet.Engine/Binding/ResolutionContext.cs b/src/FluNet.Engine/Binding/ResolutionContext.cs new file mode 100644 index 0000000..63ac2c4 --- /dev/null +++ b/src/FluNet.Engine/Binding/ResolutionContext.cs @@ -0,0 +1,16 @@ +using FluNET.Language; +using System.Globalization; + +namespace FluNET.Binding; + +public sealed record ResolutionContext( + Type ExpectedType, + ClauseKind? Role = null, + VerbDescriptor? Verb = null, + QualifierDescriptor? Qualifier = null, + IServiceProvider? Services = null, + CultureInfo? Culture = null, + IReadOnlyDictionary? Variables = null) +{ + public CultureInfo EffectiveCulture => Culture ?? CultureInfo.InvariantCulture; +} diff --git a/src/FluNet.Engine/Binding/ValueResolverRegistry.cs b/src/FluNet.Engine/Binding/ValueResolverRegistry.cs index a841e8f..493a381 100644 --- a/src/FluNet.Engine/Binding/ValueResolverRegistry.cs +++ b/src/FluNet.Engine/Binding/ValueResolverRegistry.cs @@ -1,20 +1,24 @@ +using System.Collections; using System.Globalization; +using System.Reflection; namespace FluNET.Binding; /// -/// CLR-backed value binding used by the future binder and available to verbs during migration. +/// Ordered CLR value-resolution pipeline. Explicit resolvers run first; reflection +/// conventions are the final fallback. /// public sealed class ValueResolverRegistry { private readonly List _resolvers = []; + private readonly IValueResolver _reflectionFallback = new ReflectionValueResolver(); public ValueResolverRegistry() { - Add(new StringResolver()); - Add(new FileInfoResolver()); - Add(new UriResolver()); - Add(new PrimitiveResolver()); + _resolvers.Add(new StringResolver()); + _resolvers.Add(new FileInfoResolver()); + _resolvers.Add(new UriResolver()); + _resolvers.Add(new PrimitiveResolver()); } public ValueResolverRegistry Add(IValueResolver resolver) @@ -24,21 +28,25 @@ public ValueResolverRegistry Add(IValueResolver resolver) return this; } - public bool TryResolve(string value, Type targetType, out object? resolved) + public bool TryResolve(string value, Type targetType, out object? resolved) => + TryResolve(value, targetType, new ResolutionContext(targetType), out resolved); + + public bool TryResolve(string value, Type targetType, ResolutionContext context, out object? resolved) { - foreach (IValueResolver resolver in _resolvers) + foreach (IValueResolver resolver in _resolvers.Append(_reflectionFallback)) { - if (!resolver.CanResolve(targetType)) + if (!resolver.CanResolve(targetType, context)) continue; try { - resolved = resolver.Resolve(value, targetType); - return resolved != null || !targetType.IsValueType; + resolved = resolver.Resolve(value, targetType, context); + if (resolved != null || !targetType.IsValueType || Nullable.GetUnderlyingType(targetType) != null) + return true; } catch { - // Try the next compatible resolver. Binding diagnostics are produced by the binder. + // Continue through the ordered resolver chain. Binder diagnostics explain failure. } } @@ -58,6 +66,65 @@ public bool TryResolve(string value, out T? resolved) return false; } + /// + /// Resolves repeated syntactic values into an array/list/sequence CLR shape. + /// + public bool TryResolveMany(IEnumerable values, Type targetType, ResolutionContext context, out object? resolved) + { + Type? elementType = targetType.IsArray + ? targetType.GetElementType() + : targetType.GetInterfaces() + .Concat(targetType.IsInterface ? [targetType] : []) + .FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + ?.GetGenericArguments()[0]; + + if (elementType == null) + { + resolved = null; + return false; + } + + var items = new List(); + foreach (string value in values) + { + ResolutionContext elementContext = context with { ExpectedType = elementType }; + if (!TryResolve(value, elementType, elementContext, out object? item)) + { + resolved = null; + return false; + } + items.Add(item); + } + + if (targetType.IsArray) + { + Array array = Array.CreateInstance(elementType, items.Count); + for (int i = 0; i < items.Count; i++) array.SetValue(items[i], i); + resolved = array; + return true; + } + + Type listType = typeof(List<>).MakeGenericType(elementType); + IList list = (IList)Activator.CreateInstance(listType)!; + foreach (object? item in items) list.Add(item); + + if (targetType.IsAssignableFrom(listType) || targetType.IsInterface) + { + resolved = list; + return true; + } + + ConstructorInfo? sequenceConstructor = targetType.GetConstructor([typeof(IEnumerable<>).MakeGenericType(elementType)]); + if (sequenceConstructor != null) + { + resolved = sequenceConstructor.Invoke([list]); + return true; + } + + resolved = null; + return false; + } + private sealed class StringResolver : ValueResolver { protected override string Resolve(string value) => value; diff --git a/src/FluNet.Engine/Language/LanguageRegistry.cs b/src/FluNet.Engine/Language/LanguageRegistry.cs index c135860..39bcf19 100644 --- a/src/FluNet.Engine/Language/LanguageRegistry.cs +++ b/src/FluNet.Engine/Language/LanguageRegistry.cs @@ -5,13 +5,13 @@ namespace FluNET.Language; /// -/// Mutable registration facade used while composing a FluNET language. Reflection is -/// centralized at registration/compilation time; consumers can obtain an immutable snapshot. +/// Mutable registration facade used while composing a FluNET language. A keyword may +/// have multiple concrete verb implementations; overload selection belongs to binding. /// public sealed class LanguageRegistry { private readonly Dictionary _words = new(StringComparer.OrdinalIgnoreCase); - private readonly Dictionary _verbs = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _verbs = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _qualifiers = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _assemblies = []; private readonly LanguageCompiler _compiler = new(); @@ -23,7 +23,7 @@ public LanguageRegistry() } public IReadOnlyCollection Words => _words.Values.DistinctBy(x => x.WordType).ToArray(); - public IReadOnlyCollection Verbs => _verbs.Values.DistinctBy(x => x.VerbType).ToArray(); + public IReadOnlyCollection Verbs => _verbs.Values.SelectMany(x => x).DistinctBy(x => x.VerbType).ToArray(); public IReadOnlyCollection Qualifiers => _qualifiers.Values.ToArray(); public LanguageSnapshot Snapshot => new(Words, Verbs, Qualifiers); @@ -78,14 +78,25 @@ public bool TryCreateWord(string text, out IWord? word) return false; } - public bool TryGetVerb(string text, out VerbDescriptor? descriptor) => - _verbs.TryGetValue(text, out descriptor); + /// + /// Legacy single-verb lookup. New binding code should call GetVerbOverloads. + /// + public bool TryGetVerb(string text, out VerbDescriptor? descriptor) + { + IReadOnlyList overloads = GetVerbOverloads(text); + descriptor = overloads.FirstOrDefault(); + return descriptor != null; + } - public IReadOnlyList GetVerbOverloads(string text) => Snapshot.GetVerbOverloads(text); + public IReadOnlyList GetVerbOverloads(string text) => + _verbs.TryGetValue(text, out List? overloads) + ? overloads.DistinctBy(x => x.VerbType).ToArray() + : []; public Type? GetVerbBaseType(string text) { - if (!_verbs.TryGetValue(text, out VerbDescriptor? descriptor)) + VerbDescriptor? descriptor = GetVerbOverloads(text).FirstOrDefault(); + if (descriptor == null) return null; Type? baseType = descriptor.VerbType.BaseType; @@ -107,17 +118,26 @@ private void RegisterWord(Type type) string[] synonyms = prototype is IVerb verb ? verb.Synonyms : []; var word = new WordDescriptor(type, keyword.Text, synonyms, factory); - _words[keyword.Text] = word; + _words.TryAdd(keyword.Text, word); foreach (string synonym in synonyms) - _words[synonym] = word; + _words.TryAdd(synonym, word); - if (prototype is IVerb) - { - VerbDescriptor descriptor = _compiler.DescribeVerb(type, keyword.Text, synonyms, () => factory() as IVerb); - _verbs[keyword.Text] = descriptor; - foreach (string synonym in synonyms) - _verbs[synonym] = descriptor; - } + if (prototype is not IVerb) + return; + + VerbDescriptor descriptor = _compiler.DescribeVerb(type, keyword.Text, synonyms, () => factory() as IVerb); + RegisterOverload(keyword.Text, descriptor); + foreach (string synonym in synonyms) + RegisterOverload(synonym, descriptor); + } + + private void RegisterOverload(string keyword, VerbDescriptor descriptor) + { + if (!_verbs.TryGetValue(keyword, out List? overloads)) + _verbs[keyword] = overloads = []; + + if (overloads.All(x => x.VerbType != descriptor.VerbType)) + overloads.Add(descriptor); } private static object? CreatePrototype(Type type) diff --git a/tests/FluNET.Tests/ValueResolverRegistryTests.cs b/tests/FluNET.Tests/ValueResolverRegistryTests.cs new file mode 100644 index 0000000..fd303de --- /dev/null +++ b/tests/FluNET.Tests/ValueResolverRegistryTests.cs @@ -0,0 +1,35 @@ +using FluNET.Binding; + +namespace FluNET.Tests; + +public class ValueResolverRegistryTests +{ + [Fact] + public void Reflection_fallback_resolves_enum_and_string_constructor_types() + { + var resolvers = new ValueResolverRegistry(); + + Assert.True(resolvers.TryResolve("Friday", typeof(DayOfWeek), out object? day)); + Assert.Equal(DayOfWeek.Friday, day); + + Assert.True(resolvers.TryResolve("alpha", typeof(StringConstructed), out object? custom)); + Assert.Equal("alpha", Assert.IsType(custom).Value); + } + + [Fact] + public void Repeated_values_resolve_to_array_shape() + { + var resolvers = new ValueResolverRegistry(); + var context = new ResolutionContext(typeof(FileInfo[])); + + Assert.True(resolvers.TryResolveMany(["a.txt", "b.txt"], typeof(FileInfo[]), context, out object? result)); + FileInfo[] files = Assert.IsType(result); + Assert.Equal(2, files.Length); + } + + private sealed class StringConstructed + { + public StringConstructed(string value) => Value = value; + public string Value { get; } + } +} From 1f17d042281fddde49321cade6bd6ea3945467e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:18:15 +0200 Subject: [PATCH 03/18] Add semantic binder and typed pipeline model --- src/FluNet.Engine/Binding/BoundNodes.cs | 36 +++ src/FluNet.Engine/Binding/SemanticBinder.cs | 258 ++++++++++++++++++++ src/FluNet.Engine/Syntax/Ast/AstNodes.cs | 8 +- tests/FluNET.Tests/SemanticBinderTests.cs | 41 ++++ 4 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 src/FluNet.Engine/Binding/BoundNodes.cs create mode 100644 src/FluNet.Engine/Binding/SemanticBinder.cs create mode 100644 tests/FluNET.Tests/SemanticBinderTests.cs diff --git a/src/FluNet.Engine/Binding/BoundNodes.cs b/src/FluNet.Engine/Binding/BoundNodes.cs new file mode 100644 index 0000000..8911790 --- /dev/null +++ b/src/FluNet.Engine/Binding/BoundNodes.cs @@ -0,0 +1,36 @@ +using FluNET.Diagnostics; +using FluNET.Language; +using FluNET.Language.Metadata; +using FluNET.Syntax.Ast; + +namespace FluNET.Binding; + +public sealed record BoundValue( + ExpressionNode Source, + Type ExpectedType, + Type ActualType, + object? ConstantValue, + int ConversionCost); + +public sealed record BoundRole( + ClauseDescriptor Descriptor, + IReadOnlyList Values); + +public sealed record BoundSentence( + VerbDescriptor Verb, + ConstructorDescriptor? Constructor, + IReadOnlyList Roles, + Type? ResultType, + int BindingCost); + +public sealed record BoundPipeline(IReadOnlyList Sentences, Type? ResultType); + +public sealed record BindingResult(T? Value, IReadOnlyList Diagnostics) +{ + public bool Success => Value != null && Diagnostics.All(x => x.Severity != DiagnosticSeverity.Error); +} + +public sealed record BindingContext( + IReadOnlyDictionary? VariableTypes = null, + Type? PipelineType = null, + IServiceProvider? Services = null); diff --git a/src/FluNet.Engine/Binding/SemanticBinder.cs b/src/FluNet.Engine/Binding/SemanticBinder.cs new file mode 100644 index 0000000..a59a228 --- /dev/null +++ b/src/FluNet.Engine/Binding/SemanticBinder.cs @@ -0,0 +1,258 @@ +using FluNET.Diagnostics; +using FluNET.Language; +using FluNET.Language.Metadata; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Core; + +namespace FluNET.Binding; + +/// +/// Binds sentence syntax to a concrete verb overload using compiled reflection metadata. +/// Lower binding cost wins: exact CLR matches are preferred over textual resolution. +/// +public sealed class SemanticBinder +{ + private readonly LanguageSnapshot _language; + private readonly ValueResolverRegistry _resolvers; + + public SemanticBinder(LanguageSnapshot language, ValueResolverRegistry? resolvers = null) + { + _language = language; + _resolvers = resolvers ?? new ValueResolverRegistry(); + } + + public BindingResult BindSentence(SentenceNode sentence, BindingContext? context = null) + { + context ??= new BindingContext(); + IReadOnlyList overloads = _language.GetVerbOverloads(sentence.Verb); + if (overloads.Count == 0) + return Failure("FLU2001", $"Unknown verb '{sentence.Verb}'."); + + var candidates = new List(); + foreach (VerbDescriptor overload in overloads) + { + BoundSentence? candidate = TryBindOverload(sentence, overload, context); + if (candidate != null) + candidates.Add(candidate); + } + + if (candidates.Count == 0) + { + string signatures = string.Join(", ", overloads.Select(FormatSignature)); + return Failure( + "FLU2101", + $"No overload of '{sentence.Verb}' matches this sentence. Available: {signatures}."); + } + + int bestCost = candidates.Min(x => x.BindingCost); + BoundSentence[] best = candidates.Where(x => x.BindingCost == bestCost).ToArray(); + if (best.Length > 1) + { + return Failure( + "FLU2102", + $"Ambiguous '{sentence.Verb}' sentence. Matching overloads: {string.Join(", ", best.Select(x => FormatSignature(x.Verb)))}."); + } + + return new(best[0], []); + } + + public BindingResult BindPipeline(PipelineNode pipeline, BindingContext? context = null) + { + context ??= new BindingContext(); + var bound = new List(); + var diagnostics = new List(); + Type? pipelineType = context.PipelineType; + + foreach (SentenceNode sentence in pipeline.Sentences) + { + BindingContext sentenceContext = context with { PipelineType = pipelineType }; + BindingResult result = BindSentence(sentence, sentenceContext); + diagnostics.AddRange(result.Diagnostics); + if (!result.Success || result.Value == null) + return new(null, diagnostics); + + bound.Add(result.Value); + pipelineType = result.Value.ResultType; + } + + return new(new BoundPipeline(bound, pipelineType), diagnostics); + } + + private BoundSentence? TryBindOverload(SentenceNode sentence, VerbDescriptor verb, BindingContext context) + { + var remaining = sentence.Clauses + .GroupBy(x => x.Kind) + .ToDictionary(x => x.Key, x => new Queue(x)); + + var roles = new List(); + int cost = 0; + + foreach (ClauseDescriptor expected in verb.Pattern.Clauses) + { + int minimum = expected.Cardinality is RoleCardinality.One or RoleCardinality.OneOrMore ? 1 : 0; + bool repeated = expected.Cardinality is RoleCardinality.ZeroOrMore or RoleCardinality.OneOrMore; + var values = new List(); + + if (remaining.TryGetValue(expected.Kind, out Queue? queue) && queue.Count > 0) + { + if (repeated && expected.ElementType != null) + { + BoundValue? collection = TryBindRepeatedValues(queue, expected, verb, context); + if (collection == null) return null; + values.Add(collection); + cost += collection.ConversionCost; + } + else + { + ClauseNode actual = queue.Dequeue(); + BoundValue? value = TryBindValue(actual.Value, expected, verb, context); + if (value == null) return null; + values.Add(value); + cost += value.ConversionCost; + } + } + + if (values.Count < minimum) + { + BoundValue? implicitPipeline = TryBindPipelineValue(expected, context); + if (implicitPipeline != null) + { + values.Add(implicitPipeline); + cost += implicitPipeline.ConversionCost; + } + } + + if (values.Count < minimum) + return null; + + roles.Add(new BoundRole(expected, values)); + } + + if (remaining.Values.Any(queue => queue.Count > 0)) + return null; + + ConstructorDescriptor? constructor = verb.Constructors.FirstOrDefault(x => x.RoleParameterCount > 0) + ?? verb.Constructors.FirstOrDefault(); + + return new BoundSentence(verb, constructor, roles, verb.ResultType, cost); + } + + private BoundValue? TryBindRepeatedValues( + Queue queue, + ClauseDescriptor expected, + VerbDescriptor verb, + BindingContext context) + { + ClauseNode[] clauses = queue.ToArray(); + queue.Clear(); + string[] texts = clauses.Select(x => x.Value switch + { + LiteralExpression literal => literal.Value, + ReferenceExpression reference => reference.Reference, + _ => null + }).Where(x => x != null).Cast().ToArray(); + + if (texts.Length != clauses.Length) + return null; + + ResolutionContext resolution = new( + expected.ValueType, + expected.Kind, + verb, + Qualifier: null, + Services: context.Services); + + if (!_resolvers.TryResolveMany(texts, expected.ValueType, resolution, out object? collection)) + return null; + + ExpressionNode source = clauses.Length == 1 + ? clauses[0].Value + : new InterpolatedStringExpression(string.Join(" ", texts)); + + return new(source, expected.ValueType, collection?.GetType() ?? expected.ValueType, collection, 2); + } + + private BoundValue? TryBindValue( + ExpressionNode expression, + ClauseDescriptor expected, + VerbDescriptor verb, + BindingContext context) + { + if (expected.Direction == RoleDirection.Output && expression is VariableExpression output) + return new(output, expected.ValueType, expected.ValueType, null, 0); + + if (expression is PipelineValueExpression) + return BindKnownType(expression, context.PipelineType, expected.ValueType, null); + + if (expression is VariableExpression variable) + { + Type? actualType = null; + if (context.VariableTypes != null) + context.VariableTypes.TryGetValue(variable.Name, out actualType); + return BindKnownType(expression, actualType, expected.ValueType, null); + } + + if (expression is InterpolatedStringExpression interpolated && expected.ValueType == typeof(string)) + return new(interpolated, typeof(string), typeof(string), null, 0); + + string? text = expression switch + { + LiteralExpression literal => literal.Value, + ReferenceExpression reference => reference.Reference, + _ => null + }; + + if (text == null) + return null; + + ResolutionContext resolution = new( + expected.ValueType, + expected.Kind, + verb, + Qualifier: null, + Services: context.Services); + + if (_resolvers.TryResolve(text, expected.ValueType, resolution, out object? value)) + return new(expression, expected.ValueType, value?.GetType() ?? expected.ValueType, value, expected.ValueType == typeof(string) ? 0 : 2); + + return null; + } + + private static BoundValue? TryBindPipelineValue(ClauseDescriptor expected, BindingContext context) + { + if (expected.Direction == RoleDirection.Output || context.PipelineType == null) + return null; + + return BindKnownType(new PipelineValueExpression(), context.PipelineType, expected.ValueType, null); + } + + private static BoundValue? BindKnownType(ExpressionNode source, Type? actualType, Type expectedType, object? value) + { + if (actualType == null) + return null; + + if (expectedType == actualType) + return new(source, expectedType, actualType, value, 0); + + if (expectedType.IsAssignableFrom(actualType)) + return new(source, expectedType, actualType, value, 1); + + return null; + } + + private static BindingResult Failure(string code, string message) => + new(null, [Diagnostic.Error(code, message)]); + + private static string FormatSignature(VerbDescriptor verb) + { + string clauses = string.Join(" ", verb.Pattern.Clauses.Select(x => + $"{x.Kind.ToString().ToUpperInvariant()}<{FriendlyName(x.ValueType)}>")); + return string.IsNullOrEmpty(clauses) ? verb.Text : $"{verb.Text} {clauses}"; + } + + private static string FriendlyName(Type type) + { + if (type.IsArray) return $"{FriendlyName(type.GetElementType()!)}[]"; + return type.Name; + } +} diff --git a/src/FluNet.Engine/Syntax/Ast/AstNodes.cs b/src/FluNet.Engine/Syntax/Ast/AstNodes.cs index 19420be..4868b90 100644 --- a/src/FluNet.Engine/Syntax/Ast/AstNodes.cs +++ b/src/FluNet.Engine/Syntax/Ast/AstNodes.cs @@ -3,7 +3,7 @@ namespace FluNET.Syntax.Ast; /// -/// Stable syntax model between parsing and binding. Runtime objects are deliberately absent. +/// Stable immutable syntax model between parsing and binding. Runtime objects are deliberately absent. /// public abstract record SyntaxNode; @@ -13,7 +13,10 @@ public sealed record PipelineNode(IReadOnlyList Sentences) : Synta public sealed record SentenceNode( string Verb, - IReadOnlyList Clauses) : SyntaxNode; + IReadOnlyList Clauses) : SyntaxNode +{ + public string? Qualifier { get; init; } +} public sealed record ClauseNode( ClauseKind Kind, @@ -26,3 +29,4 @@ public sealed record VariableExpression(string Name) : ExpressionNode; public sealed record ReferenceExpression(string Reference) : ExpressionNode; public sealed record PropertyExpression(ExpressionNode Target, string Property) : ExpressionNode; public sealed record InterpolatedStringExpression(string Template) : ExpressionNode; +public sealed record PipelineValueExpression : ExpressionNode; diff --git a/tests/FluNET.Tests/SemanticBinderTests.cs b/tests/FluNET.Tests/SemanticBinderTests.cs new file mode 100644 index 0000000..8be5594 --- /dev/null +++ b/tests/FluNET.Tests/SemanticBinderTests.cs @@ -0,0 +1,41 @@ +using FluNET.Binding; +using FluNET.Language; +using FluNET.Syntax.Ast; + +namespace FluNET.Tests; + +public class SemanticBinderTests +{ + [Fact] + public void Binder_binds_classic_get_using_compiled_role_and_constructor_metadata() + { + var registry = new LanguageRegistry(); + var binder = new SemanticBinder(registry.Snapshot); + var sentence = new SentenceNode( + "GET", + [ + new ClauseNode(ClauseKind.What, new VariableExpression("text")), + new ClauseNode(ClauseKind.From, new ReferenceExpression("input.txt")) + ]); + + BindingResult result = binder.BindSentence(sentence); + + Assert.True(result.Success); + Assert.NotNull(result.Value); + Assert.Equal("GET", result.Value!.Verb.Text, ignoreCase: true); + Assert.Equal(typeof(string[]), result.Value.ResultType); + Assert.Contains(result.Value.Roles, x => x.Descriptor.Kind == ClauseKind.From); + } + + [Fact] + public void Binder_reports_unknown_verbs_without_execution() + { + var binder = new SemanticBinder(new LanguageRegistry().Snapshot); + var sentence = new SentenceNode("DOES_NOT_EXIST", []); + + BindingResult result = binder.BindSentence(sentence); + + Assert.False(result.Success); + Assert.Contains(result.Diagnostics, x => x.Code == "FLU2001"); + } +} From 1ebd169abc79c553b8bf27b94788a207cef1117b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:18:44 +0200 Subject: [PATCH 04/18] Add FluNET.Classic CI --- .github/workflows/classic-ci.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/classic-ci.yml diff --git a/.github/workflows/classic-ci.yml b/.github/workflows/classic-ci.yml new file mode 100644 index 0000000..059ea84 --- /dev/null +++ b/.github/workflows/classic-ci.yml @@ -0,0 +1,30 @@ +name: FluNET.Classic CI + +on: + push: + branches: + - 'classic/**' + pull_request: + branches: + - 'classic/main' + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: Restore + run: dotnet restore FluNET.sln + + - name: Build + run: dotnet build FluNET.sln --configuration Release --no-restore + + - name: Test + run: dotnet test FluNET.sln --configuration Release --no-build --verbosity normal From bff9ce83823c3823bd687b045492c76d24f5a756 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:20:56 +0200 Subject: [PATCH 05/18] Decouple verb identity from construction --- .../Language/LanguageCompiler.cs | 92 +++++++++++++++++-- .../Language/LanguageDescriptors.cs | 2 + .../Language/LanguageRegistry.cs | 80 +++++++--------- .../Language/Metadata/LanguageAttributes.cs | 41 +++------ src/FluNet.Engine/Syntax/Core/IVerb.cs | 47 +++------- .../LanguageCompilerIdentityTests.cs | 41 +++++++++ 6 files changed, 188 insertions(+), 115 deletions(-) create mode 100644 tests/FluNET.Tests/LanguageCompilerIdentityTests.cs diff --git a/src/FluNet.Engine/Language/LanguageCompiler.cs b/src/FluNet.Engine/Language/LanguageCompiler.cs index ee1931b..acf0c40 100644 --- a/src/FluNet.Engine/Language/LanguageCompiler.cs +++ b/src/FluNet.Engine/Language/LanguageCompiler.cs @@ -13,6 +13,26 @@ public sealed class LanguageCompiler { private readonly NullabilityInfoContext _nullability = new(); + public VerbIdentity? DescribeVerbIdentity(Type verbType, IVerb? prototype = null) + { + VerbAttribute? explicitVerb = verbType.GetCustomAttribute(true); + string? text = explicitVerb?.Text + ?? InferFamilyKeyword(verbType) + ?? prototype?.Text; + + if (string.IsNullOrWhiteSpace(text)) + return null; + + string[] synonyms = verbType.GetCustomAttributes(true) + .Select(x => x.Value) + .Concat(prototype?.Synonyms ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new VerbIdentity(text.ToUpperInvariant(), synonyms); + } + public VerbDescriptor DescribeVerb( Type verbType, string text, @@ -97,10 +117,7 @@ private static RoleDirection InferDirection(Type verbType, ParameterInfo paramet return RoleDirection.Input; } - private static SentencePattern BuildPattern( - Type verbType, - string text, - IReadOnlyList constructors) + private static SentencePattern BuildPattern(Type verbType, string text, IReadOnlyList constructors) { ConstructorDescriptor? constructor = constructors.FirstOrDefault(x => x.RoleParameterCount > 0); if (constructor != null) @@ -153,15 +170,74 @@ private static SentencePattern BuildPattern( private static Type? InferResultType(Type verbType) { - Type? genericVerb = verbType.GetInterfaces().FirstOrDefault(x => + Type? resultVerb = verbType.GetInterfaces().FirstOrDefault(x => + x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<>)); + if (resultVerb != null) + return resultVerb.GetGenericArguments()[0]; + + Type? legacyVerb = verbType.GetInterfaces().FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<,>)); - return genericVerb?.GetGenericArguments()[0]; + return legacyVerb?.GetGenericArguments()[0]; } private static Type? InferFamilyType(Type verbType) { Type[] families = [typeof(IGet), typeof(ISave), typeof(ILoad), typeof(ISend), typeof(IDelete), typeof(IDownload), typeof(IPost), typeof(ITransform), typeof(ISay)]; - return families.FirstOrDefault(x => x.IsAssignableFrom(verbType)); + Type? marker = families.FirstOrDefault(x => x.IsAssignableFrom(verbType)); + if (marker != null) return marker; + + Type? current = verbType.BaseType; + while (current != null && current != typeof(object)) + { + Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; + if (KnownFamilyKeyword(candidate.Name) != null) + return candidate; + current = current.BaseType; + } + + return null; + } + + private static string? InferFamilyKeyword(Type verbType) + { + if (typeof(IGet).IsAssignableFrom(verbType)) return "GET"; + if (typeof(ISave).IsAssignableFrom(verbType)) return "SAVE"; + if (typeof(ILoad).IsAssignableFrom(verbType)) return "LOAD"; + if (typeof(ISend).IsAssignableFrom(verbType)) return "SEND"; + if (typeof(IDelete).IsAssignableFrom(verbType)) return "DELETE"; + if (typeof(IDownload).IsAssignableFrom(verbType)) return "DOWNLOAD"; + if (typeof(IPost).IsAssignableFrom(verbType)) return "POST"; + if (typeof(ITransform).IsAssignableFrom(verbType)) return "TRANSFORM"; + if (typeof(ISay).IsAssignableFrom(verbType)) return "SAY"; + + Type? current = verbType.BaseType; + while (current != null && current != typeof(object)) + { + Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; + string? keyword = KnownFamilyKeyword(candidate.Name); + if (keyword != null) return keyword; + current = current.BaseType; + } + + return null; + } + + private static string? KnownFamilyKeyword(string typeName) + { + string name = typeName.Split('`')[0]; + return name.ToUpperInvariant() switch + { + "GET" => "GET", + "SAVE" => "SAVE", + "LOAD" => "LOAD", + "SEND" => "SEND", + "DELETE" => "DELETE", + "DOWNLOAD" => "DOWNLOAD", + "POST" => "POST", + "TRANSFORM" => "TRANSFORM", + "SAY" => "SAY", + _ => null + }; } private static bool IsFamily(Type verbType, Type marker, string legacyBaseName) @@ -172,7 +248,7 @@ private static bool IsFamily(Type verbType, Type marker, string legacyBaseName) while (current != null && current != typeof(object)) { Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; - if (candidate.Name.StartsWith(legacyBaseName, StringComparison.OrdinalIgnoreCase)) + if (candidate.Name.Split('`')[0].Equals(legacyBaseName, StringComparison.OrdinalIgnoreCase)) return true; current = current.BaseType; } diff --git a/src/FluNet.Engine/Language/LanguageDescriptors.cs b/src/FluNet.Engine/Language/LanguageDescriptors.cs index 621b146..03c64c8 100644 --- a/src/FluNet.Engine/Language/LanguageDescriptors.cs +++ b/src/FluNet.Engine/Language/LanguageDescriptors.cs @@ -3,6 +3,8 @@ namespace FluNET.Language; +public sealed record VerbIdentity(string Text, IReadOnlyList Synonyms); + public sealed record WordDescriptor( Type WordType, string Text, diff --git a/src/FluNet.Engine/Language/LanguageRegistry.cs b/src/FluNet.Engine/Language/LanguageRegistry.cs index 39bcf19..a61d101 100644 --- a/src/FluNet.Engine/Language/LanguageRegistry.cs +++ b/src/FluNet.Engine/Language/LanguageRegistry.cs @@ -5,8 +5,8 @@ namespace FluNET.Language; /// -/// Mutable registration facade used while composing a FluNET language. A keyword may -/// have multiple concrete verb implementations; overload selection belongs to binding. +/// Mutable registration facade used while composing a FluNET language. New verb descriptors +/// can be discovered without constructing the verb; legacy word creation still uses prototypes. /// public sealed class LanguageRegistry { @@ -25,25 +25,17 @@ public LanguageRegistry() public IReadOnlyCollection Words => _words.Values.DistinctBy(x => x.WordType).ToArray(); public IReadOnlyCollection Verbs => _verbs.Values.SelectMany(x => x).DistinctBy(x => x.VerbType).ToArray(); public IReadOnlyCollection Qualifiers => _qualifiers.Values.ToArray(); - public LanguageSnapshot Snapshot => new(Words, Verbs, Qualifiers); public void RegisterAssemblies(IEnumerable assemblies) { foreach (Assembly assembly in assemblies) { - if (!_assemblies.Add(assembly)) - continue; + if (!_assemblies.Add(assembly)) continue; Type[] types; - try - { - types = assembly.GetTypes(); - } - catch (ReflectionTypeLoadException ex) - { - types = ex.Types.Where(x => x != null).Cast().ToArray(); - } + try { types = assembly.GetTypes(); } + catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(x => x != null).Cast().ToArray(); } foreach (Type type in types.Where(x => typeof(IWord).IsAssignableFrom(x) && !x.IsAbstract && !x.IsInterface)) RegisterWord(type); @@ -78,9 +70,6 @@ public bool TryCreateWord(string text, out IWord? word) return false; } - /// - /// Legacy single-verb lookup. New binding code should call GetVerbOverloads. - /// public bool TryGetVerb(string text, out VerbDescriptor? descriptor) { IReadOnlyList overloads = GetVerbOverloads(text); @@ -96,16 +85,13 @@ public IReadOnlyList GetVerbOverloads(string text) => public Type? GetVerbBaseType(string text) { VerbDescriptor? descriptor = GetVerbOverloads(text).FirstOrDefault(); - if (descriptor == null) - return null; + if (descriptor == null) return null; Type? baseType = descriptor.VerbType.BaseType; while (baseType != null && !baseType.IsAbstract && baseType != typeof(object)) baseType = baseType.BaseType; - if (baseType == null || baseType == typeof(object)) - return null; - + if (baseType == null || baseType == typeof(object)) return null; return baseType.IsGenericType ? baseType.GetGenericTypeDefinition() : baseType; } @@ -113,29 +99,41 @@ private void RegisterWord(Type type) { Func factory = () => CreatePrototype(type) as IWord; IWord? prototype = factory(); - if (prototype is not IKeyword keyword) - return; - string[] synonyms = prototype is IVerb verb ? verb.Synonyms : []; - var word = new WordDescriptor(type, keyword.Text, synonyms, factory); - _words.TryAdd(keyword.Text, word); - foreach (string synonym in synonyms) - _words.TryAdd(synonym, word); + if (typeof(IVerb).IsAssignableFrom(type)) + { + IVerb? verbPrototype = prototype as IVerb; + VerbIdentity? identity = _compiler.DescribeVerbIdentity(type, verbPrototype); + if (identity != null) + { + VerbDescriptor descriptor = _compiler.DescribeVerb(type, identity.Text, identity.Synonyms, () => factory() as IVerb); + RegisterOverload(identity.Text, descriptor); + foreach (string synonym in identity.Synonyms) + RegisterOverload(synonym, descriptor); + + if (prototype is IKeyword) + { + var word = new WordDescriptor(type, identity.Text, identity.Synonyms, factory); + _words.TryAdd(identity.Text, word); + foreach (string synonym in identity.Synonyms) + _words.TryAdd(synonym, word); + } + } + + return; + } - if (prototype is not IVerb) + if (prototype is not IKeyword keyword) return; - VerbDescriptor descriptor = _compiler.DescribeVerb(type, keyword.Text, synonyms, () => factory() as IVerb); - RegisterOverload(keyword.Text, descriptor); - foreach (string synonym in synonyms) - RegisterOverload(synonym, descriptor); + var nonVerbWord = new WordDescriptor(type, keyword.Text, [], factory); + _words.TryAdd(keyword.Text, nonVerbWord); } private void RegisterOverload(string keyword, VerbDescriptor descriptor) { if (!_verbs.TryGetValue(keyword, out List? overloads)) _verbs[keyword] = overloads = []; - if (overloads.All(x => x.VerbType != descriptor.VerbType)) overloads.Add(descriptor); } @@ -148,23 +146,15 @@ private void RegisterOverload(string keyword, VerbDescriptor descriptor) if (parameterless != null) return parameterless.Invoke(null); - ConstructorInfo? constructor = type.GetConstructors() - .OrderBy(x => x.GetParameters().Length) - .FirstOrDefault(); - - if (constructor == null) - return null; + ConstructorInfo? constructor = type.GetConstructors().OrderBy(x => x.GetParameters().Length).FirstOrDefault(); + if (constructor == null) return null; object?[] arguments = constructor.GetParameters() .Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null) .ToArray(); - return constructor.Invoke(arguments); } - catch - { - return null; - } + catch { return null; } } private void RegisterStandardQualifiers() diff --git a/src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs b/src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs index 836ddfe..e402c97 100644 --- a/src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs +++ b/src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs @@ -1,40 +1,23 @@ namespace FluNET.Language.Metadata; -[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] -public class RoleAttribute(ClauseKind kind) : Attribute -{ - public ClauseKind Kind { get; } = kind; -} - -public sealed class WhatAttribute : RoleAttribute +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false, Inherited = true)] +public sealed class VerbAttribute(string text) : Attribute { - public WhatAttribute() : base(ClauseKind.What) { } -} - -public sealed class FromAttribute : RoleAttribute -{ - public FromAttribute() : base(ClauseKind.From) { } -} - -public sealed class ToAttribute : RoleAttribute -{ - public ToAttribute() : base(ClauseKind.To) { } -} - -public sealed class UsingAttribute : RoleAttribute -{ - public UsingAttribute() : base(ClauseKind.Using) { } + public string Text { get; } = text; } -public sealed class WithAttribute : RoleAttribute +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public class RoleAttribute(ClauseKind kind) : Attribute { - public WithAttribute() : base(ClauseKind.With) { } + public ClauseKind Kind { get; } = kind; } -public sealed class ThenAttribute : RoleAttribute -{ - public ThenAttribute() : base(ClauseKind.Then) { } -} +public sealed class WhatAttribute : RoleAttribute { public WhatAttribute() : base(ClauseKind.What) { } } +public sealed class FromAttribute : RoleAttribute { public FromAttribute() : base(ClauseKind.From) { } } +public sealed class ToAttribute : RoleAttribute { public ToAttribute() : base(ClauseKind.To) { } } +public sealed class UsingAttribute : RoleAttribute { public UsingAttribute() : base(ClauseKind.Using) { } } +public sealed class WithAttribute : RoleAttribute { public WithAttribute() : base(ClauseKind.With) { } } +public sealed class ThenAttribute : RoleAttribute { public ThenAttribute() : base(ClauseKind.Then) { } } [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] public sealed class OptionalRoleAttribute : Attribute { } diff --git a/src/FluNet.Engine/Syntax/Core/IVerb.cs b/src/FluNet.Engine/Syntax/Core/IVerb.cs index 482b4cd..08350f5 100644 --- a/src/FluNet.Engine/Syntax/Core/IVerb.cs +++ b/src/FluNet.Engine/Syntax/Core/IVerb.cs @@ -1,48 +1,29 @@ -using FluNET.Keywords; +using FluNET.Keywords; namespace FluNET.Syntax.Core { - /// - /// Non-generic verb interface for actions that don't require type parameters. - /// Verbs are action words that can validate subsequent words in a sentence. - /// public interface IVerb : IWord, IKeyword { - /// - /// Gets the synonyms for this verb. - /// These alternative keywords have exactly the same implementation as the main verb. - /// string[] Synonyms => Array.Empty(); } /// - /// Generic verb interface for type-safe actions. + /// Result-only semantic verb contract. New verbs can compose this with IGet/ISave/etc. + /// and independent role interfaces without being forced into a fixed generic arity. /// - /// The type of object being acted upon (direct object) - /// The type of the source/origin from which the action retrieves data - public interface IVerb : IVerb + public interface IVerb : IVerb { - /// - /// The action function that takes a source and produces a result. - /// Example: For GET verb, Act takes a FileInfo and returns string content. - /// - public Func Act { get; } + } - /// - /// Invokes the verb's action and returns the result. - /// This is the primary execution method that should be called to run the verb. - /// - /// The result of the verb's action + /// + /// Legacy two-type execution contract kept for Classic compatibility. It now also + /// projects its result type through IVerb<TResult> so new metadata code can reason + /// about old and new verbs uniformly. + /// + public interface IVerb : IVerb + { + Func Act { get; } TWhat Invoke(); - - /// - /// Resolves a string value to the TFrom type contextually. - /// This allows each verb implementation to define how to interpret the value after prepositions. - /// For example: file.txt → FileInfo, https://... → Uri, etc. - /// This is the extensibility point for plugin verbs. - /// - /// The string value to resolve - /// The resolved TFrom instance, or null if resolution fails TFrom? Resolve(string value); } -} \ No newline at end of file +} diff --git a/tests/FluNET.Tests/LanguageCompilerIdentityTests.cs b/tests/FluNET.Tests/LanguageCompilerIdentityTests.cs new file mode 100644 index 0000000..3d4e426 --- /dev/null +++ b/tests/FluNET.Tests/LanguageCompilerIdentityTests.cs @@ -0,0 +1,41 @@ +using FluNET.Language; +using FluNET.Language.Metadata; +using FluNET.Syntax.Core; + +namespace FluNET.Tests; + +public class LanguageCompilerIdentityTests +{ + [Fact] + public void Verb_attribute_defines_identity_without_instantiating_the_type() + { + var compiler = new LanguageCompiler(); + + VerbIdentity? identity = compiler.DescribeVerbIdentity(typeof(AbstractAttributedVerb)); + + Assert.NotNull(identity); + Assert.Equal("CUSTOM", identity!.Text); + Assert.Contains("ALT", identity.Synonyms); + } + + [Fact] + public void Semantic_family_marker_defines_standard_keyword() + { + var compiler = new LanguageCompiler(); + + VerbIdentity? identity = compiler.DescribeVerbIdentity(typeof(AbstractGetVerb)); + + Assert.NotNull(identity); + Assert.Equal("GET", identity!.Text); + } + + [Verb("CUSTOM")] + [Alias("ALT")] + private abstract class AbstractAttributedVerb : IVerb + { + } + + private abstract class AbstractGetVerb : IGet + { + } +} From 2bbd029f886758e87b637f516ad4596ab6766b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:21:45 +0200 Subject: [PATCH 06/18] Activate verbs from bound constructor metadata --- src/FluNet.Engine/Binding/VerbActivator.cs | 159 ++++++++++++++++++ .../Execution/BoundSentenceExecutor.cs | 43 +++++ .../Syntax/Core/ExecutionContracts.cs | 16 ++ tests/FluNET.Tests/VerbActivatorTests.cs | 30 ++++ 4 files changed, 248 insertions(+) create mode 100644 src/FluNet.Engine/Binding/VerbActivator.cs create mode 100644 src/FluNet.Engine/Execution/BoundSentenceExecutor.cs create mode 100644 src/FluNet.Engine/Syntax/Core/ExecutionContracts.cs create mode 100644 tests/FluNET.Tests/VerbActivatorTests.cs diff --git a/src/FluNet.Engine/Binding/VerbActivator.cs b/src/FluNet.Engine/Binding/VerbActivator.cs new file mode 100644 index 0000000..17c1959 --- /dev/null +++ b/src/FluNet.Engine/Binding/VerbActivator.cs @@ -0,0 +1,159 @@ +using FluNET.Language.Metadata; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Core; + +namespace FluNET.Binding; + +public sealed record ActivationContext( + IReadOnlyDictionary? Variables = null, + object? PipelineValue = null, + IServiceProvider? Services = null); + +/// +/// Materializes a bound verb through the constructor selected from reflection metadata. +/// Language-role parameters come from bound values; non-role parameters may come from DI. +/// +public sealed class VerbActivator +{ + public IVerb Create(BoundSentence sentence, ActivationContext? context = null) + { + context ??= new ActivationContext(); + ConstructorDescriptor? constructor = sentence.Constructor; + + if (constructor == null) + { + IVerb? fallback = sentence.Verb.Factory(); + return fallback ?? throw new InvalidOperationException( + $"Verb '{sentence.Verb.VerbType.FullName}' has no usable constructor or factory."); + } + + var remainingRoles = sentence.Roles.ToList(); + object?[] arguments = new object?[constructor.Parameters.Count]; + + for (int i = 0; i < constructor.Parameters.Count; i++) + { + ParameterDescriptor parameter = constructor.Parameters[i]; + + if (parameter.FromServices || parameter.Role == null) + { + object? service = context.Services?.GetService(parameter.ParameterType); + if (service != null) + { + arguments[i] = service; + continue; + } + + if (parameter.Role == null) + { + if (parameter.Parameter.HasDefaultValue) + { + arguments[i] = parameter.Parameter.DefaultValue; + continue; + } + + if (parameter.IsOptional) + { + arguments[i] = DefaultValue(parameter.ParameterType); + continue; + } + + throw new InvalidOperationException( + $"Cannot resolve service parameter '{parameter.Name}' ({parameter.ParameterType.Name}) for '{sentence.Verb.Text}'."); + } + } + + BoundRole? role = FindRole(parameter, remainingRoles); + if (role == null) + { + if (parameter.IsOptional) + { + arguments[i] = parameter.Parameter.HasDefaultValue + ? parameter.Parameter.DefaultValue + : DefaultValue(parameter.ParameterType); + continue; + } + + throw new InvalidOperationException( + $"Missing bound role '{parameter.Role}' for constructor parameter '{parameter.Name}'."); + } + + remainingRoles.Remove(role); + arguments[i] = MaterializeRole(parameter, role, context); + } + + object instance = constructor.Constructor.Invoke(arguments); + return instance as IVerb ?? throw new InvalidOperationException( + $"Constructed type '{instance.GetType().FullName}' is not an IVerb."); + } + + private static BoundRole? FindRole(ParameterDescriptor parameter, IReadOnlyList roles) + { + BoundRole? named = roles.FirstOrDefault(x => + x.Descriptor.Kind == parameter.Role + && !string.IsNullOrWhiteSpace(x.Descriptor.Name) + && x.Descriptor.Name.Equals(parameter.Name, StringComparison.OrdinalIgnoreCase)); + + return named ?? roles.FirstOrDefault(x => x.Descriptor.Kind == parameter.Role); + } + + private static object? MaterializeRole(ParameterDescriptor parameter, BoundRole role, ActivationContext context) + { + if (role.Values.Count == 0) + return DefaultValue(parameter.ParameterType); + + if (role.Values.Count == 1) + return MaterializeValue(role.Values[0], parameter.ParameterType, role.Descriptor.Direction, context); + + if (parameter.ParameterType.IsArray) + { + Type elementType = parameter.ParameterType.GetElementType()!; + Array array = Array.CreateInstance(elementType, role.Values.Count); + for (int i = 0; i < role.Values.Count; i++) + array.SetValue(MaterializeValue(role.Values[i], elementType, role.Descriptor.Direction, context), i); + return array; + } + + throw new InvalidOperationException( + $"Role '{role.Descriptor.Kind}' produced multiple values for non-collection parameter '{parameter.Name}'."); + } + + private static object? MaterializeValue( + BoundValue value, + Type targetType, + RoleDirection direction, + ActivationContext context) + { + if (value.ConstantValue != null) + return value.ConstantValue; + + switch (value.Source) + { + case VariableExpression variable when direction == RoleDirection.Output: + return DefaultValue(targetType); + + case VariableExpression variable: + if (context.Variables != null && context.Variables.TryGetValue(variable.Name, out object? variableValue)) + return variableValue; + throw new InvalidOperationException($"Variable '{variable.Name}' has no runtime value."); + + case PipelineValueExpression: + return context.PipelineValue; + + case InterpolatedStringExpression interpolated when targetType == typeof(string): + return interpolated.Template; + } + + return DefaultValue(targetType); + } + + private static object? DefaultValue(Type type) + { + if (type.IsArray) + return Array.CreateInstance(type.GetElementType()!, 0); + + if (type.IsValueType) + return Activator.CreateInstance(type); + + return null; + } +} diff --git a/src/FluNet.Engine/Execution/BoundSentenceExecutor.cs b/src/FluNet.Engine/Execution/BoundSentenceExecutor.cs new file mode 100644 index 0000000..52f81fa --- /dev/null +++ b/src/FluNet.Engine/Execution/BoundSentenceExecutor.cs @@ -0,0 +1,43 @@ +using FluNET.Binding; +using FluNET.Syntax.Core; +using System.Reflection; + +namespace FluNET.Execution; + +public sealed record BoundExecutionResult(IVerb Verb, object? Result); + +/// +/// Transitional executor for the new bound model. New async verbs use IAsyncVerb; +/// legacy Classic verbs continue to execute through their parameterless Invoke method. +/// +public sealed class BoundSentenceExecutor +{ + private readonly VerbActivator _activator = new(); + + public async ValueTask ExecuteAsync( + BoundSentence sentence, + ActivationContext? context = null, + CancellationToken cancellationToken = default) + { + IVerb verb = _activator.Create(sentence, context); + + if (verb is IAsyncVerb asyncVerb) + { + object? asyncResult = await asyncVerb.InvokeAsync(cancellationToken); + return new BoundExecutionResult(verb, asyncResult); + } + + MethodInfo? invoke = verb.GetType().GetMethod( + "Invoke", + BindingFlags.Public | BindingFlags.Instance, + binder: null, + types: Type.EmptyTypes, + modifiers: null); + + if (invoke == null) + throw new InvalidOperationException($"Verb '{verb.GetType().FullName}' does not expose Invoke() or IAsyncVerb."); + + object? result = invoke.Invoke(verb, null); + return new BoundExecutionResult(verb, result); + } +} diff --git a/src/FluNet.Engine/Syntax/Core/ExecutionContracts.cs b/src/FluNet.Engine/Syntax/Core/ExecutionContracts.cs new file mode 100644 index 0000000..87db35e --- /dev/null +++ b/src/FluNet.Engine/Syntax/Core/ExecutionContracts.cs @@ -0,0 +1,16 @@ +namespace FluNET.Syntax.Core; + +public interface IAsyncVerb : IVerb +{ + ValueTask InvokeAsync(CancellationToken cancellationToken = default); +} + +public interface IPureOperation { } +public interface IIdempotentOperation { } +public interface IRetryableOperation { } +public interface ITransactionalOperation { } +public interface ILongRunningOperation { } +public interface ISideEffectingOperation { } + +public interface IPipelineProducer { } +public interface IPipelineConsumer { } diff --git a/tests/FluNET.Tests/VerbActivatorTests.cs b/tests/FluNET.Tests/VerbActivatorTests.cs new file mode 100644 index 0000000..1471c6d --- /dev/null +++ b/tests/FluNET.Tests/VerbActivatorTests.cs @@ -0,0 +1,30 @@ +using FluNET.Binding; +using FluNET.Language; +using FluNET.Syntax.Ast; + +namespace FluNET.Tests; + +public class VerbActivatorTests +{ + [Fact] + public void Activator_constructs_classic_get_from_bound_constructor_metadata() + { + var registry = new LanguageRegistry(); + var binder = new SemanticBinder(registry.Snapshot); + var sentence = new SentenceNode( + "GET", + [ + new ClauseNode(ClauseKind.What, new VariableExpression("text")), + new ClauseNode(ClauseKind.From, new ReferenceExpression("input.txt")) + ]); + + BindingResult binding = binder.BindSentence(sentence); + Assert.True(binding.Success); + + var activator = new VerbActivator(); + var verb = activator.Create(binding.Value!); + + Assert.Equal("GET", verb.Text, ignoreCase: true); + Assert.Equal("GetText", verb.GetType().Name); + } +} From 8baac087e7e88f14d65656c35ef2dc6d88a2ed96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:23:01 +0200 Subject: [PATCH 07/18] Add module ecosystem and language introspection --- src/FluNet.Engine/Language/IFluNetModule.cs | 9 +- .../Language/LanguageCompiler.cs | 193 +++--------------- .../Language/LanguageDescriptors.cs | 34 +-- .../Language/LanguageIntrospection.cs | 50 +++++ .../Language/LanguageRegistry.cs | 145 +++---------- .../Language/LanguageSnapshot.cs | 33 +-- .../Language/LanguageValidator.cs | 40 ++++ .../LanguageIntrospectionTests.cs | 24 +++ 8 files changed, 214 insertions(+), 314 deletions(-) create mode 100644 src/FluNet.Engine/Language/LanguageIntrospection.cs create mode 100644 src/FluNet.Engine/Language/LanguageValidator.cs create mode 100644 tests/FluNET.Tests/LanguageIntrospectionTests.cs diff --git a/src/FluNet.Engine/Language/IFluNetModule.cs b/src/FluNet.Engine/Language/IFluNetModule.cs index 640680f..8a20d46 100644 --- a/src/FluNet.Engine/Language/IFluNetModule.cs +++ b/src/FluNet.Engine/Language/IFluNetModule.cs @@ -1,11 +1,13 @@ namespace FluNET.Language; /// -/// Extension boundary for language packages such as FluNET.Http, FluNET.Sql or FluNET.Json. -/// Modules enrich vocabulary without modifying the core engine. +/// Extension boundary for language packages such as FluNET.Classic.Http or FluNET.Classic.Sql. /// public interface IFluNetModule { + string Name => GetType().Assembly.GetName().Name ?? GetType().Name; + Version Version => GetType().Assembly.GetName().Version ?? new Version(0, 1, 0); + IReadOnlyCollection Dependencies => Array.Empty(); void Configure(LanguageRegistry language); } @@ -14,8 +16,7 @@ public static class LanguageRegistryModuleExtensions public static LanguageRegistry AddModule(this LanguageRegistry language) where TModule : IFluNetModule, new() { - new TModule().Configure(language); - language.RegisterAssemblies(new[] { typeof(TModule).Assembly }); + language.RegisterModule(new TModule()); return language; } } diff --git a/src/FluNet.Engine/Language/LanguageCompiler.cs b/src/FluNet.Engine/Language/LanguageCompiler.cs index acf0c40..3eba4de 100644 --- a/src/FluNet.Engine/Language/LanguageCompiler.cs +++ b/src/FluNet.Engine/Language/LanguageCompiler.cs @@ -5,10 +5,6 @@ namespace FluNET.Language; -/// -/// Compiles CLR/reflection metadata into stable language descriptors. Reflection belongs -/// here (startup/build time), not in parser/binder hot paths. -/// public sealed class LanguageCompiler { private readonly NullabilityInfoContext _nullability = new(); @@ -16,12 +12,8 @@ public sealed class LanguageCompiler public VerbIdentity? DescribeVerbIdentity(Type verbType, IVerb? prototype = null) { VerbAttribute? explicitVerb = verbType.GetCustomAttribute(true); - string? text = explicitVerb?.Text - ?? InferFamilyKeyword(verbType) - ?? prototype?.Text; - - if (string.IsNullOrWhiteSpace(text)) - return null; + string? text = explicitVerb?.Text ?? InferFamilyKeyword(verbType) ?? prototype?.Text; + if (string.IsNullOrWhiteSpace(text)) return null; string[] synonyms = verbType.GetCustomAttributes(true) .Select(x => x.Value) @@ -29,80 +21,47 @@ public sealed class LanguageCompiler .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); - return new VerbIdentity(text.ToUpperInvariant(), synonyms); } - public VerbDescriptor DescribeVerb( - Type verbType, - string text, - IReadOnlyList synonyms, - Func factory) + public VerbDescriptor DescribeVerb(Type verbType, string text, IReadOnlyList synonyms, Func factory) { IReadOnlyList constructors = DescribeConstructors(verbType); SentencePattern pattern = BuildPattern(verbType, text, constructors); - return new VerbDescriptor(verbType, text, synonyms, pattern, factory) { Constructors = constructors, ResultType = InferResultType(verbType), FamilyType = InferFamilyType(verbType), - Capabilities = verbType.GetCustomAttributes(true) - .Select(x => x.Capability) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray() + Capabilities = verbType.GetCustomAttributes(true).Select(x => x.Capability).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + Traits = new( + typeof(IPureOperation).IsAssignableFrom(verbType), + typeof(IIdempotentOperation).IsAssignableFrom(verbType), + typeof(IRetryableOperation).IsAssignableFrom(verbType), + typeof(ITransactionalOperation).IsAssignableFrom(verbType), + typeof(ILongRunningOperation).IsAssignableFrom(verbType), + typeof(ISideEffectingOperation).IsAssignableFrom(verbType)) }; } - public IReadOnlyList DescribeConstructors(Type type) => - type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) - .Select(constructor => new ConstructorDescriptor( - constructor, - constructor.GetParameters().Select(parameter => DescribeParameter(type, parameter)).ToArray())) - .OrderByDescending(x => x.RoleParameterCount) - .ThenBy(x => x.ServiceParameterCount) - .ToArray(); + public IReadOnlyList DescribeConstructors(Type type) => type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .Select(c => new ConstructorDescriptor(c, c.GetParameters().Select(p => DescribeParameter(type, p)).ToArray())) + .OrderByDescending(x => x.RoleParameterCount).ThenBy(x => x.ServiceParameterCount).ToArray(); private ParameterDescriptor DescribeParameter(Type verbType, ParameterInfo parameter) { ClauseKind? role = InferRole(parameter); NullabilityInfo nullability = _nullability.Create(parameter); bool isParams = parameter.GetCustomAttribute() != null; - bool optional = parameter.IsOptional - || parameter.HasDefaultValue - || parameter.GetCustomAttribute() != null - || nullability.ReadState == NullabilityState.Nullable; - - return new ParameterDescriptor( - parameter, - parameter.Name ?? $"arg{parameter.Position}", - parameter.ParameterType, - role, - InferDirection(verbType, parameter, role), - optional, - isParams, - parameter.GetCustomAttribute() != null, - nullability.ReadState, - nullability.WriteState, - TypeShape.Analyze(parameter.ParameterType)); + bool optional = parameter.IsOptional || parameter.HasDefaultValue || parameter.GetCustomAttribute() != null || nullability.ReadState == NullabilityState.Nullable; + return new(parameter, parameter.Name ?? $"arg{parameter.Position}", parameter.ParameterType, role, InferDirection(verbType, parameter, role), optional, isParams, parameter.GetCustomAttribute() != null, nullability.ReadState, nullability.WriteState, TypeShape.Analyze(parameter.ParameterType)); } private static ClauseKind? InferRole(ParameterInfo parameter) { RoleAttribute? explicitRole = parameter.GetCustomAttribute(); - if (explicitRole != null) - return explicitRole.Kind; - - return parameter.Name?.ToLowerInvariant() switch - { - "what" => ClauseKind.What, - "from" => ClauseKind.From, - "to" => ClauseKind.To, - "using" => ClauseKind.Using, - "with" => ClauseKind.With, - "then" => ClauseKind.Then, - _ => null - }; + if (explicitRole != null) return explicitRole.Kind; + return parameter.Name?.ToLowerInvariant() switch { "what" => ClauseKind.What, "from" => ClauseKind.From, "to" => ClauseKind.To, "using" => ClauseKind.Using, "with" => ClauseKind.With, "then" => ClauseKind.Then, _ => null }; } private static RoleDirection InferDirection(Type verbType, ParameterInfo parameter, ClauseKind? role) @@ -110,11 +69,7 @@ private static RoleDirection InferDirection(Type verbType, ParameterInfo paramet if (parameter.GetCustomAttribute() != null) return RoleDirection.Output; if (parameter.GetCustomAttribute() != null) return RoleDirection.InputOutput; if (parameter.GetCustomAttribute() != null) return RoleDirection.Input; - - if (role == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get")) - return RoleDirection.Output; - - return RoleDirection.Input; + return role == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; } private static SentencePattern BuildPattern(Type verbType, string text, IReadOnlyList constructors) @@ -122,137 +77,53 @@ private static SentencePattern BuildPattern(Type verbType, string text, IReadOnl ConstructorDescriptor? constructor = constructors.FirstOrDefault(x => x.RoleParameterCount > 0); if (constructor != null) { - ClauseDescriptor[] constructorClauses = constructor.Parameters - .Where(x => x.Role != null) - .Select(x => new ClauseDescriptor( - x.Role!.Value, - x.ParameterType, - !x.IsOptional, - x.Name, - x.Direction, - x.IsParams ? RoleCardinality.ZeroOrMore : (x.IsOptional ? RoleCardinality.ZeroOrOne : RoleCardinality.One), - x.Shape.ElementType)) - .ToArray(); - - if (constructorClauses.Length > 0) - return new SentencePattern(text.ToUpperInvariant(), constructorClauses); + ClauseDescriptor[] clauses = constructor.Parameters.Where(x => x.Role != null).Select(x => new ClauseDescriptor(x.Role!.Value, x.ParameterType, !x.IsOptional, x.Name, x.Direction, x.IsParams ? RoleCardinality.ZeroOrMore : (x.IsOptional ? RoleCardinality.ZeroOrOne : RoleCardinality.One), x.Shape.ElementType)).ToArray(); + if (clauses.Length > 0) return new SentencePattern(text.ToUpperInvariant(), clauses); } - List clauses = []; + var fallback = new List(); foreach (Type contract in verbType.GetInterfaces().Where(x => x.IsGenericType)) { Type definition = contract.GetGenericTypeDefinition(); Type valueType = contract.GetGenericArguments()[0]; ClauseKind? kind = RoleKindFor(definition); if (kind == null) continue; - TypeShape shape = TypeShape.Analyze(valueType); - RoleDirection direction = kind == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") - ? RoleDirection.Output - : RoleDirection.Input; - - clauses.Add(new ClauseDescriptor(kind.Value, valueType, true, null, direction, RoleCardinality.One, shape.ElementType)); + RoleDirection direction = kind == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; + fallback.Add(new(kind.Value, valueType, true, null, direction, RoleCardinality.One, shape.ElementType)); } - - return new SentencePattern(text.ToUpperInvariant(), clauses); - } - - private static ClauseKind? RoleKindFor(Type genericDefinition) - { - if (genericDefinition == typeof(IWhat<>)) return ClauseKind.What; - if (genericDefinition == typeof(IFrom<>)) return ClauseKind.From; - if (genericDefinition == typeof(ITo<>)) return ClauseKind.To; - if (genericDefinition == typeof(IUsing<>)) return ClauseKind.Using; - if (genericDefinition == typeof(IWith<>)) return ClauseKind.With; - if (genericDefinition == typeof(IThen<>)) return ClauseKind.Then; - return null; + return new SentencePattern(text.ToUpperInvariant(), fallback); } - private static Type? InferResultType(Type verbType) - { - Type? resultVerb = verbType.GetInterfaces().FirstOrDefault(x => - x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<>)); - if (resultVerb != null) - return resultVerb.GetGenericArguments()[0]; + private static ClauseKind? RoleKindFor(Type d) => d == typeof(IWhat<>) ? ClauseKind.What : d == typeof(IFrom<>) ? ClauseKind.From : d == typeof(ITo<>) ? ClauseKind.To : d == typeof(IUsing<>) ? ClauseKind.Using : d == typeof(IWith<>) ? ClauseKind.With : d == typeof(IThen<>) ? ClauseKind.Then : null; - Type? legacyVerb = verbType.GetInterfaces().FirstOrDefault(x => - x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<,>)); - return legacyVerb?.GetGenericArguments()[0]; - } + private static Type? InferResultType(Type verbType) => verbType.GetInterfaces().FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<>))?.GetGenericArguments()[0]; private static Type? InferFamilyType(Type verbType) { Type[] families = [typeof(IGet), typeof(ISave), typeof(ILoad), typeof(ISend), typeof(IDelete), typeof(IDownload), typeof(IPost), typeof(ITransform), typeof(ISay)]; Type? marker = families.FirstOrDefault(x => x.IsAssignableFrom(verbType)); if (marker != null) return marker; - Type? current = verbType.BaseType; - while (current != null && current != typeof(object)) - { - Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; - if (KnownFamilyKeyword(candidate.Name) != null) - return candidate; - current = current.BaseType; - } - + while (current != null && current != typeof(object)) { Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; if (KnownFamilyKeyword(candidate.Name) != null) return candidate; current = current.BaseType; } return null; } private static string? InferFamilyKeyword(Type verbType) { - if (typeof(IGet).IsAssignableFrom(verbType)) return "GET"; - if (typeof(ISave).IsAssignableFrom(verbType)) return "SAVE"; - if (typeof(ILoad).IsAssignableFrom(verbType)) return "LOAD"; - if (typeof(ISend).IsAssignableFrom(verbType)) return "SEND"; - if (typeof(IDelete).IsAssignableFrom(verbType)) return "DELETE"; - if (typeof(IDownload).IsAssignableFrom(verbType)) return "DOWNLOAD"; - if (typeof(IPost).IsAssignableFrom(verbType)) return "POST"; - if (typeof(ITransform).IsAssignableFrom(verbType)) return "TRANSFORM"; - if (typeof(ISay).IsAssignableFrom(verbType)) return "SAY"; - + if (typeof(IGet).IsAssignableFrom(verbType)) return "GET"; if (typeof(ISave).IsAssignableFrom(verbType)) return "SAVE"; if (typeof(ILoad).IsAssignableFrom(verbType)) return "LOAD"; if (typeof(ISend).IsAssignableFrom(verbType)) return "SEND"; if (typeof(IDelete).IsAssignableFrom(verbType)) return "DELETE"; if (typeof(IDownload).IsAssignableFrom(verbType)) return "DOWNLOAD"; if (typeof(IPost).IsAssignableFrom(verbType)) return "POST"; if (typeof(ITransform).IsAssignableFrom(verbType)) return "TRANSFORM"; if (typeof(ISay).IsAssignableFrom(verbType)) return "SAY"; Type? current = verbType.BaseType; - while (current != null && current != typeof(object)) - { - Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; - string? keyword = KnownFamilyKeyword(candidate.Name); - if (keyword != null) return keyword; - current = current.BaseType; - } - + while (current != null && current != typeof(object)) { Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; string? keyword = KnownFamilyKeyword(candidate.Name); if (keyword != null) return keyword; current = current.BaseType; } return null; } - private static string? KnownFamilyKeyword(string typeName) - { - string name = typeName.Split('`')[0]; - return name.ToUpperInvariant() switch - { - "GET" => "GET", - "SAVE" => "SAVE", - "LOAD" => "LOAD", - "SEND" => "SEND", - "DELETE" => "DELETE", - "DOWNLOAD" => "DOWNLOAD", - "POST" => "POST", - "TRANSFORM" => "TRANSFORM", - "SAY" => "SAY", - _ => null - }; - } + private static string? KnownFamilyKeyword(string typeName) => typeName.Split('`')[0].ToUpperInvariant() switch { "GET" => "GET", "SAVE" => "SAVE", "LOAD" => "LOAD", "SEND" => "SEND", "DELETE" => "DELETE", "DOWNLOAD" => "DOWNLOAD", "POST" => "POST", "TRANSFORM" => "TRANSFORM", "SAY" => "SAY", _ => null }; private static bool IsFamily(Type verbType, Type marker, string legacyBaseName) { if (marker.IsAssignableFrom(verbType)) return true; - Type? current = verbType.BaseType; - while (current != null && current != typeof(object)) - { - Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; - if (candidate.Name.Split('`')[0].Equals(legacyBaseName, StringComparison.OrdinalIgnoreCase)) - return true; - current = current.BaseType; - } - + while (current != null && current != typeof(object)) { Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; if (candidate.Name.Split('`')[0].Equals(legacyBaseName, StringComparison.OrdinalIgnoreCase)) return true; current = current.BaseType; } return false; } } diff --git a/src/FluNet.Engine/Language/LanguageDescriptors.cs b/src/FluNet.Engine/Language/LanguageDescriptors.cs index 03c64c8..b1e2792 100644 --- a/src/FluNet.Engine/Language/LanguageDescriptors.cs +++ b/src/FluNet.Engine/Language/LanguageDescriptors.cs @@ -1,26 +1,25 @@ -using FluNET.Syntax.Core; using FluNET.Language.Metadata; +using FluNET.Syntax.Core; namespace FluNET.Language; public sealed record VerbIdentity(string Text, IReadOnlyList Synonyms); -public sealed record WordDescriptor( - Type WordType, - string Text, - IReadOnlyList Synonyms, - Func Factory) : ILanguageElement +public sealed record ExecutionTraitsDescriptor( + bool Pure, + bool Idempotent, + bool Retryable, + bool Transactional, + bool LongRunning, + bool SideEffecting); + +public sealed record WordDescriptor(Type WordType, string Text, IReadOnlyList Synonyms, Func Factory) : ILanguageElement { public string StableId => $"word:{Text.ToLowerInvariant()}:{WordType.FullName}"; public string Name => Text; } -public sealed record VerbDescriptor( - Type VerbType, - string Text, - IReadOnlyList Synonyms, - SentencePattern Pattern, - Func Factory) : ILanguageElement +public sealed record VerbDescriptor(Type VerbType, string Text, IReadOnlyList Synonyms, SentencePattern Pattern, Func Factory) : ILanguageElement { public string StableId => $"verb:{Text.ToLowerInvariant()}:{VerbType.FullName}"; public string Name => Text; @@ -28,6 +27,7 @@ public sealed record VerbDescriptor( public Type? ResultType { get; init; } public Type? FamilyType { get; init; } public IReadOnlyList Capabilities { get; init; } = []; + public ExecutionTraitsDescriptor Traits { get; init; } = new(false, false, false, false, false, false); } public sealed record QualifierDescriptor(string Text, Type? ValueType = null) : ILanguageElement @@ -35,3 +35,13 @@ public sealed record QualifierDescriptor(string Text, Type? ValueType = null) : public string StableId => $"qualifier:{Text.ToLowerInvariant()}"; public string Name => Text; } + +public sealed record ModuleDescriptor( + string ModuleName, + Version Version, + Type ModuleType, + IReadOnlyList Dependencies) : ILanguageElement +{ + public string StableId => $"module:{ModuleName.ToLowerInvariant()}"; + public string Name => ModuleName; +} diff --git a/src/FluNet.Engine/Language/LanguageIntrospection.cs b/src/FluNet.Engine/Language/LanguageIntrospection.cs new file mode 100644 index 0000000..b212eca --- /dev/null +++ b/src/FluNet.Engine/Language/LanguageIntrospection.cs @@ -0,0 +1,50 @@ +using System.Text.Json; + +namespace FluNET.Language; + +public sealed record LanguageManifest( + IReadOnlyList Verbs, + IReadOnlyList Qualifiers, + IReadOnlyList Modules); + +public static class LanguageIntrospection +{ + public static LanguageManifest CreateManifest(LanguageSnapshot snapshot) => new( + snapshot.Verbs.Select(v => (object)new + { + id = v.StableId, + keyword = v.Text, + synonyms = v.Synonyms, + resultType = v.ResultType?.FullName, + family = v.FamilyType?.FullName, + capabilities = v.Capabilities, + traits = v.Traits, + clauses = v.Pattern.Clauses.Select(c => new + { + kind = c.Kind.ToString().ToUpperInvariant(), + name = c.Name, + valueType = c.ValueType.FullName, + elementType = c.ElementType?.FullName, + direction = c.Direction.ToString(), + cardinality = c.Cardinality.ToString(), + required = c.Required + }).ToArray() + }).ToArray(), + snapshot.Qualifiers.Select(q => (object)new { id = q.StableId, text = q.Text, valueType = q.ValueType?.FullName }).ToArray(), + snapshot.Modules.Select(m => (object)new { id = m.StableId, name = m.ModuleName, version = m.Version.ToString(), dependencies = m.Dependencies.Select(x => x.FullName).ToArray() }).ToArray()); + + public static string ToJson(LanguageSnapshot snapshot, bool indented = true) => + JsonSerializer.Serialize(CreateManifest(snapshot), new JsonSerializerOptions { WriteIndented = indented }); + + public static string ExplainVerb(LanguageSnapshot snapshot, string keyword) + { + IReadOnlyList overloads = snapshot.GetVerbOverloads(keyword); + if (overloads.Count == 0) return $"Unknown verb: {keyword}"; + + return string.Join(Environment.NewLine + Environment.NewLine, overloads.Select(v => + { + string signature = string.Join(" ", v.Pattern.Clauses.Select(c => $"{c.Kind.ToString().ToUpperInvariant()}<{c.ValueType.Name}>")); + return $"{v.Text} {signature}\nImplementation: {v.VerbType.FullName}\nResult: {v.ResultType?.FullName ?? "void/unknown"}\nCapabilities: {string.Join(", ", v.Capabilities)}"; + })); + } +} diff --git a/src/FluNet.Engine/Language/LanguageRegistry.cs b/src/FluNet.Engine/Language/LanguageRegistry.cs index a61d101..231902f 100644 --- a/src/FluNet.Engine/Language/LanguageRegistry.cs +++ b/src/FluNet.Engine/Language/LanguageRegistry.cs @@ -4,166 +4,81 @@ namespace FluNET.Language; -/// -/// Mutable registration facade used while composing a FluNET language. New verb descriptors -/// can be discovered without constructing the verb; legacy word creation still uses prototypes. -/// public sealed class LanguageRegistry { private readonly Dictionary _words = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _verbs = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _qualifiers = new(StringComparer.OrdinalIgnoreCase); + private readonly List _modules = []; private readonly HashSet _assemblies = []; private readonly LanguageCompiler _compiler = new(); - public LanguageRegistry() - { - RegisterStandardQualifiers(); - RegisterAssemblies(AppDomain.CurrentDomain.GetAssemblies()); - } + public LanguageRegistry() { RegisterStandardQualifiers(); RegisterAssemblies(AppDomain.CurrentDomain.GetAssemblies()); } public IReadOnlyCollection Words => _words.Values.DistinctBy(x => x.WordType).ToArray(); public IReadOnlyCollection Verbs => _verbs.Values.SelectMany(x => x).DistinctBy(x => x.VerbType).ToArray(); public IReadOnlyCollection Qualifiers => _qualifiers.Values.ToArray(); - public LanguageSnapshot Snapshot => new(Words, Verbs, Qualifiers); + public IReadOnlyList Modules => _modules; + public LanguageSnapshot Snapshot => new(Words, Verbs, Qualifiers, Modules); + + public void RegisterModule(IFluNetModule module) + { + ArgumentNullException.ThrowIfNull(module); + if (_modules.All(x => x.ModuleType != module.GetType())) + _modules.Add(new(module.Name, module.Version, module.GetType(), module.Dependencies.ToArray())); + module.Configure(this); + RegisterAssemblies([module.GetType().Assembly]); + } public void RegisterAssemblies(IEnumerable assemblies) { foreach (Assembly assembly in assemblies) { if (!_assemblies.Add(assembly)) continue; - Type[] types; - try { types = assembly.GetTypes(); } - catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(x => x != null).Cast().ToArray(); } - - foreach (Type type in types.Where(x => typeof(IWord).IsAssignableFrom(x) && !x.IsAbstract && !x.IsInterface)) - RegisterWord(type); + try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(x => x != null).Cast().ToArray(); } + foreach (Type type in types.Where(x => typeof(IWord).IsAssignableFrom(x) && !x.IsAbstract && !x.IsInterface)) RegisterWord(type); } } - public void Refresh() - { - _words.Clear(); - _verbs.Clear(); - _assemblies.Clear(); - RegisterAssemblies(AppDomain.CurrentDomain.GetAssemblies()); - } - - public void RegisterQualifier(string text, Type? valueType = null) - { - ArgumentException.ThrowIfNullOrWhiteSpace(text); - _qualifiers[text] = new QualifierDescriptor(text.ToUpperInvariant(), valueType); - } - + public void Refresh() { _words.Clear(); _verbs.Clear(); _assemblies.Clear(); RegisterAssemblies(AppDomain.CurrentDomain.GetAssemblies()); } + public void RegisterQualifier(string text, Type? valueType = null) { ArgumentException.ThrowIfNullOrWhiteSpace(text); _qualifiers[text] = new(text.ToUpperInvariant(), valueType); } public bool IsQualifier(string text) => _qualifiers.ContainsKey(text); - - public bool TryCreateWord(string text, out IWord? word) - { - if (_words.TryGetValue(text, out WordDescriptor? descriptor)) - { - word = descriptor.Factory(); - return word != null; - } - - word = null; - return false; - } - - public bool TryGetVerb(string text, out VerbDescriptor? descriptor) - { - IReadOnlyList overloads = GetVerbOverloads(text); - descriptor = overloads.FirstOrDefault(); - return descriptor != null; - } - - public IReadOnlyList GetVerbOverloads(string text) => - _verbs.TryGetValue(text, out List? overloads) - ? overloads.DistinctBy(x => x.VerbType).ToArray() - : []; + public bool TryCreateWord(string text, out IWord? word) { if (_words.TryGetValue(text, out WordDescriptor? d)) { word = d.Factory(); return word != null; } word = null; return false; } + public bool TryGetVerb(string text, out VerbDescriptor? descriptor) { descriptor = GetVerbOverloads(text).FirstOrDefault(); return descriptor != null; } + public IReadOnlyList GetVerbOverloads(string text) => _verbs.TryGetValue(text, out List? overloads) ? overloads.DistinctBy(x => x.VerbType).ToArray() : []; public Type? GetVerbBaseType(string text) { - VerbDescriptor? descriptor = GetVerbOverloads(text).FirstOrDefault(); - if (descriptor == null) return null; - - Type? baseType = descriptor.VerbType.BaseType; - while (baseType != null && !baseType.IsAbstract && baseType != typeof(object)) - baseType = baseType.BaseType; - - if (baseType == null || baseType == typeof(object)) return null; - return baseType.IsGenericType ? baseType.GetGenericTypeDefinition() : baseType; + VerbDescriptor? descriptor = GetVerbOverloads(text).FirstOrDefault(); if (descriptor == null) return null; + Type? baseType = descriptor.VerbType.BaseType; while (baseType != null && !baseType.IsAbstract && baseType != typeof(object)) baseType = baseType.BaseType; + if (baseType == null || baseType == typeof(object)) return null; return baseType.IsGenericType ? baseType.GetGenericTypeDefinition() : baseType; } private void RegisterWord(Type type) { Func factory = () => CreatePrototype(type) as IWord; IWord? prototype = factory(); - if (typeof(IVerb).IsAssignableFrom(type)) { - IVerb? verbPrototype = prototype as IVerb; - VerbIdentity? identity = _compiler.DescribeVerbIdentity(type, verbPrototype); + VerbIdentity? identity = _compiler.DescribeVerbIdentity(type, prototype as IVerb); if (identity != null) { VerbDescriptor descriptor = _compiler.DescribeVerb(type, identity.Text, identity.Synonyms, () => factory() as IVerb); - RegisterOverload(identity.Text, descriptor); - foreach (string synonym in identity.Synonyms) - RegisterOverload(synonym, descriptor); - - if (prototype is IKeyword) - { - var word = new WordDescriptor(type, identity.Text, identity.Synonyms, factory); - _words.TryAdd(identity.Text, word); - foreach (string synonym in identity.Synonyms) - _words.TryAdd(synonym, word); - } + RegisterOverload(identity.Text, descriptor); foreach (string synonym in identity.Synonyms) RegisterOverload(synonym, descriptor); + if (prototype is IKeyword) { var word = new WordDescriptor(type, identity.Text, identity.Synonyms, factory); _words.TryAdd(identity.Text, word); foreach (string synonym in identity.Synonyms) _words.TryAdd(synonym, word); } } - return; } - - if (prototype is not IKeyword keyword) - return; - - var nonVerbWord = new WordDescriptor(type, keyword.Text, [], factory); - _words.TryAdd(keyword.Text, nonVerbWord); + if (prototype is IKeyword keyword) _words.TryAdd(keyword.Text, new(type, keyword.Text, [], factory)); } - private void RegisterOverload(string keyword, VerbDescriptor descriptor) - { - if (!_verbs.TryGetValue(keyword, out List? overloads)) - _verbs[keyword] = overloads = []; - if (overloads.All(x => x.VerbType != descriptor.VerbType)) - overloads.Add(descriptor); - } + private void RegisterOverload(string keyword, VerbDescriptor descriptor) { if (!_verbs.TryGetValue(keyword, out List? overloads)) _verbs[keyword] = overloads = []; if (overloads.All(x => x.VerbType != descriptor.VerbType)) overloads.Add(descriptor); } private static object? CreatePrototype(Type type) { - try - { - ConstructorInfo? parameterless = type.GetConstructor(Type.EmptyTypes); - if (parameterless != null) - return parameterless.Invoke(null); - - ConstructorInfo? constructor = type.GetConstructors().OrderBy(x => x.GetParameters().Length).FirstOrDefault(); - if (constructor == null) return null; - - object?[] arguments = constructor.GetParameters() - .Select(p => p.ParameterType.IsValueType ? Activator.CreateInstance(p.ParameterType) : null) - .ToArray(); - return constructor.Invoke(arguments); - } - catch { return null; } + try { ConstructorInfo? p = type.GetConstructor(Type.EmptyTypes); if (p != null) return p.Invoke(null); ConstructorInfo? c = type.GetConstructors().OrderBy(x => x.GetParameters().Length).FirstOrDefault(); if (c == null) return null; object?[] a = c.GetParameters().Select(x => x.ParameterType.IsValueType ? Activator.CreateInstance(x.ParameterType) : null).ToArray(); return c.Invoke(a); } catch { return null; } } - private void RegisterStandardQualifiers() - { - RegisterQualifier("TEXT", typeof(string)); - RegisterQualifier("JSON"); - RegisterQualifier("XML"); - RegisterQualifier("BINARY", typeof(byte[])); - foreach (string qualifier in new[] { "CSV", "HTML", "YAML", "IMAGE", "VIDEO", "AUDIO" }) - RegisterQualifier(qualifier); - } + private void RegisterStandardQualifiers() { RegisterQualifier("TEXT", typeof(string)); RegisterQualifier("JSON"); RegisterQualifier("XML"); RegisterQualifier("BINARY", typeof(byte[])); foreach (string q in new[] { "CSV", "HTML", "YAML", "IMAGE", "VIDEO", "AUDIO" }) RegisterQualifier(q); } } diff --git a/src/FluNet.Engine/Language/LanguageSnapshot.cs b/src/FluNet.Engine/Language/LanguageSnapshot.cs index 6898a3c..50dc75d 100644 --- a/src/FluNet.Engine/Language/LanguageSnapshot.cs +++ b/src/FluNet.Engine/Language/LanguageSnapshot.cs @@ -1,49 +1,40 @@ namespace FluNET.Language; -/// -/// Immutable compiled view of the language. Parsing, binding, tooling and runtime -/// should consume this snapshot rather than repeatedly reflecting over assemblies. -/// public sealed class LanguageSnapshot { private readonly IReadOnlyDictionary _words; private readonly IReadOnlyDictionary> _verbs; private readonly IReadOnlyDictionary _qualifiers; + private readonly IReadOnlyList _modules; public LanguageSnapshot( IEnumerable words, IEnumerable verbs, - IEnumerable qualifiers) + IEnumerable qualifiers, + IEnumerable? modules = null) { var wordMap = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (WordDescriptor word in words) { wordMap[word.Text] = word; - foreach (string synonym in word.Synonyms) - wordMap[synonym] = word; + foreach (string synonym in word.Synonyms) wordMap[synonym] = word; } var verbMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (VerbDescriptor verb in verbs) { AddVerb(verb.Text, verb); - foreach (string synonym in verb.Synonyms) - AddVerb(synonym, verb); + foreach (string synonym in verb.Synonyms) AddVerb(synonym, verb); } - var qualifierMap = qualifiers.ToDictionary(x => x.Text, StringComparer.OrdinalIgnoreCase); - _words = wordMap; - _verbs = verbMap.ToDictionary( - x => x.Key, - x => (IReadOnlyList)x.Value.DistinctBy(v => v.VerbType).ToArray(), - StringComparer.OrdinalIgnoreCase); - _qualifiers = qualifierMap; + _verbs = verbMap.ToDictionary(x => x.Key, x => (IReadOnlyList)x.Value.DistinctBy(v => v.VerbType).ToArray(), StringComparer.OrdinalIgnoreCase); + _qualifiers = qualifiers.ToDictionary(x => x.Text, StringComparer.OrdinalIgnoreCase); + _modules = (modules ?? []).ToArray(); void AddVerb(string key, VerbDescriptor descriptor) { - if (!verbMap.TryGetValue(key, out List? set)) - verbMap[key] = set = []; + if (!verbMap.TryGetValue(key, out List? set)) verbMap[key] = set = []; set.Add(descriptor); } } @@ -51,11 +42,9 @@ void AddVerb(string key, VerbDescriptor descriptor) public IReadOnlyCollection Words => _words.Values.DistinctBy(x => x.WordType).ToArray(); public IReadOnlyCollection Verbs => _verbs.Values.SelectMany(x => x).DistinctBy(x => x.VerbType).ToArray(); public IReadOnlyCollection Qualifiers => _qualifiers.Values.ToArray(); + public IReadOnlyList Modules => _modules; public bool TryGetWord(string text, out WordDescriptor? descriptor) => _words.TryGetValue(text, out descriptor); - - public IReadOnlyList GetVerbOverloads(string text) => - _verbs.TryGetValue(text, out IReadOnlyList? descriptors) ? descriptors : []; - + public IReadOnlyList GetVerbOverloads(string text) => _verbs.TryGetValue(text, out IReadOnlyList? descriptors) ? descriptors : []; public bool IsQualifier(string text) => _qualifiers.ContainsKey(text); } diff --git a/src/FluNet.Engine/Language/LanguageValidator.cs b/src/FluNet.Engine/Language/LanguageValidator.cs new file mode 100644 index 0000000..bd6a3f5 --- /dev/null +++ b/src/FluNet.Engine/Language/LanguageValidator.cs @@ -0,0 +1,40 @@ +using FluNET.Diagnostics; + +namespace FluNET.Language; + +public static class LanguageValidator +{ + public static IReadOnlyList Validate(LanguageSnapshot snapshot) + { + var diagnostics = new List(); + + foreach (IGrouping group in snapshot.Verbs.GroupBy(SignatureKey, StringComparer.OrdinalIgnoreCase)) + { + VerbDescriptor[] duplicates = group.ToArray(); + if (duplicates.Length > 1) + { + diagnostics.Add(Diagnostic.Error( + "FLU-LANG-001", + $"Duplicate verb signature '{group.Key}' is implemented by: {string.Join(", ", duplicates.Select(x => x.VerbType.FullName))}.")); + } + } + + foreach (ModuleDescriptor module in snapshot.Modules) + { + foreach (Type dependency in module.Dependencies) + { + if (snapshot.Modules.All(x => x.ModuleType != dependency)) + { + diagnostics.Add(Diagnostic.Error( + "FLU-LANG-010", + $"Module '{module.ModuleName}' requires missing module '{dependency.FullName}'.")); + } + } + } + + return diagnostics; + } + + private static string SignatureKey(VerbDescriptor verb) => + $"{verb.Text}:{string.Join("|", verb.Pattern.Clauses.Select(x => $"{x.Kind}:{x.ValueType.FullName}:{x.Cardinality}"))}"; +} diff --git a/tests/FluNET.Tests/LanguageIntrospectionTests.cs b/tests/FluNET.Tests/LanguageIntrospectionTests.cs new file mode 100644 index 0000000..ceacbd0 --- /dev/null +++ b/tests/FluNET.Tests/LanguageIntrospectionTests.cs @@ -0,0 +1,24 @@ +using FluNET.Language; + +namespace FluNET.Tests; + +public class LanguageIntrospectionTests +{ + [Fact] + public void Manifest_contains_compiled_get_metadata() + { + LanguageSnapshot snapshot = new LanguageRegistry().Snapshot; + string json = LanguageIntrospection.ToJson(snapshot); + + Assert.Contains("GET", json); + Assert.Contains("clauses", json); + Assert.Contains("resultType", json); + } + + [Fact] + public void Language_validator_accepts_standard_snapshot_without_missing_module_dependencies() + { + LanguageSnapshot snapshot = new LanguageRegistry().Snapshot; + Assert.DoesNotContain(LanguageValidator.Validate(snapshot), x => x.Code == "FLU-LANG-010"); + } +} From 7af69c4f6c3bce06bec9bb2762dfce8f45789b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:24:58 +0200 Subject: [PATCH 08/18] Add Classic lexer parser and compiler frontend --- src/FluNet.Engine/Binding/SemanticBinder.cs | 184 +++++------------- .../Compilation/ClassicCompiler.cs | 49 +++++ src/FluNet.Engine/Syntax/Ast/AstNodes.cs | 18 +- .../Syntax/Lexing/ClassicLexer.cs | 106 ++++++++++ .../Syntax/Parsing/ClassicParser.cs | 148 ++++++++++++++ tests/FluNET.Tests/ClassicCompilerTests.cs | 20 ++ tests/FluNET.Tests/ClassicParserTests.cs | 33 ++++ 7 files changed, 409 insertions(+), 149 deletions(-) create mode 100644 src/FluNet.Engine/Compilation/ClassicCompiler.cs create mode 100644 src/FluNet.Engine/Syntax/Lexing/ClassicLexer.cs create mode 100644 src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs create mode 100644 tests/FluNET.Tests/ClassicCompilerTests.cs create mode 100644 tests/FluNET.Tests/ClassicParserTests.cs diff --git a/src/FluNet.Engine/Binding/SemanticBinder.cs b/src/FluNet.Engine/Binding/SemanticBinder.cs index a59a228..11568b2 100644 --- a/src/FluNet.Engine/Binding/SemanticBinder.cs +++ b/src/FluNet.Engine/Binding/SemanticBinder.cs @@ -6,10 +6,6 @@ namespace FluNET.Binding; -/// -/// Binds sentence syntax to a concrete verb overload using compiled reflection metadata. -/// Lower binding cost wins: exact CLR matches are preferred over textual resolution. -/// public sealed class SemanticBinder { private readonly LanguageSnapshot _language; @@ -25,34 +21,24 @@ public BindingResult BindSentence(SentenceNode sentence, BindingC { context ??= new BindingContext(); IReadOnlyList overloads = _language.GetVerbOverloads(sentence.Verb); - if (overloads.Count == 0) - return Failure("FLU2001", $"Unknown verb '{sentence.Verb}'."); + if (overloads.Count == 0) return Failure("FLU2001", $"Unknown verb '{sentence.Verb}'."); var candidates = new List(); foreach (VerbDescriptor overload in overloads) { BoundSentence? candidate = TryBindOverload(sentence, overload, context); - if (candidate != null) - candidates.Add(candidate); + if (candidate != null) candidates.Add(candidate); } if (candidates.Count == 0) { string signatures = string.Join(", ", overloads.Select(FormatSignature)); - return Failure( - "FLU2101", - $"No overload of '{sentence.Verb}' matches this sentence. Available: {signatures}."); + return Failure("FLU2101", $"No overload of '{sentence.Verb}' matches this sentence. Available: {signatures}."); } int bestCost = candidates.Min(x => x.BindingCost); BoundSentence[] best = candidates.Where(x => x.BindingCost == bestCost).ToArray(); - if (best.Length > 1) - { - return Failure( - "FLU2102", - $"Ambiguous '{sentence.Verb}' sentence. Matching overloads: {string.Join(", ", best.Select(x => FormatSignature(x.Verb)))}."); - } - + if (best.Length > 1) return Failure("FLU2102", $"Ambiguous '{sentence.Verb}' sentence. Matching overloads: {string.Join(", ", best.Select(x => FormatSignature(x.Verb)))}."); return new(best[0], []); } @@ -61,18 +47,26 @@ public BindingResult BindPipeline(PipelineNode pipeline, BindingC context ??= new BindingContext(); var bound = new List(); var diagnostics = new List(); + var variableTypes = context.VariableTypes != null + ? new Dictionary(context.VariableTypes, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); Type? pipelineType = context.PipelineType; foreach (SentenceNode sentence in pipeline.Sentences) { - BindingContext sentenceContext = context with { PipelineType = pipelineType }; + BindingContext sentenceContext = context with { PipelineType = pipelineType, VariableTypes = variableTypes }; BindingResult result = BindSentence(sentence, sentenceContext); diagnostics.AddRange(result.Diagnostics); - if (!result.Success || result.Value == null) - return new(null, diagnostics); + if (!result.Success || result.Value == null) return new(null, diagnostics); bound.Add(result.Value); pipelineType = result.Value.ResultType; + + foreach (BoundRole role in result.Value.Roles.Where(x => x.Descriptor.Direction is RoleDirection.Output or RoleDirection.InputOutput)) + { + foreach (BoundValue value in role.Values) + if (value.Source is VariableExpression variable) variableTypes[variable.Name] = role.Descriptor.ValueType; + } } return new(new BoundPipeline(bound, pipelineType), diagnostics); @@ -80,10 +74,7 @@ public BindingResult BindPipeline(PipelineNode pipeline, BindingC private BoundSentence? TryBindOverload(SentenceNode sentence, VerbDescriptor verb, BindingContext context) { - var remaining = sentence.Clauses - .GroupBy(x => x.Kind) - .ToDictionary(x => x.Key, x => new Queue(x)); - + var remaining = sentence.Clauses.GroupBy(x => x.Kind).ToDictionary(x => x.Key, x => new Queue(x)); var roles = new List(); int cost = 0; @@ -99,160 +90,75 @@ public BindingResult BindPipeline(PipelineNode pipeline, BindingC { BoundValue? collection = TryBindRepeatedValues(queue, expected, verb, context); if (collection == null) return null; - values.Add(collection); - cost += collection.ConversionCost; + values.Add(collection); cost += collection.ConversionCost; } else { ClauseNode actual = queue.Dequeue(); BoundValue? value = TryBindValue(actual.Value, expected, verb, context); if (value == null) return null; - values.Add(value); - cost += value.ConversionCost; + values.Add(value); cost += value.ConversionCost; } } if (values.Count < minimum) { BoundValue? implicitPipeline = TryBindPipelineValue(expected, context); - if (implicitPipeline != null) - { - values.Add(implicitPipeline); - cost += implicitPipeline.ConversionCost; - } + if (implicitPipeline != null) { values.Add(implicitPipeline); cost += implicitPipeline.ConversionCost; } } - if (values.Count < minimum) - return null; - + if (values.Count < minimum) return null; roles.Add(new BoundRole(expected, values)); } - if (remaining.Values.Any(queue => queue.Count > 0)) - return null; - - ConstructorDescriptor? constructor = verb.Constructors.FirstOrDefault(x => x.RoleParameterCount > 0) - ?? verb.Constructors.FirstOrDefault(); - + if (remaining.Values.Any(queue => queue.Count > 0)) return null; + ConstructorDescriptor? constructor = verb.Constructors.FirstOrDefault(x => x.RoleParameterCount > 0) ?? verb.Constructors.FirstOrDefault(); return new BoundSentence(verb, constructor, roles, verb.ResultType, cost); } - private BoundValue? TryBindRepeatedValues( - Queue queue, - ClauseDescriptor expected, - VerbDescriptor verb, - BindingContext context) + private BoundValue? TryBindRepeatedValues(Queue queue, ClauseDescriptor expected, VerbDescriptor verb, BindingContext context) { - ClauseNode[] clauses = queue.ToArray(); - queue.Clear(); - string[] texts = clauses.Select(x => x.Value switch - { - LiteralExpression literal => literal.Value, - ReferenceExpression reference => reference.Reference, - _ => null - }).Where(x => x != null).Cast().ToArray(); - - if (texts.Length != clauses.Length) - return null; - - ResolutionContext resolution = new( - expected.ValueType, - expected.Kind, - verb, - Qualifier: null, - Services: context.Services); - - if (!_resolvers.TryResolveMany(texts, expected.ValueType, resolution, out object? collection)) - return null; - - ExpressionNode source = clauses.Length == 1 - ? clauses[0].Value - : new InterpolatedStringExpression(string.Join(" ", texts)); - + ClauseNode[] clauses = queue.ToArray(); queue.Clear(); + string[] texts = clauses.Select(x => x.Value switch { LiteralExpression l => l.Value, ReferenceExpression r => r.Reference, _ => null }).Where(x => x != null).Cast().ToArray(); + if (texts.Length != clauses.Length) return null; + ResolutionContext resolution = new(expected.ValueType, expected.Kind, verb, Qualifier: null, Services: context.Services); + if (!_resolvers.TryResolveMany(texts, expected.ValueType, resolution, out object? collection)) return null; + ExpressionNode source = clauses.Length == 1 ? clauses[0].Value : new InterpolatedStringExpression(string.Join(" ", texts)); return new(source, expected.ValueType, collection?.GetType() ?? expected.ValueType, collection, 2); } - private BoundValue? TryBindValue( - ExpressionNode expression, - ClauseDescriptor expected, - VerbDescriptor verb, - BindingContext context) + private BoundValue? TryBindValue(ExpressionNode expression, ClauseDescriptor expected, VerbDescriptor verb, BindingContext context) { - if (expected.Direction == RoleDirection.Output && expression is VariableExpression output) - return new(output, expected.ValueType, expected.ValueType, null, 0); - - if (expression is PipelineValueExpression) - return BindKnownType(expression, context.PipelineType, expected.ValueType, null); - + if (expected.Direction == RoleDirection.Output && expression is VariableExpression output) return new(output, expected.ValueType, expected.ValueType, null, 0); + if (expression is PipelineValueExpression) return BindKnownType(expression, context.PipelineType, expected.ValueType, null); if (expression is VariableExpression variable) { - Type? actualType = null; - if (context.VariableTypes != null) - context.VariableTypes.TryGetValue(variable.Name, out actualType); + Type? actualType = null; context.VariableTypes?.TryGetValue(variable.Name, out actualType); return BindKnownType(expression, actualType, expected.ValueType, null); } - - if (expression is InterpolatedStringExpression interpolated && expected.ValueType == typeof(string)) - return new(interpolated, typeof(string), typeof(string), null, 0); - - string? text = expression switch - { - LiteralExpression literal => literal.Value, - ReferenceExpression reference => reference.Reference, - _ => null - }; - - if (text == null) - return null; - - ResolutionContext resolution = new( - expected.ValueType, - expected.Kind, - verb, - Qualifier: null, - Services: context.Services); - - if (_resolvers.TryResolve(text, expected.ValueType, resolution, out object? value)) - return new(expression, expected.ValueType, value?.GetType() ?? expected.ValueType, value, expected.ValueType == typeof(string) ? 0 : 2); - + if (expression is InterpolatedStringExpression interpolated && expected.ValueType == typeof(string)) return new(interpolated, typeof(string), typeof(string), null, 0); + string? text = expression switch { LiteralExpression l => l.Value, ReferenceExpression r => r.Reference, _ => null }; + if (text == null) return null; + ResolutionContext resolution = new(expected.ValueType, expected.Kind, verb, Qualifier: null, Services: context.Services); + if (_resolvers.TryResolve(text, expected.ValueType, resolution, out object? value)) return new(expression, expected.ValueType, value?.GetType() ?? expected.ValueType, value, expected.ValueType == typeof(string) ? 0 : 2); return null; } private static BoundValue? TryBindPipelineValue(ClauseDescriptor expected, BindingContext context) { - if (expected.Direction == RoleDirection.Output || context.PipelineType == null) - return null; - + if (expected.Direction == RoleDirection.Output || context.PipelineType == null) return null; return BindKnownType(new PipelineValueExpression(), context.PipelineType, expected.ValueType, null); } private static BoundValue? BindKnownType(ExpressionNode source, Type? actualType, Type expectedType, object? value) { - if (actualType == null) - return null; - - if (expectedType == actualType) - return new(source, expectedType, actualType, value, 0); - - if (expectedType.IsAssignableFrom(actualType)) - return new(source, expectedType, actualType, value, 1); - + if (actualType == null) return null; + if (expectedType == actualType) return new(source, expectedType, actualType, value, 0); + if (expectedType.IsAssignableFrom(actualType)) return new(source, expectedType, actualType, value, 1); return null; } - private static BindingResult Failure(string code, string message) => - new(null, [Diagnostic.Error(code, message)]); - - private static string FormatSignature(VerbDescriptor verb) - { - string clauses = string.Join(" ", verb.Pattern.Clauses.Select(x => - $"{x.Kind.ToString().ToUpperInvariant()}<{FriendlyName(x.ValueType)}>")); - return string.IsNullOrEmpty(clauses) ? verb.Text : $"{verb.Text} {clauses}"; - } - - private static string FriendlyName(Type type) - { - if (type.IsArray) return $"{FriendlyName(type.GetElementType()!)}[]"; - return type.Name; - } + private static BindingResult Failure(string code, string message) => new(null, [Diagnostic.Error(code, message)]); + private static string FormatSignature(VerbDescriptor verb) { string clauses = string.Join(" ", verb.Pattern.Clauses.Select(x => $"{x.Kind.ToString().ToUpperInvariant()}<{FriendlyName(x.ValueType)}>")); return string.IsNullOrEmpty(clauses) ? verb.Text : $"{verb.Text} {clauses}"; } + private static string FriendlyName(Type type) => type.IsArray ? $"{FriendlyName(type.GetElementType()!)}[]" : type.Name; } diff --git a/src/FluNet.Engine/Compilation/ClassicCompiler.cs b/src/FluNet.Engine/Compilation/ClassicCompiler.cs new file mode 100644 index 0000000..1fc8889 --- /dev/null +++ b/src/FluNet.Engine/Compilation/ClassicCompiler.cs @@ -0,0 +1,49 @@ +using FluNET.Binding; +using FluNET.Diagnostics; +using FluNET.Language; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Parsing; + +namespace FluNET.Compilation; + +public sealed record ClassicCompilation( + ScriptNode? Syntax, + IReadOnlyList Pipelines, + IReadOnlyList Diagnostics) +{ + public bool Success => Syntax != null && Diagnostics.All(x => x.Severity != DiagnosticSeverity.Error); +} + +/// +/// End-to-end frontend: source -> AST -> semantic binding. Execution stays a separate concern. +/// +public sealed class ClassicCompiler +{ + private readonly ClassicParser _parser; + private readonly SemanticBinder _binder; + + public ClassicCompiler(LanguageSnapshot language, ValueResolverRegistry? resolvers = null) + { + _parser = new ClassicParser(language); + _binder = new SemanticBinder(language, resolvers); + } + + public ClassicCompilation Compile(string source, BindingContext? context = null) + { + ParseResult parse = _parser.Parse(source); + var diagnostics = new List(parse.Diagnostics); + var pipelines = new List(); + + if (!parse.Success || parse.Script == null) + return new(parse.Script, pipelines, diagnostics); + + foreach (PipelineNode pipeline in parse.Script.Pipelines) + { + BindingResult binding = _binder.BindPipeline(pipeline, context); + diagnostics.AddRange(binding.Diagnostics); + if (binding.Value != null) pipelines.Add(binding.Value); + } + + return new(parse.Script, pipelines, diagnostics); + } +} diff --git a/src/FluNet.Engine/Syntax/Ast/AstNodes.cs b/src/FluNet.Engine/Syntax/Ast/AstNodes.cs index 4868b90..cb543c9 100644 --- a/src/FluNet.Engine/Syntax/Ast/AstNodes.cs +++ b/src/FluNet.Engine/Syntax/Ast/AstNodes.cs @@ -1,29 +1,27 @@ +using FluNET.Diagnostics; using FluNET.Language; namespace FluNET.Syntax.Ast; /// -/// Stable immutable syntax model between parsing and binding. Runtime objects are deliberately absent. +/// Stable immutable syntax model between parsing and binding. /// -public abstract record SyntaxNode; +public abstract record SyntaxNode +{ + public TextSpan? Span { get; init; } +} public sealed record ScriptNode(IReadOnlyList Pipelines) : SyntaxNode; - public sealed record PipelineNode(IReadOnlyList Sentences) : SyntaxNode; -public sealed record SentenceNode( - string Verb, - IReadOnlyList Clauses) : SyntaxNode +public sealed record SentenceNode(string Verb, IReadOnlyList Clauses) : SyntaxNode { public string? Qualifier { get; init; } } -public sealed record ClauseNode( - ClauseKind Kind, - ExpressionNode Value) : SyntaxNode; +public sealed record ClauseNode(ClauseKind Kind, ExpressionNode Value) : SyntaxNode; public abstract record ExpressionNode : SyntaxNode; - public sealed record LiteralExpression(string Value) : ExpressionNode; public sealed record VariableExpression(string Name) : ExpressionNode; public sealed record ReferenceExpression(string Reference) : ExpressionNode; diff --git a/src/FluNet.Engine/Syntax/Lexing/ClassicLexer.cs b/src/FluNet.Engine/Syntax/Lexing/ClassicLexer.cs new file mode 100644 index 0000000..df1af68 --- /dev/null +++ b/src/FluNet.Engine/Syntax/Lexing/ClassicLexer.cs @@ -0,0 +1,106 @@ +using FluNET.Diagnostics; + +namespace FluNET.Syntax.Lexing; + +public enum ClassicTokenKind +{ + Word, + Variable, + Reference, + String, + NewLine +} + +public sealed record ClassicToken(ClassicTokenKind Kind, string Text, TextSpan Span); + +/// +/// Small lexer for the Classic sentence surface. It deliberately knows nothing about verbs; +/// language knowledge enters at parsing/binding through LanguageSnapshot. +/// +public sealed class ClassicLexer +{ + public IReadOnlyList Lex(string source) + { + ArgumentNullException.ThrowIfNull(source); + var tokens = new List(); + int i = 0; + + while (i < source.Length) + { + char ch = source[i]; + if (ch == '\r' || ch == '\n' || ch == ';') + { + int start = i; + if (ch == '\r' && i + 1 < source.Length && source[i + 1] == '\n') i++; + i++; + tokens.Add(new(ClassicTokenKind.NewLine, "\n", new TextSpan(start, i - start))); + continue; + } + + if (char.IsWhiteSpace(ch)) + { + i++; + continue; + } + + if (ch == '[') + { + tokens.Add(ReadDelimited(source, ref i, '[', ']', ClassicTokenKind.Variable)); + continue; + } + + if (ch == '{') + { + tokens.Add(ReadDelimited(source, ref i, '{', '}', ClassicTokenKind.Reference)); + continue; + } + + if (ch == '"') + { + tokens.Add(ReadString(source, ref i)); + continue; + } + + int wordStart = i; + while (i < source.Length && !char.IsWhiteSpace(source[i]) && source[i] != ';') i++; + tokens.Add(new(ClassicTokenKind.Word, source[wordStart..i], new TextSpan(wordStart, i - wordStart))); + } + + return tokens; + } + + private static ClassicToken ReadDelimited(string source, ref int index, char open, char close, ClassicTokenKind kind) + { + int start = index++; + int contentStart = index; + while (index < source.Length && source[index] != close) index++; + int contentEnd = index; + if (index < source.Length && source[index] == close) index++; + return new(kind, source[contentStart..contentEnd], new TextSpan(start, index - start)); + } + + private static ClassicToken ReadString(string source, ref int index) + { + int start = index++; + var value = new System.Text.StringBuilder(); + bool escaped = false; + while (index < source.Length) + { + char ch = source[index++]; + if (escaped) + { + value.Append(ch); + escaped = false; + continue; + } + if (ch == '\\') + { + escaped = true; + continue; + } + if (ch == '"') break; + value.Append(ch); + } + return new(ClassicTokenKind.String, value.ToString(), new TextSpan(start, index - start)); + } +} diff --git a/src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs b/src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs new file mode 100644 index 0000000..72e0ee1 --- /dev/null +++ b/src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs @@ -0,0 +1,148 @@ +using FluNET.Diagnostics; +using FluNET.Language; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Lexing; + +namespace FluNET.Syntax.Parsing; + +public sealed record ParseResult(ScriptNode? Script, IReadOnlyList Diagnostics) +{ + public bool Success => Script != null && Diagnostics.All(x => x.Severity != DiagnosticSeverity.Error); +} + +/// +/// Parses the Classic sentence form into immutable AST. WHAT is implicit for values that appear +/// after the verb/qualifier and before the first preposition. +/// +public sealed class ClassicParser +{ + private static readonly Dictionary RoleKeywords = new(StringComparer.OrdinalIgnoreCase) + { + ["WHAT"] = ClauseKind.What, + ["FROM"] = ClauseKind.From, + ["TO"] = ClauseKind.To, + ["USING"] = ClauseKind.Using, + ["WITH"] = ClauseKind.With + }; + + private readonly LanguageSnapshot _language; + private readonly ClassicLexer _lexer; + + public ClassicParser(LanguageSnapshot language, ClassicLexer? lexer = null) + { + _language = language; + _lexer = lexer ?? new ClassicLexer(); + } + + public ParseResult Parse(string source) + { + IReadOnlyList tokens = _lexer.Lex(source); + var diagnostics = new List(); + var pipelines = new List(); + int index = 0; + + while (index < tokens.Count) + { + SkipNewLines(tokens, ref index); + if (index >= tokens.Count) break; + + var sentences = new List(); + while (index < tokens.Count && tokens[index].Kind != ClassicTokenKind.NewLine) + { + SentenceNode? sentence = ParseSentence(tokens, ref index, diagnostics); + if (sentence != null) sentences.Add(sentence); + + if (index < tokens.Count && IsWord(tokens[index], "THEN")) + { + index++; + continue; + } + break; + } + + if (sentences.Count > 0) + { + int start = sentences[0].Span?.Start ?? 0; + int end = sentences[^1].Span?.End ?? start; + pipelines.Add(new PipelineNode(sentences) { Span = new TextSpan(start, Math.Max(0, end - start)) }); + } + + SkipNewLines(tokens, ref index); + } + + return new(new ScriptNode(pipelines), diagnostics); + } + + private SentenceNode? ParseSentence(IReadOnlyList tokens, ref int index, List diagnostics) + { + if (index >= tokens.Count || tokens[index].Kind != ClassicTokenKind.Word) + { + ClassicToken? token = index < tokens.Count ? tokens[index] : null; + diagnostics.Add(Diagnostic.Error("FLU1001", "Expected a verb at the start of the sentence.", token?.Span)); + SkipUntilBoundary(tokens, ref index); + return null; + } + + ClassicToken verbToken = tokens[index++]; + string verb = verbToken.Text.ToUpperInvariant(); + string? qualifier = null; + + if (index < tokens.Count && tokens[index].Kind == ClassicTokenKind.Word && _language.IsQualifier(tokens[index].Text)) + qualifier = tokens[index++].Text.ToUpperInvariant(); + + ClauseKind currentRole = ClauseKind.What; + var clauses = new List(); + int end = verbToken.Span.End; + + while (index < tokens.Count) + { + ClassicToken token = tokens[index]; + if (token.Kind == ClassicTokenKind.NewLine || IsWord(token, "THEN")) break; + + if (token.Kind == ClassicTokenKind.Word && RoleKeywords.TryGetValue(token.Text, out ClauseKind role)) + { + currentRole = role; + end = token.Span.End; + index++; + continue; + } + + ExpressionNode expression = ToExpression(token); + clauses.Add(new ClauseNode(currentRole, expression) { Span = token.Span }); + end = token.Span.End; + index++; + } + + return new SentenceNode(verb, clauses) + { + Qualifier = qualifier, + Span = new TextSpan(verbToken.Span.Start, Math.Max(0, end - verbToken.Span.Start)) + }; + } + + private static ExpressionNode ToExpression(ClassicToken token) + { + ExpressionNode expression = token.Kind switch + { + ClassicTokenKind.Variable => new VariableExpression(token.Text), + ClassicTokenKind.Reference => new ReferenceExpression(token.Text), + ClassicTokenKind.String when token.Text.Contains('[') => new InterpolatedStringExpression(token.Text), + ClassicTokenKind.String => new LiteralExpression(token.Text), + _ => new LiteralExpression(token.Text) + }; + return expression with { Span = token.Span }; + } + + private static bool IsWord(ClassicToken token, string text) => + token.Kind == ClassicTokenKind.Word && token.Text.Equals(text, StringComparison.OrdinalIgnoreCase); + + private static void SkipNewLines(IReadOnlyList tokens, ref int index) + { + while (index < tokens.Count && tokens[index].Kind == ClassicTokenKind.NewLine) index++; + } + + private static void SkipUntilBoundary(IReadOnlyList tokens, ref int index) + { + while (index < tokens.Count && tokens[index].Kind != ClassicTokenKind.NewLine && !IsWord(tokens[index], "THEN")) index++; + } +} diff --git a/tests/FluNET.Tests/ClassicCompilerTests.cs b/tests/FluNET.Tests/ClassicCompilerTests.cs new file mode 100644 index 0000000..cc0146f --- /dev/null +++ b/tests/FluNET.Tests/ClassicCompilerTests.cs @@ -0,0 +1,20 @@ +using FluNET.Compilation; +using FluNET.Language; + +namespace FluNET.Tests; + +public class ClassicCompilerTests +{ + [Fact] + public void Compiler_binds_a_classic_get_from_source_text() + { + LanguageSnapshot language = new LanguageRegistry().Snapshot; + var compiler = new ClassicCompiler(language); + + ClassicCompilation result = compiler.Compile("GET [text] FROM {input.txt}"); + + Assert.True(result.Success); + Assert.Single(result.Pipelines); + Assert.Equal(typeof(string[]), result.Pipelines[0].ResultType); + } +} diff --git a/tests/FluNET.Tests/ClassicParserTests.cs b/tests/FluNET.Tests/ClassicParserTests.cs new file mode 100644 index 0000000..48e5044 --- /dev/null +++ b/tests/FluNET.Tests/ClassicParserTests.cs @@ -0,0 +1,33 @@ +using FluNET.Language; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Parsing; + +namespace FluNET.Tests; + +public class ClassicParserTests +{ + [Fact] + public void Parser_preserves_classic_sentence_shape_and_then_pipeline() + { + var parser = new ClassicParser(new LanguageRegistry().Snapshot); + + ParseResult result = parser.Parse("GET TEXT [data] FROM {input.txt} THEN SAY [data]"); + + Assert.True(result.Success); + PipelineNode pipeline = Assert.Single(result.Script!.Pipelines); + Assert.Equal(2, pipeline.Sentences.Count); + Assert.Equal("GET", pipeline.Sentences[0].Verb); + Assert.Equal("TEXT", pipeline.Sentences[0].Qualifier); + Assert.Contains(pipeline.Sentences[0].Clauses, x => x.Kind == ClauseKind.What && x.Value is VariableExpression); + Assert.Contains(pipeline.Sentences[0].Clauses, x => x.Kind == ClauseKind.From && x.Value is ReferenceExpression); + } + + [Fact] + public void Parser_keeps_multiple_values_for_the_same_role() + { + var parser = new ClassicParser(new LanguageRegistry().Snapshot); + ParseResult result = parser.Parse("GET [data] FROM a.txt b.txt c.txt"); + SentenceNode sentence = Assert.Single(Assert.Single(result.Script!.Pipelines).Sentences); + Assert.Equal(3, sentence.Clauses.Count(x => x.Kind == ClauseKind.From)); + } +} From 60d08f47ce610208b7a8f062e0cd7a851b7e6590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:27:14 +0200 Subject: [PATCH 09/18] Add CLR conversion graph and qualifier overload selection --- src/FluNet.Engine/Binding/BoundNodes.cs | 16 +-- src/FluNet.Engine/Binding/IValueConverter.cs | 82 ++++++++++++ src/FluNet.Engine/Binding/SemanticBinder.cs | 58 ++++---- src/FluNet.Engine/Binding/VerbActivator.cs | 124 ++++-------------- .../Compilation/ClassicCompiler.cs | 18 +-- .../Language/LanguageSnapshot.cs | 31 +---- .../ValueConversionRegistryTests.cs | 17 +++ 7 files changed, 172 insertions(+), 174 deletions(-) create mode 100644 src/FluNet.Engine/Binding/IValueConverter.cs create mode 100644 tests/FluNET.Tests/ValueConversionRegistryTests.cs diff --git a/src/FluNet.Engine/Binding/BoundNodes.cs b/src/FluNet.Engine/Binding/BoundNodes.cs index 8911790..b387232 100644 --- a/src/FluNet.Engine/Binding/BoundNodes.cs +++ b/src/FluNet.Engine/Binding/BoundNodes.cs @@ -10,19 +10,11 @@ public sealed record BoundValue( Type ExpectedType, Type ActualType, object? ConstantValue, - int ConversionCost); - -public sealed record BoundRole( - ClauseDescriptor Descriptor, - IReadOnlyList Values); - -public sealed record BoundSentence( - VerbDescriptor Verb, - ConstructorDescriptor? Constructor, - IReadOnlyList Roles, - Type? ResultType, - int BindingCost); + int ConversionCost, + ValueConversion? Conversion = null); +public sealed record BoundRole(ClauseDescriptor Descriptor, IReadOnlyList Values); +public sealed record BoundSentence(VerbDescriptor Verb, ConstructorDescriptor? Constructor, IReadOnlyList Roles, Type? ResultType, int BindingCost); public sealed record BoundPipeline(IReadOnlyList Sentences, Type? ResultType); public sealed record BindingResult(T? Value, IReadOnlyList Diagnostics) diff --git a/src/FluNet.Engine/Binding/IValueConverter.cs b/src/FluNet.Engine/Binding/IValueConverter.cs new file mode 100644 index 0000000..c69ab99 --- /dev/null +++ b/src/FluNet.Engine/Binding/IValueConverter.cs @@ -0,0 +1,82 @@ +using System.Globalization; + +namespace FluNET.Binding; + +public sealed record ValueConversion( + Type SourceType, + Type TargetType, + int Cost, + Func Apply); + +public interface IValueConverter +{ + Type SourceType { get; } + Type TargetType { get; } + int Cost { get; } + object? Convert(object? value); +} + +public abstract class ValueConverter(int cost = 2) : IValueConverter +{ + public Type SourceType => typeof(TFrom); + public Type TargetType => typeof(TTo); + public int Cost { get; } = cost; + public object? Convert(object? value) => value is null ? default(TTo) : Convert((TFrom)value); + protected abstract TTo Convert(TFrom value); +} + +/// +/// Runtime conversion graph used after a value already has a CLR type. This is separate +/// from textual IValueResolver resolution. +/// +public sealed class ValueConversionRegistry +{ + private static readonly HashSet NumericTypes = + [typeof(byte), typeof(sbyte), typeof(short), typeof(ushort), typeof(int), typeof(uint), typeof(long), typeof(ulong), typeof(float), typeof(double), typeof(decimal)]; + + private readonly List _converters = []; + + public ValueConversionRegistry Add(IValueConverter converter) + { + ArgumentNullException.ThrowIfNull(converter); + _converters.Insert(0, converter); + return this; + } + + public bool TryGet(Type sourceType, Type targetType, out ValueConversion? conversion) + { + if (sourceType == targetType) + { + conversion = new(sourceType, targetType, 0, value => value); + return true; + } + + if (targetType.IsAssignableFrom(sourceType)) + { + conversion = new(sourceType, targetType, 1, value => value); + return true; + } + + Type sourceActual = Nullable.GetUnderlyingType(sourceType) ?? sourceType; + Type targetActual = Nullable.GetUnderlyingType(targetType) ?? targetType; + + IValueConverter? custom = _converters + .Where(x => x.SourceType.IsAssignableFrom(sourceActual) && targetActual.IsAssignableFrom(x.TargetType)) + .OrderBy(x => x.Cost) + .FirstOrDefault(); + if (custom != null) + { + conversion = new(sourceType, targetType, custom.Cost, custom.Convert); + return true; + } + + if (NumericTypes.Contains(sourceActual) && NumericTypes.Contains(targetActual)) + { + conversion = new(sourceType, targetType, 2, value => value is null ? null : System.Convert.ChangeType(value, targetActual, CultureInfo.InvariantCulture)); + return true; + } + + conversion = null; + return false; + } +} diff --git a/src/FluNet.Engine/Binding/SemanticBinder.cs b/src/FluNet.Engine/Binding/SemanticBinder.cs index 11568b2..bbc8d57 100644 --- a/src/FluNet.Engine/Binding/SemanticBinder.cs +++ b/src/FluNet.Engine/Binding/SemanticBinder.cs @@ -10,18 +10,22 @@ public sealed class SemanticBinder { private readonly LanguageSnapshot _language; private readonly ValueResolverRegistry _resolvers; + private readonly ValueConversionRegistry _conversions; - public SemanticBinder(LanguageSnapshot language, ValueResolverRegistry? resolvers = null) + public SemanticBinder(LanguageSnapshot language, ValueResolverRegistry? resolvers = null, ValueConversionRegistry? conversions = null) { _language = language; _resolvers = resolvers ?? new ValueResolverRegistry(); + _conversions = conversions ?? new ValueConversionRegistry(); } public BindingResult BindSentence(SentenceNode sentence, BindingContext? context = null) { context ??= new BindingContext(); - IReadOnlyList overloads = _language.GetVerbOverloads(sentence.Verb); - if (overloads.Count == 0) return Failure("FLU2001", $"Unknown verb '{sentence.Verb}'."); + IReadOnlyList overloads = _language.GetVerbOverloads(sentence.Verb) + .Where(x => QualifierMatches(sentence.Qualifier, x)) + .ToArray(); + if (overloads.Count == 0) return Failure("FLU2001", $"Unknown verb or qualifier combination '{sentence.Verb}{(sentence.Qualifier is null ? "" : " " + sentence.Qualifier)}'."); var candidates = new List(); foreach (VerbDescriptor overload in overloads) @@ -47,43 +51,51 @@ public BindingResult BindPipeline(PipelineNode pipeline, BindingC context ??= new BindingContext(); var bound = new List(); var diagnostics = new List(); - var variableTypes = context.VariableTypes != null - ? new Dictionary(context.VariableTypes, StringComparer.OrdinalIgnoreCase) - : new Dictionary(StringComparer.OrdinalIgnoreCase); + var variableTypes = context.VariableTypes != null ? new Dictionary(context.VariableTypes, StringComparer.OrdinalIgnoreCase) : new Dictionary(StringComparer.OrdinalIgnoreCase); Type? pipelineType = context.PipelineType; foreach (SentenceNode sentence in pipeline.Sentences) { - BindingContext sentenceContext = context with { PipelineType = pipelineType, VariableTypes = variableTypes }; - BindingResult result = BindSentence(sentence, sentenceContext); + BindingResult result = BindSentence(sentence, context with { PipelineType = pipelineType, VariableTypes = variableTypes }); diagnostics.AddRange(result.Diagnostics); if (!result.Success || result.Value == null) return new(null, diagnostics); - bound.Add(result.Value); pipelineType = result.Value.ResultType; - foreach (BoundRole role in result.Value.Roles.Where(x => x.Descriptor.Direction is RoleDirection.Output or RoleDirection.InputOutput)) - { foreach (BoundValue value in role.Values) if (value.Source is VariableExpression variable) variableTypes[variable.Name] = role.Descriptor.ValueType; - } } - return new(new BoundPipeline(bound, pipelineType), diagnostics); } + private bool QualifierMatches(string? qualifierText, VerbDescriptor verb) + { + if (qualifierText == null) return true; + if (!_language.TryGetQualifier(qualifierText, out QualifierDescriptor? qualifier) || qualifier == null) return false; + if (qualifier.ValueType == null) return true; + + IEnumerable candidateTypes = verb.Pattern.Clauses.Where(x => x.Kind == ClauseKind.What).Select(x => x.ValueType); + if (verb.ResultType != null) candidateTypes = candidateTypes.Prepend(verb.ResultType); + return candidateTypes.Any(type => TypeMatchesQualifier(type, qualifier.ValueType)); + } + + private static bool TypeMatchesQualifier(Type candidate, Type qualifier) + { + if (candidate == qualifier || qualifier.IsAssignableFrom(candidate) || candidate.IsAssignableFrom(qualifier)) return true; + if (candidate.IsArray && candidate.GetElementType() == qualifier) return true; + return false; + } + private BoundSentence? TryBindOverload(SentenceNode sentence, VerbDescriptor verb, BindingContext context) { var remaining = sentence.Clauses.GroupBy(x => x.Kind).ToDictionary(x => x.Key, x => new Queue(x)); var roles = new List(); int cost = 0; - foreach (ClauseDescriptor expected in verb.Pattern.Clauses) { int minimum = expected.Cardinality is RoleCardinality.One or RoleCardinality.OneOrMore ? 1 : 0; bool repeated = expected.Cardinality is RoleCardinality.ZeroOrMore or RoleCardinality.OneOrMore; var values = new List(); - if (remaining.TryGetValue(expected.Kind, out Queue? queue) && queue.Count > 0) { if (repeated && expected.ElementType != null) @@ -94,23 +106,19 @@ public BindingResult BindPipeline(PipelineNode pipeline, BindingC } else { - ClauseNode actual = queue.Dequeue(); - BoundValue? value = TryBindValue(actual.Value, expected, verb, context); + BoundValue? value = TryBindValue(queue.Dequeue().Value, expected, verb, context); if (value == null) return null; values.Add(value); cost += value.ConversionCost; } } - if (values.Count < minimum) { BoundValue? implicitPipeline = TryBindPipelineValue(expected, context); if (implicitPipeline != null) { values.Add(implicitPipeline); cost += implicitPipeline.ConversionCost; } } - if (values.Count < minimum) return null; roles.Add(new BoundRole(expected, values)); } - if (remaining.Values.Any(queue => queue.Count > 0)) return null; ConstructorDescriptor? constructor = verb.Constructors.FirstOrDefault(x => x.RoleParameterCount > 0) ?? verb.Constructors.FirstOrDefault(); return new BoundSentence(verb, constructor, roles, verb.ResultType, cost); @@ -144,18 +152,16 @@ public BindingResult BindPipeline(PipelineNode pipeline, BindingC return null; } - private static BoundValue? TryBindPipelineValue(ClauseDescriptor expected, BindingContext context) + private BoundValue? TryBindPipelineValue(ClauseDescriptor expected, BindingContext context) { if (expected.Direction == RoleDirection.Output || context.PipelineType == null) return null; return BindKnownType(new PipelineValueExpression(), context.PipelineType, expected.ValueType, null); } - private static BoundValue? BindKnownType(ExpressionNode source, Type? actualType, Type expectedType, object? value) + private BoundValue? BindKnownType(ExpressionNode source, Type? actualType, Type expectedType, object? value) { - if (actualType == null) return null; - if (expectedType == actualType) return new(source, expectedType, actualType, value, 0); - if (expectedType.IsAssignableFrom(actualType)) return new(source, expectedType, actualType, value, 1); - return null; + if (actualType == null || !_conversions.TryGet(actualType, expectedType, out ValueConversion? conversion) || conversion == null) return null; + return new(source, expectedType, actualType, value, conversion.Cost, conversion); } private static BindingResult Failure(string code, string message) => new(null, [Diagnostic.Error(code, message)]); diff --git a/src/FluNet.Engine/Binding/VerbActivator.cs b/src/FluNet.Engine/Binding/VerbActivator.cs index 17c1959..cc87220 100644 --- a/src/FluNet.Engine/Binding/VerbActivator.cs +++ b/src/FluNet.Engine/Binding/VerbActivator.cs @@ -4,156 +4,86 @@ namespace FluNET.Binding; -public sealed record ActivationContext( - IReadOnlyDictionary? Variables = null, - object? PipelineValue = null, - IServiceProvider? Services = null); +public sealed record ActivationContext(IReadOnlyDictionary? Variables = null, object? PipelineValue = null, IServiceProvider? Services = null); -/// -/// Materializes a bound verb through the constructor selected from reflection metadata. -/// Language-role parameters come from bound values; non-role parameters may come from DI. -/// public sealed class VerbActivator { public IVerb Create(BoundSentence sentence, ActivationContext? context = null) { context ??= new ActivationContext(); ConstructorDescriptor? constructor = sentence.Constructor; - if (constructor == null) { IVerb? fallback = sentence.Verb.Factory(); - return fallback ?? throw new InvalidOperationException( - $"Verb '{sentence.Verb.VerbType.FullName}' has no usable constructor or factory."); + return fallback ?? throw new InvalidOperationException($"Verb '{sentence.Verb.VerbType.FullName}' has no usable constructor or factory."); } var remainingRoles = sentence.Roles.ToList(); object?[] arguments = new object?[constructor.Parameters.Count]; - for (int i = 0; i < constructor.Parameters.Count; i++) { ParameterDescriptor parameter = constructor.Parameters[i]; - if (parameter.FromServices || parameter.Role == null) { object? service = context.Services?.GetService(parameter.ParameterType); - if (service != null) - { - arguments[i] = service; - continue; - } - + if (service != null) { arguments[i] = service; continue; } if (parameter.Role == null) { - if (parameter.Parameter.HasDefaultValue) - { - arguments[i] = parameter.Parameter.DefaultValue; - continue; - } - - if (parameter.IsOptional) - { - arguments[i] = DefaultValue(parameter.ParameterType); - continue; - } - - throw new InvalidOperationException( - $"Cannot resolve service parameter '{parameter.Name}' ({parameter.ParameterType.Name}) for '{sentence.Verb.Text}'."); + if (parameter.Parameter.HasDefaultValue) { arguments[i] = parameter.Parameter.DefaultValue; continue; } + if (parameter.IsOptional) { arguments[i] = DefaultValue(parameter.ParameterType); continue; } + throw new InvalidOperationException($"Cannot resolve service parameter '{parameter.Name}' ({parameter.ParameterType.Name}) for '{sentence.Verb.Text}'."); } } BoundRole? role = FindRole(parameter, remainingRoles); if (role == null) { - if (parameter.IsOptional) - { - arguments[i] = parameter.Parameter.HasDefaultValue - ? parameter.Parameter.DefaultValue - : DefaultValue(parameter.ParameterType); - continue; - } - - throw new InvalidOperationException( - $"Missing bound role '{parameter.Role}' for constructor parameter '{parameter.Name}'."); + if (parameter.IsOptional) { arguments[i] = parameter.Parameter.HasDefaultValue ? parameter.Parameter.DefaultValue : DefaultValue(parameter.ParameterType); continue; } + throw new InvalidOperationException($"Missing bound role '{parameter.Role}' for constructor parameter '{parameter.Name}'."); } - remainingRoles.Remove(role); arguments[i] = MaterializeRole(parameter, role, context); } object instance = constructor.Constructor.Invoke(arguments); - return instance as IVerb ?? throw new InvalidOperationException( - $"Constructed type '{instance.GetType().FullName}' is not an IVerb."); + return instance as IVerb ?? throw new InvalidOperationException($"Constructed type '{instance.GetType().FullName}' is not an IVerb."); } private static BoundRole? FindRole(ParameterDescriptor parameter, IReadOnlyList roles) { - BoundRole? named = roles.FirstOrDefault(x => - x.Descriptor.Kind == parameter.Role - && !string.IsNullOrWhiteSpace(x.Descriptor.Name) - && x.Descriptor.Name.Equals(parameter.Name, StringComparison.OrdinalIgnoreCase)); - + BoundRole? named = roles.FirstOrDefault(x => x.Descriptor.Kind == parameter.Role && !string.IsNullOrWhiteSpace(x.Descriptor.Name) && x.Descriptor.Name.Equals(parameter.Name, StringComparison.OrdinalIgnoreCase)); return named ?? roles.FirstOrDefault(x => x.Descriptor.Kind == parameter.Role); } private static object? MaterializeRole(ParameterDescriptor parameter, BoundRole role, ActivationContext context) { - if (role.Values.Count == 0) - return DefaultValue(parameter.ParameterType); - - if (role.Values.Count == 1) - return MaterializeValue(role.Values[0], parameter.ParameterType, role.Descriptor.Direction, context); - + if (role.Values.Count == 0) return DefaultValue(parameter.ParameterType); + if (role.Values.Count == 1) return MaterializeValue(role.Values[0], parameter.ParameterType, role.Descriptor.Direction, context); if (parameter.ParameterType.IsArray) { Type elementType = parameter.ParameterType.GetElementType()!; Array array = Array.CreateInstance(elementType, role.Values.Count); - for (int i = 0; i < role.Values.Count; i++) - array.SetValue(MaterializeValue(role.Values[i], elementType, role.Descriptor.Direction, context), i); + for (int i = 0; i < role.Values.Count; i++) array.SetValue(MaterializeValue(role.Values[i], elementType, role.Descriptor.Direction, context), i); return array; } - - throw new InvalidOperationException( - $"Role '{role.Descriptor.Kind}' produced multiple values for non-collection parameter '{parameter.Name}'."); + throw new InvalidOperationException($"Role '{role.Descriptor.Kind}' produced multiple values for non-collection parameter '{parameter.Name}'."); } - private static object? MaterializeValue( - BoundValue value, - Type targetType, - RoleDirection direction, - ActivationContext context) + private static object? MaterializeValue(BoundValue value, Type targetType, RoleDirection direction, ActivationContext context) { - if (value.ConstantValue != null) - return value.ConstantValue; - - switch (value.Source) + object? raw; + if (value.ConstantValue != null) raw = value.ConstantValue; + else raw = value.Source switch { - case VariableExpression variable when direction == RoleDirection.Output: - return DefaultValue(targetType); - - case VariableExpression variable: - if (context.Variables != null && context.Variables.TryGetValue(variable.Name, out object? variableValue)) - return variableValue; - throw new InvalidOperationException($"Variable '{variable.Name}' has no runtime value."); - - case PipelineValueExpression: - return context.PipelineValue; - - case InterpolatedStringExpression interpolated when targetType == typeof(string): - return interpolated.Template; - } - - return DefaultValue(targetType); + VariableExpression variable when direction == RoleDirection.Output => DefaultValue(targetType), + VariableExpression variable when context.Variables != null && context.Variables.TryGetValue(variable.Name, out object? variableValue) => variableValue, + VariableExpression variable => throw new InvalidOperationException($"Variable '{variable.Name}' has no runtime value."), + PipelineValueExpression => context.PipelineValue, + InterpolatedStringExpression interpolated when targetType == typeof(string) => interpolated.Template, + _ => DefaultValue(targetType) + }; + return value.Conversion?.Apply(raw) ?? raw; } - private static object? DefaultValue(Type type) - { - if (type.IsArray) - return Array.CreateInstance(type.GetElementType()!, 0); - - if (type.IsValueType) - return Activator.CreateInstance(type); - - return null; - } + private static object? DefaultValue(Type type) { if (type.IsArray) return Array.CreateInstance(type.GetElementType()!, 0); if (type.IsValueType) return Activator.CreateInstance(type); return null; } } diff --git a/src/FluNet.Engine/Compilation/ClassicCompiler.cs b/src/FluNet.Engine/Compilation/ClassicCompiler.cs index 1fc8889..35053fc 100644 --- a/src/FluNet.Engine/Compilation/ClassicCompiler.cs +++ b/src/FluNet.Engine/Compilation/ClassicCompiler.cs @@ -6,26 +6,20 @@ namespace FluNET.Compilation; -public sealed record ClassicCompilation( - ScriptNode? Syntax, - IReadOnlyList Pipelines, - IReadOnlyList Diagnostics) +public sealed record ClassicCompilation(ScriptNode? Syntax, IReadOnlyList Pipelines, IReadOnlyList Diagnostics) { public bool Success => Syntax != null && Diagnostics.All(x => x.Severity != DiagnosticSeverity.Error); } -/// -/// End-to-end frontend: source -> AST -> semantic binding. Execution stays a separate concern. -/// public sealed class ClassicCompiler { private readonly ClassicParser _parser; private readonly SemanticBinder _binder; - public ClassicCompiler(LanguageSnapshot language, ValueResolverRegistry? resolvers = null) + public ClassicCompiler(LanguageSnapshot language, ValueResolverRegistry? resolvers = null, ValueConversionRegistry? conversions = null) { _parser = new ClassicParser(language); - _binder = new SemanticBinder(language, resolvers); + _binder = new SemanticBinder(language, resolvers, conversions); } public ClassicCompilation Compile(string source, BindingContext? context = null) @@ -33,17 +27,13 @@ public ClassicCompilation Compile(string source, BindingContext? context = null) ParseResult parse = _parser.Parse(source); var diagnostics = new List(parse.Diagnostics); var pipelines = new List(); - - if (!parse.Success || parse.Script == null) - return new(parse.Script, pipelines, diagnostics); - + if (!parse.Success || parse.Script == null) return new(parse.Script, pipelines, diagnostics); foreach (PipelineNode pipeline in parse.Script.Pipelines) { BindingResult binding = _binder.BindPipeline(pipeline, context); diagnostics.AddRange(binding.Diagnostics); if (binding.Value != null) pipelines.Add(binding.Value); } - return new(parse.Script, pipelines, diagnostics); } } diff --git a/src/FluNet.Engine/Language/LanguageSnapshot.cs b/src/FluNet.Engine/Language/LanguageSnapshot.cs index 50dc75d..9d21abf 100644 --- a/src/FluNet.Engine/Language/LanguageSnapshot.cs +++ b/src/FluNet.Engine/Language/LanguageSnapshot.cs @@ -7,44 +7,25 @@ public sealed class LanguageSnapshot private readonly IReadOnlyDictionary _qualifiers; private readonly IReadOnlyList _modules; - public LanguageSnapshot( - IEnumerable words, - IEnumerable verbs, - IEnumerable qualifiers, - IEnumerable? modules = null) + public LanguageSnapshot(IEnumerable words, IEnumerable verbs, IEnumerable qualifiers, IEnumerable? modules = null) { var wordMap = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (WordDescriptor word in words) - { - wordMap[word.Text] = word; - foreach (string synonym in word.Synonyms) wordMap[synonym] = word; - } - + foreach (WordDescriptor word in words) { wordMap[word.Text] = word; foreach (string synonym in word.Synonyms) wordMap[synonym] = word; } var verbMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (VerbDescriptor verb in verbs) - { - AddVerb(verb.Text, verb); - foreach (string synonym in verb.Synonyms) AddVerb(synonym, verb); - } - + foreach (VerbDescriptor verb in verbs) { AddVerb(verb.Text, verb); foreach (string synonym in verb.Synonyms) AddVerb(synonym, verb); } _words = wordMap; _verbs = verbMap.ToDictionary(x => x.Key, x => (IReadOnlyList)x.Value.DistinctBy(v => v.VerbType).ToArray(), StringComparer.OrdinalIgnoreCase); _qualifiers = qualifiers.ToDictionary(x => x.Text, StringComparer.OrdinalIgnoreCase); - _modules = (modules ?? []).ToArray(); - - void AddVerb(string key, VerbDescriptor descriptor) - { - if (!verbMap.TryGetValue(key, out List? set)) verbMap[key] = set = []; - set.Add(descriptor); - } + _modules = (modules ?? Array.Empty()).ToArray(); + void AddVerb(string key, VerbDescriptor descriptor) { if (!verbMap.TryGetValue(key, out List? set)) verbMap[key] = set = []; set.Add(descriptor); } } public IReadOnlyCollection Words => _words.Values.DistinctBy(x => x.WordType).ToArray(); public IReadOnlyCollection Verbs => _verbs.Values.SelectMany(x => x).DistinctBy(x => x.VerbType).ToArray(); public IReadOnlyCollection Qualifiers => _qualifiers.Values.ToArray(); public IReadOnlyList Modules => _modules; - public bool TryGetWord(string text, out WordDescriptor? descriptor) => _words.TryGetValue(text, out descriptor); public IReadOnlyList GetVerbOverloads(string text) => _verbs.TryGetValue(text, out IReadOnlyList? descriptors) ? descriptors : []; public bool IsQualifier(string text) => _qualifiers.ContainsKey(text); + public bool TryGetQualifier(string text, out QualifierDescriptor? descriptor) => _qualifiers.TryGetValue(text, out descriptor); } diff --git a/tests/FluNET.Tests/ValueConversionRegistryTests.cs b/tests/FluNET.Tests/ValueConversionRegistryTests.cs new file mode 100644 index 0000000..ab2787d --- /dev/null +++ b/tests/FluNET.Tests/ValueConversionRegistryTests.cs @@ -0,0 +1,17 @@ +using FluNET.Binding; + +namespace FluNET.Tests; + +public class ValueConversionRegistryTests +{ + [Fact] + public void Numeric_conversion_has_higher_cost_than_exact_match() + { + var conversions = new ValueConversionRegistry(); + Assert.True(conversions.TryGet(typeof(int), typeof(int), out ValueConversion? exact)); + Assert.True(conversions.TryGet(typeof(int), typeof(long), out ValueConversion? numeric)); + Assert.Equal(0, exact!.Cost); + Assert.True(numeric!.Cost > exact.Cost); + Assert.Equal(42L, numeric.Apply(42)); + } +} From 5ee1da2e5072b37bb1638702885ab7f47360a3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:28:17 +0200 Subject: [PATCH 10/18] Compile multiple sentence patterns per verb --- src/FluNet.Engine/Binding/SemanticBinder.cs | 71 ++++++++++------- .../Language/LanguageCompiler.cs | 79 +++++++++++++------ .../Language/LanguageDescriptors.cs | 11 +-- .../Language/LanguageIntrospection.cs | 38 ++++----- tests/FluNET.Tests/LanguagePatternTests.cs | 36 +++++++++ 5 files changed, 160 insertions(+), 75 deletions(-) create mode 100644 tests/FluNET.Tests/LanguagePatternTests.cs diff --git a/src/FluNet.Engine/Binding/SemanticBinder.cs b/src/FluNet.Engine/Binding/SemanticBinder.cs index bbc8d57..0e84408 100644 --- a/src/FluNet.Engine/Binding/SemanticBinder.cs +++ b/src/FluNet.Engine/Binding/SemanticBinder.cs @@ -30,19 +30,30 @@ public BindingResult BindSentence(SentenceNode sentence, BindingC var candidates = new List(); foreach (VerbDescriptor overload in overloads) { - BoundSentence? candidate = TryBindOverload(sentence, overload, context); - if (candidate != null) candidates.Add(candidate); + IReadOnlyList patterns = overload.Patterns.Count > 0 + ? overload.Patterns + : [new VerbPatternDescriptor(overload.Pattern, overload.Constructors.FirstOrDefault())]; + + foreach (VerbPatternDescriptor pattern in patterns) + { + BoundSentence? candidate = TryBindPattern(sentence, overload, pattern, context); + if (candidate != null) candidates.Add(candidate); + } } if (candidates.Count == 0) { - string signatures = string.Join(", ", overloads.Select(FormatSignature)); + string signatures = string.Join(", ", overloads.SelectMany(FormatSignatures)); return Failure("FLU2101", $"No overload of '{sentence.Verb}' matches this sentence. Available: {signatures}."); } int bestCost = candidates.Min(x => x.BindingCost); BoundSentence[] best = candidates.Where(x => x.BindingCost == bestCost).ToArray(); - if (best.Length > 1) return Failure("FLU2102", $"Ambiguous '{sentence.Verb}' sentence. Matching overloads: {string.Join(", ", best.Select(x => FormatSignature(x.Verb)))}."); + if (best.Length > 1) + { + string matches = string.Join(", ", best.Select(x => FormatSignature(x.Verb.Text, x.Roles.Select(r => r.Descriptor)))); + return Failure("FLU2102", $"Ambiguous '{sentence.Verb}' sentence. Matching overloads: {matches}."); + } return new(best[0], []); } @@ -68,30 +79,14 @@ public BindingResult BindPipeline(PipelineNode pipeline, BindingC return new(new BoundPipeline(bound, pipelineType), diagnostics); } - private bool QualifierMatches(string? qualifierText, VerbDescriptor verb) - { - if (qualifierText == null) return true; - if (!_language.TryGetQualifier(qualifierText, out QualifierDescriptor? qualifier) || qualifier == null) return false; - if (qualifier.ValueType == null) return true; - - IEnumerable candidateTypes = verb.Pattern.Clauses.Where(x => x.Kind == ClauseKind.What).Select(x => x.ValueType); - if (verb.ResultType != null) candidateTypes = candidateTypes.Prepend(verb.ResultType); - return candidateTypes.Any(type => TypeMatchesQualifier(type, qualifier.ValueType)); - } - - private static bool TypeMatchesQualifier(Type candidate, Type qualifier) - { - if (candidate == qualifier || qualifier.IsAssignableFrom(candidate) || candidate.IsAssignableFrom(qualifier)) return true; - if (candidate.IsArray && candidate.GetElementType() == qualifier) return true; - return false; - } - - private BoundSentence? TryBindOverload(SentenceNode sentence, VerbDescriptor verb, BindingContext context) + private BoundSentence? TryBindPattern(SentenceNode sentence, VerbDescriptor verb, VerbPatternDescriptor patternDescriptor, BindingContext context) { + SentencePattern pattern = patternDescriptor.Pattern; var remaining = sentence.Clauses.GroupBy(x => x.Kind).ToDictionary(x => x.Key, x => new Queue(x)); var roles = new List(); int cost = 0; - foreach (ClauseDescriptor expected in verb.Pattern.Clauses) + + foreach (ClauseDescriptor expected in pattern.Clauses) { int minimum = expected.Cardinality is RoleCardinality.One or RoleCardinality.OneOrMore ? 1 : 0; bool repeated = expected.Cardinality is RoleCardinality.ZeroOrMore or RoleCardinality.OneOrMore; @@ -119,9 +114,26 @@ private static bool TypeMatchesQualifier(Type candidate, Type qualifier) if (values.Count < minimum) return null; roles.Add(new BoundRole(expected, values)); } + if (remaining.Values.Any(queue => queue.Count > 0)) return null; - ConstructorDescriptor? constructor = verb.Constructors.FirstOrDefault(x => x.RoleParameterCount > 0) ?? verb.Constructors.FirstOrDefault(); - return new BoundSentence(verb, constructor, roles, verb.ResultType, cost); + return new BoundSentence(verb, patternDescriptor.Constructor, roles, verb.ResultType, cost); + } + + private bool QualifierMatches(string? qualifierText, VerbDescriptor verb) + { + if (qualifierText == null) return true; + if (!_language.TryGetQualifier(qualifierText, out QualifierDescriptor? qualifier) || qualifier == null) return false; + if (qualifier.ValueType == null) return true; + IEnumerable candidateTypes = verb.Patterns.SelectMany(p => p.Pattern.Clauses).Where(x => x.Kind == ClauseKind.What).Select(x => x.ValueType); + if (!candidateTypes.Any()) candidateTypes = verb.Pattern.Clauses.Where(x => x.Kind == ClauseKind.What).Select(x => x.ValueType); + if (verb.ResultType != null) candidateTypes = candidateTypes.Prepend(verb.ResultType); + return candidateTypes.Any(type => TypeMatchesQualifier(type, qualifier.ValueType)); + } + + private static bool TypeMatchesQualifier(Type candidate, Type qualifier) + { + if (candidate == qualifier || qualifier.IsAssignableFrom(candidate) || candidate.IsAssignableFrom(qualifier)) return true; + return candidate.IsArray && candidate.GetElementType() == qualifier; } private BoundValue? TryBindRepeatedValues(Queue queue, ClauseDescriptor expected, VerbDescriptor verb, BindingContext context) @@ -165,6 +177,11 @@ private static bool TypeMatchesQualifier(Type candidate, Type qualifier) } private static BindingResult Failure(string code, string message) => new(null, [Diagnostic.Error(code, message)]); - private static string FormatSignature(VerbDescriptor verb) { string clauses = string.Join(" ", verb.Pattern.Clauses.Select(x => $"{x.Kind.ToString().ToUpperInvariant()}<{FriendlyName(x.ValueType)}>")); return string.IsNullOrEmpty(clauses) ? verb.Text : $"{verb.Text} {clauses}"; } + private static IEnumerable FormatSignatures(VerbDescriptor verb) + { + IReadOnlyList patterns = verb.Patterns.Count > 0 ? verb.Patterns : [new VerbPatternDescriptor(verb.Pattern, null)]; + return patterns.Select(x => FormatSignature(verb.Text, x.Pattern.Clauses)); + } + private static string FormatSignature(string verb, IEnumerable clauses) => $"{verb} {string.Join(" ", clauses.Select(x => $"{x.Kind.ToString().ToUpperInvariant()}<{FriendlyName(x.ValueType)}>"))}".TrimEnd(); private static string FriendlyName(Type type) => type.IsArray ? $"{FriendlyName(type.GetElementType()!)}[]" : type.Name; } diff --git a/src/FluNet.Engine/Language/LanguageCompiler.cs b/src/FluNet.Engine/Language/LanguageCompiler.cs index 3eba4de..6732725 100644 --- a/src/FluNet.Engine/Language/LanguageCompiler.cs +++ b/src/FluNet.Engine/Language/LanguageCompiler.cs @@ -27,10 +27,13 @@ public sealed class LanguageCompiler public VerbDescriptor DescribeVerb(Type verbType, string text, IReadOnlyList synonyms, Func factory) { IReadOnlyList constructors = DescribeConstructors(verbType); - SentencePattern pattern = BuildPattern(verbType, text, constructors); - return new VerbDescriptor(verbType, text, synonyms, pattern, factory) + IReadOnlyList patterns = BuildPatterns(verbType, text, constructors); + SentencePattern compatibilityPattern = patterns.FirstOrDefault()?.Pattern ?? BuildInterfacePattern(verbType, text); + + return new VerbDescriptor(verbType, text, synonyms, compatibilityPattern, factory) { Constructors = constructors, + Patterns = patterns, ResultType = InferResultType(verbType), FamilyType = InferFamilyType(verbType), Capabilities = verbType.GetCustomAttributes(true).Select(x => x.Capability).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), @@ -48,6 +51,55 @@ public IReadOnlyList DescribeConstructors(Type type) => t .Select(c => new ConstructorDescriptor(c, c.GetParameters().Select(p => DescribeParameter(type, p)).ToArray())) .OrderByDescending(x => x.RoleParameterCount).ThenBy(x => x.ServiceParameterCount).ToArray(); + private IReadOnlyList BuildPatterns(Type verbType, string text, IReadOnlyList constructors) + { + var patterns = new List(); + foreach (ConstructorDescriptor constructor in constructors.Where(x => x.RoleParameterCount > 0)) + { + ClauseDescriptor[] clauses = constructor.Parameters + .Where(x => x.Role != null) + .Select(ToClause) + .ToArray(); + if (clauses.Length > 0) + patterns.Add(new(new SentencePattern(text.ToUpperInvariant(), clauses), constructor)); + } + + if (patterns.Count == 0) + patterns.Add(new(BuildInterfacePattern(verbType, text), constructors.FirstOrDefault())); + + return patterns + .DistinctBy(x => PatternKey(x.Pattern), StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private static string PatternKey(SentencePattern pattern) => string.Join("|", pattern.Clauses.Select(x => + $"{x.Kind}:{x.Name}:{x.ValueType.FullName}:{x.Cardinality}:{x.Direction}")); + + private static ClauseDescriptor ToClause(ParameterDescriptor parameter) => new( + parameter.Role!.Value, + parameter.ParameterType, + !parameter.IsOptional, + parameter.Name, + parameter.Direction, + parameter.IsParams ? RoleCardinality.ZeroOrMore : (parameter.IsOptional ? RoleCardinality.ZeroOrOne : RoleCardinality.One), + parameter.Shape.ElementType); + + private SentencePattern BuildInterfacePattern(Type verbType, string text) + { + var fallback = new List(); + foreach (Type contract in verbType.GetInterfaces().Where(x => x.IsGenericType)) + { + Type definition = contract.GetGenericTypeDefinition(); + Type valueType = contract.GetGenericArguments()[0]; + ClauseKind? kind = RoleKindFor(definition); + if (kind == null) continue; + TypeShape shape = TypeShape.Analyze(valueType); + RoleDirection direction = kind == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; + fallback.Add(new(kind.Value, valueType, true, null, direction, RoleCardinality.One, shape.ElementType)); + } + return new SentencePattern(text.ToUpperInvariant(), fallback); + } + private ParameterDescriptor DescribeParameter(Type verbType, ParameterInfo parameter) { ClauseKind? role = InferRole(parameter); @@ -72,29 +124,6 @@ private static RoleDirection InferDirection(Type verbType, ParameterInfo paramet return role == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; } - private static SentencePattern BuildPattern(Type verbType, string text, IReadOnlyList constructors) - { - ConstructorDescriptor? constructor = constructors.FirstOrDefault(x => x.RoleParameterCount > 0); - if (constructor != null) - { - ClauseDescriptor[] clauses = constructor.Parameters.Where(x => x.Role != null).Select(x => new ClauseDescriptor(x.Role!.Value, x.ParameterType, !x.IsOptional, x.Name, x.Direction, x.IsParams ? RoleCardinality.ZeroOrMore : (x.IsOptional ? RoleCardinality.ZeroOrOne : RoleCardinality.One), x.Shape.ElementType)).ToArray(); - if (clauses.Length > 0) return new SentencePattern(text.ToUpperInvariant(), clauses); - } - - var fallback = new List(); - foreach (Type contract in verbType.GetInterfaces().Where(x => x.IsGenericType)) - { - Type definition = contract.GetGenericTypeDefinition(); - Type valueType = contract.GetGenericArguments()[0]; - ClauseKind? kind = RoleKindFor(definition); - if (kind == null) continue; - TypeShape shape = TypeShape.Analyze(valueType); - RoleDirection direction = kind == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; - fallback.Add(new(kind.Value, valueType, true, null, direction, RoleCardinality.One, shape.ElementType)); - } - return new SentencePattern(text.ToUpperInvariant(), fallback); - } - private static ClauseKind? RoleKindFor(Type d) => d == typeof(IWhat<>) ? ClauseKind.What : d == typeof(IFrom<>) ? ClauseKind.From : d == typeof(ITo<>) ? ClauseKind.To : d == typeof(IUsing<>) ? ClauseKind.Using : d == typeof(IWith<>) ? ClauseKind.With : d == typeof(IThen<>) ? ClauseKind.Then : null; private static Type? InferResultType(Type verbType) => verbType.GetInterfaces().FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<>))?.GetGenericArguments()[0]; diff --git a/src/FluNet.Engine/Language/LanguageDescriptors.cs b/src/FluNet.Engine/Language/LanguageDescriptors.cs index b1e2792..1b7d6bb 100644 --- a/src/FluNet.Engine/Language/LanguageDescriptors.cs +++ b/src/FluNet.Engine/Language/LanguageDescriptors.cs @@ -13,6 +13,10 @@ public sealed record ExecutionTraitsDescriptor( bool LongRunning, bool SideEffecting); +public sealed record VerbPatternDescriptor( + SentencePattern Pattern, + ConstructorDescriptor? Constructor); + public sealed record WordDescriptor(Type WordType, string Text, IReadOnlyList Synonyms, Func Factory) : ILanguageElement { public string StableId => $"word:{Text.ToLowerInvariant()}:{WordType.FullName}"; @@ -24,6 +28,7 @@ public sealed record VerbDescriptor(Type VerbType, string Text, IReadOnlyList $"verb:{Text.ToLowerInvariant()}:{VerbType.FullName}"; public string Name => Text; public IReadOnlyList Constructors { get; init; } = []; + public IReadOnlyList Patterns { get; init; } = []; public Type? ResultType { get; init; } public Type? FamilyType { get; init; } public IReadOnlyList Capabilities { get; init; } = []; @@ -36,11 +41,7 @@ public sealed record QualifierDescriptor(string Text, Type? ValueType = null) : public string Name => Text; } -public sealed record ModuleDescriptor( - string ModuleName, - Version Version, - Type ModuleType, - IReadOnlyList Dependencies) : ILanguageElement +public sealed record ModuleDescriptor(string ModuleName, Version Version, Type ModuleType, IReadOnlyList Dependencies) : ILanguageElement { public string StableId => $"module:{ModuleName.ToLowerInvariant()}"; public string Name => ModuleName; diff --git a/src/FluNet.Engine/Language/LanguageIntrospection.cs b/src/FluNet.Engine/Language/LanguageIntrospection.cs index b212eca..96f578e 100644 --- a/src/FluNet.Engine/Language/LanguageIntrospection.cs +++ b/src/FluNet.Engine/Language/LanguageIntrospection.cs @@ -2,10 +2,7 @@ namespace FluNET.Language; -public sealed record LanguageManifest( - IReadOnlyList Verbs, - IReadOnlyList Qualifiers, - IReadOnlyList Modules); +public sealed record LanguageManifest(IReadOnlyList Verbs, IReadOnlyList Qualifiers, IReadOnlyList Modules); public static class LanguageIntrospection { @@ -19,32 +16,37 @@ public static class LanguageIntrospection family = v.FamilyType?.FullName, capabilities = v.Capabilities, traits = v.Traits, - clauses = v.Pattern.Clauses.Select(c => new + patterns = (v.Patterns.Count > 0 ? v.Patterns.Select(x => x.Pattern) : [v.Pattern]).Select(pattern => new { - kind = c.Kind.ToString().ToUpperInvariant(), - name = c.Name, - valueType = c.ValueType.FullName, - elementType = c.ElementType?.FullName, - direction = c.Direction.ToString(), - cardinality = c.Cardinality.ToString(), - required = c.Required + clauses = pattern.Clauses.Select(c => new + { + kind = c.Kind.ToString().ToUpperInvariant(), + name = c.Name, + valueType = c.ValueType.FullName, + elementType = c.ElementType?.FullName, + direction = c.Direction.ToString(), + cardinality = c.Cardinality.ToString(), + required = c.Required + }).ToArray() }).ToArray() }).ToArray(), snapshot.Qualifiers.Select(q => (object)new { id = q.StableId, text = q.Text, valueType = q.ValueType?.FullName }).ToArray(), snapshot.Modules.Select(m => (object)new { id = m.StableId, name = m.ModuleName, version = m.Version.ToString(), dependencies = m.Dependencies.Select(x => x.FullName).ToArray() }).ToArray()); - public static string ToJson(LanguageSnapshot snapshot, bool indented = true) => - JsonSerializer.Serialize(CreateManifest(snapshot), new JsonSerializerOptions { WriteIndented = indented }); + public static string ToJson(LanguageSnapshot snapshot, bool indented = true) => JsonSerializer.Serialize(CreateManifest(snapshot), new JsonSerializerOptions { WriteIndented = indented }); public static string ExplainVerb(LanguageSnapshot snapshot, string keyword) { IReadOnlyList overloads = snapshot.GetVerbOverloads(keyword); if (overloads.Count == 0) return $"Unknown verb: {keyword}"; - - return string.Join(Environment.NewLine + Environment.NewLine, overloads.Select(v => + return string.Join(Environment.NewLine + Environment.NewLine, overloads.SelectMany(v => { - string signature = string.Join(" ", v.Pattern.Clauses.Select(c => $"{c.Kind.ToString().ToUpperInvariant()}<{c.ValueType.Name}>")); - return $"{v.Text} {signature}\nImplementation: {v.VerbType.FullName}\nResult: {v.ResultType?.FullName ?? "void/unknown"}\nCapabilities: {string.Join(", ", v.Capabilities)}"; + IEnumerable patterns = v.Patterns.Count > 0 ? v.Patterns.Select(x => x.Pattern) : [v.Pattern]; + return patterns.Select(pattern => + { + string signature = string.Join(" ", pattern.Clauses.Select(c => $"{c.Kind.ToString().ToUpperInvariant()}<{c.ValueType.Name}>")); + return $"{v.Text} {signature}\nImplementation: {v.VerbType.FullName}\nResult: {v.ResultType?.FullName ?? "void/unknown"}\nCapabilities: {string.Join(", ", v.Capabilities)}"; + }); })); } } diff --git a/tests/FluNET.Tests/LanguagePatternTests.cs b/tests/FluNET.Tests/LanguagePatternTests.cs new file mode 100644 index 0000000..2ec4865 --- /dev/null +++ b/tests/FluNET.Tests/LanguagePatternTests.cs @@ -0,0 +1,36 @@ +using FluNET.Language; +using FluNET.Language.Metadata; +using FluNET.Syntax.Core; + +namespace FluNET.Tests; + +public class LanguagePatternTests +{ + [Fact] + public void Compiler_creates_distinct_sentence_patterns_from_role_constructors() + { + var compiler = new LanguageCompiler(); + VerbDescriptor descriptor = compiler.DescribeVerb( + typeof(MultiPatternVerb), + "CUSTOM", + [], + () => null); + + Assert.Equal(2, descriptor.Patterns.Count); + Assert.Contains(descriptor.Patterns, x => x.Pattern.Clauses.Count == 1); + Assert.Contains(descriptor.Patterns, x => x.Pattern.Clauses.Count == 2); + } + + [Verb("CUSTOM")] + private sealed class MultiPatternVerb : IVerb + { + public MultiPatternVerb([What] string what) { } + public MultiPatternVerb([What] string what, [From] FileInfo from) { } + + public string Text => "CUSTOM"; + public IWord? Next { get; set; } + public IWord? Previous { get; set; } + public bool Validate(IWord word) => true; + public FluNET.Syntax.Validation.ValidationResult ValidateNext(IWord nextWord, FluNET.Lexicon.Lexicon lexicon) => FluNET.Syntax.Validation.ValidationResult.Success(); + } +} From 0bdfdf1a6f64b38b33348f2a60444a2c0c951963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:28:53 +0200 Subject: [PATCH 11/18] Add bound pipeline runtime and capability policy --- .../Execution/BoundPipelineExecutor.cs | 80 +++++++++++++++++++ .../Capabilities/ICapabilityPolicy.cs | 27 +++++++ .../Execution/ClassicScriptEngine.cs | 70 ++++++++++++++++ tests/FluNET.Tests/CapabilityPolicyTests.cs | 15 ++++ 4 files changed, 192 insertions(+) create mode 100644 src/FluNet.Engine/Execution/BoundPipelineExecutor.cs create mode 100644 src/FluNet.Engine/Execution/Capabilities/ICapabilityPolicy.cs create mode 100644 src/FluNet.Engine/Execution/ClassicScriptEngine.cs create mode 100644 tests/FluNET.Tests/CapabilityPolicyTests.cs diff --git a/src/FluNet.Engine/Execution/BoundPipelineExecutor.cs b/src/FluNet.Engine/Execution/BoundPipelineExecutor.cs new file mode 100644 index 0000000..892712b --- /dev/null +++ b/src/FluNet.Engine/Execution/BoundPipelineExecutor.cs @@ -0,0 +1,80 @@ +using FluNET.Binding; +using FluNET.Execution.Capabilities; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Core; + +namespace FluNET.Execution; + +public sealed record BoundPipelineExecutionResult( + object? Result, + IReadOnlyDictionary Variables, + IReadOnlyList Sentences); + +/// +/// Executes a semantically bound pipeline. Values flow through THEN implicitly and output +/// WHAT variables are populated from each sentence result for Classic compatibility. +/// +public sealed class BoundPipelineExecutor +{ + private readonly BoundSentenceExecutor _sentenceExecutor; + private readonly ICapabilityPolicy _capabilities; + + public BoundPipelineExecutor( + BoundSentenceExecutor? sentenceExecutor = null, + ICapabilityPolicy? capabilities = null) + { + _sentenceExecutor = sentenceExecutor ?? new BoundSentenceExecutor(); + _capabilities = capabilities ?? AllowAllCapabilityPolicy.Instance; + } + + public async ValueTask ExecuteAsync( + BoundPipeline pipeline, + IReadOnlyDictionary? initialVariables = null, + IServiceProvider? services = null, + CancellationToken cancellationToken = default) + { + var variables = initialVariables != null + ? new Dictionary(initialVariables, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + var executions = new List(); + object? pipelineValue = null; + + foreach (BoundSentence sentence in pipeline.Sentences) + { + EnsureCapabilities(sentence); + + var activation = new ActivationContext(variables, pipelineValue, services); + BoundExecutionResult execution = await _sentenceExecutor.ExecuteAsync(sentence, activation, cancellationToken); + executions.Add(execution); + pipelineValue = execution.Result; + StoreOutputBindings(sentence, execution.Result, variables); + } + + return new(pipelineValue, variables, executions); + } + + private void EnsureCapabilities(BoundSentence sentence) + { + foreach (string capability in sentence.Verb.Capabilities) + { + if (!_capabilities.IsAllowed(capability, sentence.Verb)) + throw new CapabilityDeniedException(capability, sentence.Verb); + } + } + + private static void StoreOutputBindings( + BoundSentence sentence, + object? result, + IDictionary variables) + { + foreach (BoundRole role in sentence.Roles.Where(x => + x.Descriptor.Direction is RoleDirection.Output or RoleDirection.InputOutput)) + { + foreach (BoundValue value in role.Values) + { + if (value.Source is VariableExpression variable) + variables[variable.Name] = result; + } + } + } +} diff --git a/src/FluNet.Engine/Execution/Capabilities/ICapabilityPolicy.cs b/src/FluNet.Engine/Execution/Capabilities/ICapabilityPolicy.cs new file mode 100644 index 0000000..e2280a3 --- /dev/null +++ b/src/FluNet.Engine/Execution/Capabilities/ICapabilityPolicy.cs @@ -0,0 +1,27 @@ +using FluNET.Language; + +namespace FluNET.Execution.Capabilities; + +public interface ICapabilityPolicy +{ + bool IsAllowed(string capability, VerbDescriptor verb); +} + +public sealed class AllowAllCapabilityPolicy : ICapabilityPolicy +{ + public static AllowAllCapabilityPolicy Instance { get; } = new(); + public bool IsAllowed(string capability, VerbDescriptor verb) => true; +} + +public sealed class ExplicitCapabilityPolicy(IEnumerable allowed) : ICapabilityPolicy +{ + private readonly HashSet _allowed = new(allowed, StringComparer.OrdinalIgnoreCase); + public bool IsAllowed(string capability, VerbDescriptor verb) => _allowed.Contains(capability); +} + +public sealed class CapabilityDeniedException(string capability, VerbDescriptor verb) + : InvalidOperationException($"Capability '{capability}' required by '{verb.Text}' is not allowed.") +{ + public string Capability { get; } = capability; + public VerbDescriptor Verb { get; } = verb; +} diff --git a/src/FluNet.Engine/Execution/ClassicScriptEngine.cs b/src/FluNet.Engine/Execution/ClassicScriptEngine.cs new file mode 100644 index 0000000..a8d4c39 --- /dev/null +++ b/src/FluNet.Engine/Execution/ClassicScriptEngine.cs @@ -0,0 +1,70 @@ +using FluNET.Binding; +using FluNET.Compilation; +using FluNET.Diagnostics; +using FluNET.Execution.Capabilities; +using FluNET.Language; + +namespace FluNET.Execution; + +public sealed record ClassicScriptResult( + ClassicCompilation Compilation, + IReadOnlyList Executions, + IReadOnlyDictionary Variables) +{ + public bool Success => Compilation.Success; + public object? Result => Executions.LastOrDefault()?.Result; +} + +/// +/// New opt-in FluNET.Classic execution path. It does not replace the legacy Engine yet; +/// it proves source -> AST -> binding -> capability check -> execution as one coherent API. +/// +public sealed class ClassicScriptEngine +{ + private readonly ClassicCompiler _compiler; + private readonly BoundPipelineExecutor _executor; + private readonly IServiceProvider? _services; + + public ClassicScriptEngine( + LanguageSnapshot language, + IServiceProvider? services = null, + ValueResolverRegistry? resolvers = null, + ValueConversionRegistry? conversions = null, + ICapabilityPolicy? capabilities = null) + { + _compiler = new ClassicCompiler(language, resolvers, conversions); + _executor = new BoundPipelineExecutor(capabilities: capabilities); + _services = services; + } + + public async ValueTask RunAsync( + string source, + IReadOnlyDictionary? variables = null, + CancellationToken cancellationToken = default) + { + var typeMap = variables?.Where(x => x.Value != null) + .ToDictionary(x => x.Key, x => x.Value!.GetType(), StringComparer.OrdinalIgnoreCase); + ClassicCompilation compilation = _compiler.Compile(source, new BindingContext(typeMap, Services: _services)); + if (!compilation.Success) + return new(compilation, [], variables ?? new Dictionary()); + + var runtimeVariables = variables != null + ? new Dictionary(variables, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + var executions = new List(); + + foreach (BoundPipeline pipeline in compilation.Pipelines) + { + BoundPipelineExecutionResult execution = await _executor.ExecuteAsync( + pipeline, + runtimeVariables, + _services, + cancellationToken); + executions.Add(execution); + foreach (KeyValuePair pair in execution.Variables) + runtimeVariables[pair.Key] = pair.Value; + } + + return new(compilation, executions, runtimeVariables); + } +} diff --git a/tests/FluNET.Tests/CapabilityPolicyTests.cs b/tests/FluNET.Tests/CapabilityPolicyTests.cs new file mode 100644 index 0000000..b0cd4c5 --- /dev/null +++ b/tests/FluNET.Tests/CapabilityPolicyTests.cs @@ -0,0 +1,15 @@ +using FluNET.Execution.Capabilities; +using FluNET.Language; + +namespace FluNET.Tests; + +public class CapabilityPolicyTests +{ + [Fact] + public void Explicit_policy_denies_unlisted_capability() + { + VerbDescriptor get = new LanguageRegistry().Snapshot.GetVerbOverloads("GET").First(); + var policy = new ExplicitCapabilityPolicy(["filesystem.write"]); + Assert.False(policy.IsAllowed("filesystem.read", get)); + } +} From e39488fb330a5a1fd0cc954544e8979df038ee78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:29:50 +0200 Subject: [PATCH 12/18] Compile constructor activators and language build diagnostics --- src/FluNet.Engine/Binding/VerbActivator.cs | 82 +++---------- .../Language/ConstructorActivatorCompiler.cs | 24 ++++ .../Language/LanguageBuildResult.cs | 10 ++ .../Language/LanguageCompiler.cs | 116 ++++-------------- .../Language/LanguageRegistry.cs | 46 +++---- .../Language/Metadata/Descriptors.cs | 5 +- tests/FluNET.Tests/LanguageBuildTests.cs | 14 +++ 7 files changed, 107 insertions(+), 190 deletions(-) create mode 100644 src/FluNet.Engine/Language/ConstructorActivatorCompiler.cs create mode 100644 src/FluNet.Engine/Language/LanguageBuildResult.cs create mode 100644 tests/FluNET.Tests/LanguageBuildTests.cs diff --git a/src/FluNet.Engine/Binding/VerbActivator.cs b/src/FluNet.Engine/Binding/VerbActivator.cs index cc87220..f9ae5cc 100644 --- a/src/FluNet.Engine/Binding/VerbActivator.cs +++ b/src/FluNet.Engine/Binding/VerbActivator.cs @@ -10,80 +10,26 @@ public sealed class VerbActivator { public IVerb Create(BoundSentence sentence, ActivationContext? context = null) { - context ??= new ActivationContext(); - ConstructorDescriptor? constructor = sentence.Constructor; - if (constructor == null) - { - IVerb? fallback = sentence.Verb.Factory(); - return fallback ?? throw new InvalidOperationException($"Verb '{sentence.Verb.VerbType.FullName}' has no usable constructor or factory."); - } - - var remainingRoles = sentence.Roles.ToList(); - object?[] arguments = new object?[constructor.Parameters.Count]; + context ??= new ActivationContext(); ConstructorDescriptor? constructor = sentence.Constructor; + if (constructor == null) { IVerb? fallback = sentence.Verb.Factory(); return fallback ?? throw new InvalidOperationException($"Verb '{sentence.Verb.VerbType.FullName}' has no usable constructor or factory."); } + var remainingRoles = sentence.Roles.ToList(); object?[] arguments = new object?[constructor.Parameters.Count]; for (int i = 0; i < constructor.Parameters.Count; i++) { - ParameterDescriptor parameter = constructor.Parameters[i]; - if (parameter.FromServices || parameter.Role == null) - { - object? service = context.Services?.GetService(parameter.ParameterType); - if (service != null) { arguments[i] = service; continue; } - if (parameter.Role == null) - { - if (parameter.Parameter.HasDefaultValue) { arguments[i] = parameter.Parameter.DefaultValue; continue; } - if (parameter.IsOptional) { arguments[i] = DefaultValue(parameter.ParameterType); continue; } - throw new InvalidOperationException($"Cannot resolve service parameter '{parameter.Name}' ({parameter.ParameterType.Name}) for '{sentence.Verb.Text}'."); - } - } - - BoundRole? role = FindRole(parameter, remainingRoles); - if (role == null) + ParameterDescriptor p = constructor.Parameters[i]; + if (p.FromServices || p.Role == null) { - if (parameter.IsOptional) { arguments[i] = parameter.Parameter.HasDefaultValue ? parameter.Parameter.DefaultValue : DefaultValue(parameter.ParameterType); continue; } - throw new InvalidOperationException($"Missing bound role '{parameter.Role}' for constructor parameter '{parameter.Name}'."); + object? service = context.Services?.GetService(p.ParameterType); if (service != null) { arguments[i] = service; continue; } + if (p.Role == null) { if (p.Parameter.HasDefaultValue) { arguments[i] = p.Parameter.DefaultValue; continue; } if (p.IsOptional) { arguments[i] = DefaultValue(p.ParameterType); continue; } throw new InvalidOperationException($"Cannot resolve service parameter '{p.Name}' ({p.ParameterType.Name}) for '{sentence.Verb.Text}'."); } } - remainingRoles.Remove(role); - arguments[i] = MaterializeRole(parameter, role, context); + BoundRole? role = FindRole(p, remainingRoles); if (role == null) { if (p.IsOptional) { arguments[i] = p.Parameter.HasDefaultValue ? p.Parameter.DefaultValue : DefaultValue(p.ParameterType); continue; } throw new InvalidOperationException($"Missing bound role '{p.Role}' for constructor parameter '{p.Name}'."); } + remainingRoles.Remove(role); arguments[i] = MaterializeRole(p, role, context); } - - object instance = constructor.Constructor.Invoke(arguments); + object instance = constructor.Activator(arguments); return instance as IVerb ?? throw new InvalidOperationException($"Constructed type '{instance.GetType().FullName}' is not an IVerb."); } - private static BoundRole? FindRole(ParameterDescriptor parameter, IReadOnlyList roles) - { - BoundRole? named = roles.FirstOrDefault(x => x.Descriptor.Kind == parameter.Role && !string.IsNullOrWhiteSpace(x.Descriptor.Name) && x.Descriptor.Name.Equals(parameter.Name, StringComparison.OrdinalIgnoreCase)); - return named ?? roles.FirstOrDefault(x => x.Descriptor.Kind == parameter.Role); - } - - private static object? MaterializeRole(ParameterDescriptor parameter, BoundRole role, ActivationContext context) - { - if (role.Values.Count == 0) return DefaultValue(parameter.ParameterType); - if (role.Values.Count == 1) return MaterializeValue(role.Values[0], parameter.ParameterType, role.Descriptor.Direction, context); - if (parameter.ParameterType.IsArray) - { - Type elementType = parameter.ParameterType.GetElementType()!; - Array array = Array.CreateInstance(elementType, role.Values.Count); - for (int i = 0; i < role.Values.Count; i++) array.SetValue(MaterializeValue(role.Values[i], elementType, role.Descriptor.Direction, context), i); - return array; - } - throw new InvalidOperationException($"Role '{role.Descriptor.Kind}' produced multiple values for non-collection parameter '{parameter.Name}'."); - } - - private static object? MaterializeValue(BoundValue value, Type targetType, RoleDirection direction, ActivationContext context) - { - object? raw; - if (value.ConstantValue != null) raw = value.ConstantValue; - else raw = value.Source switch - { - VariableExpression variable when direction == RoleDirection.Output => DefaultValue(targetType), - VariableExpression variable when context.Variables != null && context.Variables.TryGetValue(variable.Name, out object? variableValue) => variableValue, - VariableExpression variable => throw new InvalidOperationException($"Variable '{variable.Name}' has no runtime value."), - PipelineValueExpression => context.PipelineValue, - InterpolatedStringExpression interpolated when targetType == typeof(string) => interpolated.Template, - _ => DefaultValue(targetType) - }; - return value.Conversion?.Apply(raw) ?? raw; - } - - private static object? DefaultValue(Type type) { if (type.IsArray) return Array.CreateInstance(type.GetElementType()!, 0); if (type.IsValueType) return Activator.CreateInstance(type); return null; } + private static BoundRole? FindRole(ParameterDescriptor p, IReadOnlyList roles) { BoundRole? named = roles.FirstOrDefault(x => x.Descriptor.Kind == p.Role && !string.IsNullOrWhiteSpace(x.Descriptor.Name) && x.Descriptor.Name.Equals(p.Name, StringComparison.OrdinalIgnoreCase)); return named ?? roles.FirstOrDefault(x => x.Descriptor.Kind == p.Role); } + private static object? MaterializeRole(ParameterDescriptor p, BoundRole r, ActivationContext c) { if (r.Values.Count == 0) return DefaultValue(p.ParameterType); if (r.Values.Count == 1) return MaterializeValue(r.Values[0], p.ParameterType, r.Descriptor.Direction, c); if (p.ParameterType.IsArray) { Type e = p.ParameterType.GetElementType()!; Array a = Array.CreateInstance(e, r.Values.Count); for (int i = 0; i < r.Values.Count; i++) a.SetValue(MaterializeValue(r.Values[i], e, r.Descriptor.Direction, c), i); return a; } throw new InvalidOperationException($"Role '{r.Descriptor.Kind}' produced multiple values for non-collection parameter '{p.Name}'."); } + private static object? MaterializeValue(BoundValue v, Type t, RoleDirection d, ActivationContext c) { object? raw; if (v.ConstantValue != null) raw = v.ConstantValue; else raw = v.Source switch { VariableExpression variable when d == RoleDirection.Output => DefaultValue(t), VariableExpression variable when c.Variables != null && c.Variables.TryGetValue(variable.Name, out object? vv) => vv, VariableExpression variable => throw new InvalidOperationException($"Variable '{variable.Name}' has no runtime value."), PipelineValueExpression => c.PipelineValue, InterpolatedStringExpression s when t == typeof(string) => s.Template, _ => DefaultValue(t) }; return v.Conversion?.Apply(raw) ?? raw; } + private static object? DefaultValue(Type t) { if (t.IsArray) return Array.CreateInstance(t.GetElementType()!, 0); if (t.IsValueType) return Activator.CreateInstance(t); return null; } } diff --git a/src/FluNet.Engine/Language/ConstructorActivatorCompiler.cs b/src/FluNet.Engine/Language/ConstructorActivatorCompiler.cs new file mode 100644 index 0000000..1dd5bcc --- /dev/null +++ b/src/FluNet.Engine/Language/ConstructorActivatorCompiler.cs @@ -0,0 +1,24 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace FluNET.Language; + +internal static class ConstructorActivatorCompiler +{ + public static Func Compile(ConstructorInfo constructor) + { + ParameterExpression arguments = Expression.Parameter(typeof(object[]), "arguments"); + ParameterInfo[] parameters = constructor.GetParameters(); + Expression[] converted = new Expression[parameters.Length]; + + for (int i = 0; i < parameters.Length; i++) + { + BinaryExpression index = Expression.ArrayIndex(arguments, Expression.Constant(i)); + converted[i] = Expression.Convert(index, parameters[i].ParameterType); + } + + NewExpression create = Expression.New(constructor, converted); + UnaryExpression box = Expression.Convert(create, typeof(object)); + return Expression.Lambda>(box, arguments).Compile(); + } +} diff --git a/src/FluNet.Engine/Language/LanguageBuildResult.cs b/src/FluNet.Engine/Language/LanguageBuildResult.cs new file mode 100644 index 0000000..9b52f82 --- /dev/null +++ b/src/FluNet.Engine/Language/LanguageBuildResult.cs @@ -0,0 +1,10 @@ +using FluNET.Diagnostics; + +namespace FluNET.Language; + +public sealed record LanguageBuildResult( + LanguageSnapshot Snapshot, + IReadOnlyList Diagnostics) +{ + public bool Success => Diagnostics.All(x => x.Severity != DiagnosticSeverity.Error); +} diff --git a/src/FluNet.Engine/Language/LanguageCompiler.cs b/src/FluNet.Engine/Language/LanguageCompiler.cs index 6732725..1c8952d 100644 --- a/src/FluNet.Engine/Language/LanguageCompiler.cs +++ b/src/FluNet.Engine/Language/LanguageCompiler.cs @@ -14,13 +14,7 @@ public sealed class LanguageCompiler VerbAttribute? explicitVerb = verbType.GetCustomAttribute(true); string? text = explicitVerb?.Text ?? InferFamilyKeyword(verbType) ?? prototype?.Text; if (string.IsNullOrWhiteSpace(text)) return null; - - string[] synonyms = verbType.GetCustomAttributes(true) - .Select(x => x.Value) - .Concat(prototype?.Synonyms ?? []) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + string[] synonyms = verbType.GetCustomAttributes(true).Select(x => x.Value).Concat(prototype?.Synonyms ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); return new VerbIdentity(text.ToUpperInvariant(), synonyms); } @@ -29,7 +23,6 @@ public VerbDescriptor DescribeVerb(Type verbType, string text, IReadOnlyList constructors = DescribeConstructors(verbType); IReadOnlyList patterns = BuildPatterns(verbType, text, constructors); SentencePattern compatibilityPattern = patterns.FirstOrDefault()?.Pattern ?? BuildInterfacePattern(verbType, text); - return new VerbDescriptor(verbType, text, synonyms, compatibilityPattern, factory) { Constructors = constructors, @@ -37,18 +30,12 @@ public VerbDescriptor DescribeVerb(Type verbType, string text, IReadOnlyList(true).Select(x => x.Capability).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), - Traits = new( - typeof(IPureOperation).IsAssignableFrom(verbType), - typeof(IIdempotentOperation).IsAssignableFrom(verbType), - typeof(IRetryableOperation).IsAssignableFrom(verbType), - typeof(ITransactionalOperation).IsAssignableFrom(verbType), - typeof(ILongRunningOperation).IsAssignableFrom(verbType), - typeof(ISideEffectingOperation).IsAssignableFrom(verbType)) + Traits = new(typeof(IPureOperation).IsAssignableFrom(verbType), typeof(IIdempotentOperation).IsAssignableFrom(verbType), typeof(IRetryableOperation).IsAssignableFrom(verbType), typeof(ITransactionalOperation).IsAssignableFrom(verbType), typeof(ILongRunningOperation).IsAssignableFrom(verbType), typeof(ISideEffectingOperation).IsAssignableFrom(verbType)) }; } public IReadOnlyList DescribeConstructors(Type type) => type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) - .Select(c => new ConstructorDescriptor(c, c.GetParameters().Select(p => DescribeParameter(type, p)).ToArray())) + .Select(c => new ConstructorDescriptor(c, c.GetParameters().Select(p => DescribeParameter(type, p)).ToArray(), ConstructorActivatorCompiler.Compile(c))) .OrderByDescending(x => x.RoleParameterCount).ThenBy(x => x.ServiceParameterCount).ToArray(); private IReadOnlyList BuildPatterns(Type verbType, string text, IReadOnlyList constructors) @@ -56,103 +43,52 @@ private IReadOnlyList BuildPatterns(Type verbType, string var patterns = new List(); foreach (ConstructorDescriptor constructor in constructors.Where(x => x.RoleParameterCount > 0)) { - ClauseDescriptor[] clauses = constructor.Parameters - .Where(x => x.Role != null) - .Select(ToClause) - .ToArray(); - if (clauses.Length > 0) - patterns.Add(new(new SentencePattern(text.ToUpperInvariant(), clauses), constructor)); + ClauseDescriptor[] clauses = constructor.Parameters.Where(x => x.Role != null).Select(ToClause).ToArray(); + if (clauses.Length > 0) patterns.Add(new(new SentencePattern(text.ToUpperInvariant(), clauses), constructor)); } - - if (patterns.Count == 0) - patterns.Add(new(BuildInterfacePattern(verbType, text), constructors.FirstOrDefault())); - - return patterns - .DistinctBy(x => PatternKey(x.Pattern), StringComparer.OrdinalIgnoreCase) - .ToArray(); + if (patterns.Count == 0) patterns.Add(new(BuildInterfacePattern(verbType, text), constructors.FirstOrDefault())); + return patterns.DistinctBy(x => PatternKey(x.Pattern), StringComparer.OrdinalIgnoreCase).ToArray(); } - private static string PatternKey(SentencePattern pattern) => string.Join("|", pattern.Clauses.Select(x => - $"{x.Kind}:{x.Name}:{x.ValueType.FullName}:{x.Cardinality}:{x.Direction}")); - - private static ClauseDescriptor ToClause(ParameterDescriptor parameter) => new( - parameter.Role!.Value, - parameter.ParameterType, - !parameter.IsOptional, - parameter.Name, - parameter.Direction, - parameter.IsParams ? RoleCardinality.ZeroOrMore : (parameter.IsOptional ? RoleCardinality.ZeroOrOne : RoleCardinality.One), - parameter.Shape.ElementType); + private static string PatternKey(SentencePattern pattern) => string.Join("|", pattern.Clauses.Select(x => $"{x.Kind}:{x.Name}:{x.ValueType.FullName}:{x.Cardinality}:{x.Direction}")); + private static ClauseDescriptor ToClause(ParameterDescriptor p) => new(p.Role!.Value, p.ParameterType, !p.IsOptional, p.Name, p.Direction, p.IsParams ? RoleCardinality.ZeroOrMore : (p.IsOptional ? RoleCardinality.ZeroOrOne : RoleCardinality.One), p.Shape.ElementType); private SentencePattern BuildInterfacePattern(Type verbType, string text) { var fallback = new List(); foreach (Type contract in verbType.GetInterfaces().Where(x => x.IsGenericType)) { - Type definition = contract.GetGenericTypeDefinition(); - Type valueType = contract.GetGenericArguments()[0]; - ClauseKind? kind = RoleKindFor(definition); - if (kind == null) continue; - TypeShape shape = TypeShape.Analyze(valueType); - RoleDirection direction = kind == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; + Type definition = contract.GetGenericTypeDefinition(); Type valueType = contract.GetGenericArguments()[0]; ClauseKind? kind = RoleKindFor(definition); if (kind == null) continue; + TypeShape shape = TypeShape.Analyze(valueType); RoleDirection direction = kind == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; fallback.Add(new(kind.Value, valueType, true, null, direction, RoleCardinality.One, shape.ElementType)); } return new SentencePattern(text.ToUpperInvariant(), fallback); } - private ParameterDescriptor DescribeParameter(Type verbType, ParameterInfo parameter) - { - ClauseKind? role = InferRole(parameter); - NullabilityInfo nullability = _nullability.Create(parameter); - bool isParams = parameter.GetCustomAttribute() != null; - bool optional = parameter.IsOptional || parameter.HasDefaultValue || parameter.GetCustomAttribute() != null || nullability.ReadState == NullabilityState.Nullable; - return new(parameter, parameter.Name ?? $"arg{parameter.Position}", parameter.ParameterType, role, InferDirection(verbType, parameter, role), optional, isParams, parameter.GetCustomAttribute() != null, nullability.ReadState, nullability.WriteState, TypeShape.Analyze(parameter.ParameterType)); - } - - private static ClauseKind? InferRole(ParameterInfo parameter) + private ParameterDescriptor DescribeParameter(Type verbType, ParameterInfo p) { - RoleAttribute? explicitRole = parameter.GetCustomAttribute(); - if (explicitRole != null) return explicitRole.Kind; - return parameter.Name?.ToLowerInvariant() switch { "what" => ClauseKind.What, "from" => ClauseKind.From, "to" => ClauseKind.To, "using" => ClauseKind.Using, "with" => ClauseKind.With, "then" => ClauseKind.Then, _ => null }; - } - - private static RoleDirection InferDirection(Type verbType, ParameterInfo parameter, ClauseKind? role) - { - if (parameter.GetCustomAttribute() != null) return RoleDirection.Output; - if (parameter.GetCustomAttribute() != null) return RoleDirection.InputOutput; - if (parameter.GetCustomAttribute() != null) return RoleDirection.Input; - return role == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; + ClauseKind? role = InferRole(p); NullabilityInfo n = _nullability.Create(p); bool isParams = p.GetCustomAttribute() != null; + bool optional = p.IsOptional || p.HasDefaultValue || p.GetCustomAttribute() != null || n.ReadState == NullabilityState.Nullable; + return new(p, p.Name ?? $"arg{p.Position}", p.ParameterType, role, InferDirection(verbType, p, role), optional, isParams, p.GetCustomAttribute() != null, n.ReadState, n.WriteState, TypeShape.Analyze(p.ParameterType)); } + private static ClauseKind? InferRole(ParameterInfo p) { RoleAttribute? a = p.GetCustomAttribute(); if (a != null) return a.Kind; return p.Name?.ToLowerInvariant() switch { "what" => ClauseKind.What, "from" => ClauseKind.From, "to" => ClauseKind.To, "using" => ClauseKind.Using, "with" => ClauseKind.With, "then" => ClauseKind.Then, _ => null }; } + private static RoleDirection InferDirection(Type t, ParameterInfo p, ClauseKind? r) { if (p.GetCustomAttribute() != null) return RoleDirection.Output; if (p.GetCustomAttribute() != null) return RoleDirection.InputOutput; if (p.GetCustomAttribute() != null) return RoleDirection.Input; return r == ClauseKind.What && IsFamily(t, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; } private static ClauseKind? RoleKindFor(Type d) => d == typeof(IWhat<>) ? ClauseKind.What : d == typeof(IFrom<>) ? ClauseKind.From : d == typeof(ITo<>) ? ClauseKind.To : d == typeof(IUsing<>) ? ClauseKind.Using : d == typeof(IWith<>) ? ClauseKind.With : d == typeof(IThen<>) ? ClauseKind.Then : null; + private static Type? InferResultType(Type t) => t.GetInterfaces().FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<>))?.GetGenericArguments()[0]; - private static Type? InferResultType(Type verbType) => verbType.GetInterfaces().FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<>))?.GetGenericArguments()[0]; - - private static Type? InferFamilyType(Type verbType) + private static Type? InferFamilyType(Type t) { - Type[] families = [typeof(IGet), typeof(ISave), typeof(ILoad), typeof(ISend), typeof(IDelete), typeof(IDownload), typeof(IPost), typeof(ITransform), typeof(ISay)]; - Type? marker = families.FirstOrDefault(x => x.IsAssignableFrom(verbType)); - if (marker != null) return marker; - Type? current = verbType.BaseType; - while (current != null && current != typeof(object)) { Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; if (KnownFamilyKeyword(candidate.Name) != null) return candidate; current = current.BaseType; } - return null; + Type[] f = [typeof(IGet), typeof(ISave), typeof(ILoad), typeof(ISend), typeof(IDelete), typeof(IDownload), typeof(IPost), typeof(ITransform), typeof(ISay)]; Type? marker = f.FirstOrDefault(x => x.IsAssignableFrom(t)); if (marker != null) return marker; + Type? c = t.BaseType; while (c != null && c != typeof(object)) { Type candidate = c.IsGenericType ? c.GetGenericTypeDefinition() : c; if (KnownFamilyKeyword(candidate.Name) != null) return candidate; c = c.BaseType; } return null; } - private static string? InferFamilyKeyword(Type verbType) + private static string? InferFamilyKeyword(Type t) { - if (typeof(IGet).IsAssignableFrom(verbType)) return "GET"; if (typeof(ISave).IsAssignableFrom(verbType)) return "SAVE"; if (typeof(ILoad).IsAssignableFrom(verbType)) return "LOAD"; if (typeof(ISend).IsAssignableFrom(verbType)) return "SEND"; if (typeof(IDelete).IsAssignableFrom(verbType)) return "DELETE"; if (typeof(IDownload).IsAssignableFrom(verbType)) return "DOWNLOAD"; if (typeof(IPost).IsAssignableFrom(verbType)) return "POST"; if (typeof(ITransform).IsAssignableFrom(verbType)) return "TRANSFORM"; if (typeof(ISay).IsAssignableFrom(verbType)) return "SAY"; - Type? current = verbType.BaseType; - while (current != null && current != typeof(object)) { Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; string? keyword = KnownFamilyKeyword(candidate.Name); if (keyword != null) return keyword; current = current.BaseType; } - return null; + if (typeof(IGet).IsAssignableFrom(t)) return "GET"; if (typeof(ISave).IsAssignableFrom(t)) return "SAVE"; if (typeof(ILoad).IsAssignableFrom(t)) return "LOAD"; if (typeof(ISend).IsAssignableFrom(t)) return "SEND"; if (typeof(IDelete).IsAssignableFrom(t)) return "DELETE"; if (typeof(IDownload).IsAssignableFrom(t)) return "DOWNLOAD"; if (typeof(IPost).IsAssignableFrom(t)) return "POST"; if (typeof(ITransform).IsAssignableFrom(t)) return "TRANSFORM"; if (typeof(ISay).IsAssignableFrom(t)) return "SAY"; + Type? c = t.BaseType; while (c != null && c != typeof(object)) { Type candidate = c.IsGenericType ? c.GetGenericTypeDefinition() : c; string? k = KnownFamilyKeyword(candidate.Name); if (k != null) return k; c = c.BaseType; } return null; } - private static string? KnownFamilyKeyword(string typeName) => typeName.Split('`')[0].ToUpperInvariant() switch { "GET" => "GET", "SAVE" => "SAVE", "LOAD" => "LOAD", "SEND" => "SEND", "DELETE" => "DELETE", "DOWNLOAD" => "DOWNLOAD", "POST" => "POST", "TRANSFORM" => "TRANSFORM", "SAY" => "SAY", _ => null }; - - private static bool IsFamily(Type verbType, Type marker, string legacyBaseName) - { - if (marker.IsAssignableFrom(verbType)) return true; - Type? current = verbType.BaseType; - while (current != null && current != typeof(object)) { Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; if (candidate.Name.Split('`')[0].Equals(legacyBaseName, StringComparison.OrdinalIgnoreCase)) return true; current = current.BaseType; } - return false; - } + private static string? KnownFamilyKeyword(string n) => n.Split('`')[0].ToUpperInvariant() switch { "GET" => "GET", "SAVE" => "SAVE", "LOAD" => "LOAD", "SEND" => "SEND", "DELETE" => "DELETE", "DOWNLOAD" => "DOWNLOAD", "POST" => "POST", "TRANSFORM" => "TRANSFORM", "SAY" => "SAY", _ => null }; + private static bool IsFamily(Type t, Type marker, string legacy) { if (marker.IsAssignableFrom(t)) return true; Type? c = t.BaseType; while (c != null && c != typeof(object)) { Type candidate = c.IsGenericType ? c.GetGenericTypeDefinition() : c; if (candidate.Name.Split('`')[0].Equals(legacy, StringComparison.OrdinalIgnoreCase)) return true; c = c.BaseType; } return false; } } diff --git a/src/FluNet.Engine/Language/LanguageRegistry.cs b/src/FluNet.Engine/Language/LanguageRegistry.cs index 231902f..a622421 100644 --- a/src/FluNet.Engine/Language/LanguageRegistry.cs +++ b/src/FluNet.Engine/Language/LanguageRegistry.cs @@ -1,4 +1,5 @@ using FluNET.Keywords; +using FluNET.Language.Metadata; using FluNET.Syntax.Core; using System.Reflection; @@ -14,30 +15,27 @@ public sealed class LanguageRegistry private readonly LanguageCompiler _compiler = new(); public LanguageRegistry() { RegisterStandardQualifiers(); RegisterAssemblies(AppDomain.CurrentDomain.GetAssemblies()); } - public IReadOnlyCollection Words => _words.Values.DistinctBy(x => x.WordType).ToArray(); public IReadOnlyCollection Verbs => _verbs.Values.SelectMany(x => x).DistinctBy(x => x.VerbType).ToArray(); public IReadOnlyCollection Qualifiers => _qualifiers.Values.ToArray(); public IReadOnlyList Modules => _modules; public LanguageSnapshot Snapshot => new(Words, Verbs, Qualifiers, Modules); + public LanguageBuildResult Build() { LanguageSnapshot snapshot = Snapshot; return new(snapshot, LanguageValidator.Validate(snapshot)); } - public void RegisterModule(IFluNetModule module) - { - ArgumentNullException.ThrowIfNull(module); - if (_modules.All(x => x.ModuleType != module.GetType())) - _modules.Add(new(module.Name, module.Version, module.GetType(), module.Dependencies.ToArray())); - module.Configure(this); - RegisterAssemblies([module.GetType().Assembly]); - } + public void RegisterModule(IFluNetModule module) { ArgumentNullException.ThrowIfNull(module); if (_modules.All(x => x.ModuleType != module.GetType())) _modules.Add(new(module.Name, module.Version, module.GetType(), module.Dependencies.ToArray())); module.Configure(this); RegisterAssemblies([module.GetType().Assembly]); } public void RegisterAssemblies(IEnumerable assemblies) { foreach (Assembly assembly in assemblies) { if (!_assemblies.Add(assembly)) continue; - Type[] types; - try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(x => x != null).Cast().ToArray(); } - foreach (Type type in types.Where(x => typeof(IWord).IsAssignableFrom(x) && !x.IsAbstract && !x.IsInterface)) RegisterWord(type); + Type[] types; try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(x => x != null).Cast().ToArray(); } + foreach (Type type in types) + { + QualifierAttribute? qualifier = type.GetCustomAttribute(true); + if (qualifier != null) RegisterQualifier(qualifier.Text, qualifier.ValueType); + if (typeof(IWord).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface) RegisterWord(type); + } } } @@ -48,37 +46,25 @@ public void RegisterAssemblies(IEnumerable assemblies) public bool TryGetVerb(string text, out VerbDescriptor? descriptor) { descriptor = GetVerbOverloads(text).FirstOrDefault(); return descriptor != null; } public IReadOnlyList GetVerbOverloads(string text) => _verbs.TryGetValue(text, out List? overloads) ? overloads.DistinctBy(x => x.VerbType).ToArray() : []; - public Type? GetVerbBaseType(string text) - { - VerbDescriptor? descriptor = GetVerbOverloads(text).FirstOrDefault(); if (descriptor == null) return null; - Type? baseType = descriptor.VerbType.BaseType; while (baseType != null && !baseType.IsAbstract && baseType != typeof(object)) baseType = baseType.BaseType; - if (baseType == null || baseType == typeof(object)) return null; return baseType.IsGenericType ? baseType.GetGenericTypeDefinition() : baseType; - } + public Type? GetVerbBaseType(string text) { VerbDescriptor? d = GetVerbOverloads(text).FirstOrDefault(); if (d == null) return null; Type? b = d.VerbType.BaseType; while (b != null && !b.IsAbstract && b != typeof(object)) b = b.BaseType; if (b == null || b == typeof(object)) return null; return b.IsGenericType ? b.GetGenericTypeDefinition() : b; } private void RegisterWord(Type type) { - Func factory = () => CreatePrototype(type) as IWord; - IWord? prototype = factory(); + Func factory = () => CreatePrototype(type) as IWord; IWord? prototype = factory(); if (typeof(IVerb).IsAssignableFrom(type)) { VerbIdentity? identity = _compiler.DescribeVerbIdentity(type, prototype as IVerb); if (identity != null) { - VerbDescriptor descriptor = _compiler.DescribeVerb(type, identity.Text, identity.Synonyms, () => factory() as IVerb); - RegisterOverload(identity.Text, descriptor); foreach (string synonym in identity.Synonyms) RegisterOverload(synonym, descriptor); - if (prototype is IKeyword) { var word = new WordDescriptor(type, identity.Text, identity.Synonyms, factory); _words.TryAdd(identity.Text, word); foreach (string synonym in identity.Synonyms) _words.TryAdd(synonym, word); } + VerbDescriptor descriptor = _compiler.DescribeVerb(type, identity.Text, identity.Synonyms, () => factory() as IVerb); RegisterOverload(identity.Text, descriptor); foreach (string s in identity.Synonyms) RegisterOverload(s, descriptor); + if (prototype is IKeyword) { var word = new WordDescriptor(type, identity.Text, identity.Synonyms, factory); _words.TryAdd(identity.Text, word); foreach (string s in identity.Synonyms) _words.TryAdd(s, word); } } return; } if (prototype is IKeyword keyword) _words.TryAdd(keyword.Text, new(type, keyword.Text, [], factory)); } - private void RegisterOverload(string keyword, VerbDescriptor descriptor) { if (!_verbs.TryGetValue(keyword, out List? overloads)) _verbs[keyword] = overloads = []; if (overloads.All(x => x.VerbType != descriptor.VerbType)) overloads.Add(descriptor); } - - private static object? CreatePrototype(Type type) - { - try { ConstructorInfo? p = type.GetConstructor(Type.EmptyTypes); if (p != null) return p.Invoke(null); ConstructorInfo? c = type.GetConstructors().OrderBy(x => x.GetParameters().Length).FirstOrDefault(); if (c == null) return null; object?[] a = c.GetParameters().Select(x => x.ParameterType.IsValueType ? Activator.CreateInstance(x.ParameterType) : null).ToArray(); return c.Invoke(a); } catch { return null; } - } - + private void RegisterOverload(string k, VerbDescriptor d) { if (!_verbs.TryGetValue(k, out List? o)) _verbs[k] = o = []; if (o.All(x => x.VerbType != d.VerbType)) o.Add(d); } + private static object? CreatePrototype(Type t) { try { ConstructorInfo? p = t.GetConstructor(Type.EmptyTypes); if (p != null) return p.Invoke(null); ConstructorInfo? c = t.GetConstructors().OrderBy(x => x.GetParameters().Length).FirstOrDefault(); if (c == null) return null; object?[] a = c.GetParameters().Select(x => x.ParameterType.IsValueType ? Activator.CreateInstance(x.ParameterType) : null).ToArray(); return c.Invoke(a); } catch { return null; } } private void RegisterStandardQualifiers() { RegisterQualifier("TEXT", typeof(string)); RegisterQualifier("JSON"); RegisterQualifier("XML"); RegisterQualifier("BINARY", typeof(byte[])); foreach (string q in new[] { "CSV", "HTML", "YAML", "IMAGE", "VIDEO", "AUDIO" }) RegisterQualifier(q); } } diff --git a/src/FluNet.Engine/Language/Metadata/Descriptors.cs b/src/FluNet.Engine/Language/Metadata/Descriptors.cs index b61a7cb..7c0b014 100644 --- a/src/FluNet.Engine/Language/Metadata/Descriptors.cs +++ b/src/FluNet.Engine/Language/Metadata/Descriptors.cs @@ -18,8 +18,9 @@ public sealed record ParameterDescriptor( public sealed record ConstructorDescriptor( ConstructorInfo Constructor, - IReadOnlyList Parameters) + IReadOnlyList Parameters, + Func Activator) { public int RoleParameterCount => Parameters.Count(x => x.Role != null); - public int ServiceParameterCount => Parameters.Count(x => x.FromServices); + public int ServiceParameterCount => Parameters.Count(x => x.FromServices || x.Role == null); } diff --git a/tests/FluNET.Tests/LanguageBuildTests.cs b/tests/FluNET.Tests/LanguageBuildTests.cs new file mode 100644 index 0000000..7581f25 --- /dev/null +++ b/tests/FluNET.Tests/LanguageBuildTests.cs @@ -0,0 +1,14 @@ +using FluNET.Language; + +namespace FluNET.Tests; + +public class LanguageBuildTests +{ + [Fact] + public void Registry_build_returns_snapshot_and_language_diagnostics() + { + LanguageBuildResult result = new LanguageRegistry().Build(); + Assert.NotNull(result.Snapshot); + Assert.NotNull(result.Diagnostics); + } +} From b393ae0f993d4e13f09b1b10e2ae53901220d9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:31:03 +0200 Subject: [PATCH 13/18] Add FluNET.Classic introspection CLI --- src/FluNET.CLI/FluNET.CLI.csproj | 13 ++ src/FluNET.CLI/Program.cs | 199 +++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 src/FluNET.CLI/FluNET.CLI.csproj create mode 100644 src/FluNET.CLI/Program.cs diff --git a/src/FluNET.CLI/FluNET.CLI.csproj b/src/FluNET.CLI/FluNET.CLI.csproj new file mode 100644 index 0000000..42d1564 --- /dev/null +++ b/src/FluNET.CLI/FluNET.CLI.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + enable + enable + flu + + + + + + diff --git a/src/FluNET.CLI/Program.cs b/src/FluNET.CLI/Program.cs new file mode 100644 index 0000000..08078e1 --- /dev/null +++ b/src/FluNET.CLI/Program.cs @@ -0,0 +1,199 @@ +using FluNET.Compilation; +using FluNET.Diagnostics; +using FluNET.Execution; +using FluNET.Execution.Capabilities; +using FluNET.Language; + +return await FluCli.RunAsync(args); + +internal static class FluCli +{ + public static async Task RunAsync(string[] args) + { + var registry = new LanguageRegistry(); + LanguageBuildResult language = registry.Build(); + + if (args.Length == 0 || IsHelp(args[0])) + { + PrintHelp(); + return 0; + } + + string command = args[0].ToLowerInvariant(); + return command switch + { + "verbs" => ShowVerbs(language.Snapshot), + "verb" => ShowVerb(language.Snapshot, args), + "modules" => ShowModules(language.Snapshot), + "language" => ShowLanguage(language), + "check" => Check(language.Snapshot, args), + "explain" => Explain(language.Snapshot, args), + "run" => await RunScriptAsync(language.Snapshot, args), + _ => Unknown(command) + }; + } + + private static int ShowVerbs(LanguageSnapshot language) + { + foreach (IGrouping group in language.Verbs + .OrderBy(x => x.Text) + .GroupBy(x => x.Text, StringComparer.OrdinalIgnoreCase)) + Console.WriteLine($"{group.Key,-16} {group.Count()} implementation(s)"); + return 0; + } + + private static int ShowVerb(LanguageSnapshot language, string[] args) + { + if (args.Length < 2) return Missing("verb keyword"); + Console.WriteLine(LanguageIntrospection.ExplainVerb(language, args[1])); + return language.GetVerbOverloads(args[1]).Count > 0 ? 0 : 2; + } + + private static int ShowModules(LanguageSnapshot language) + { + if (language.Modules.Count == 0) + { + Console.WriteLine("No explicit language modules registered."); + return 0; + } + + foreach (ModuleDescriptor module in language.Modules.OrderBy(x => x.ModuleName)) + Console.WriteLine($"{module.ModuleName} {module.Version}"); + return 0; + } + + private static int ShowLanguage(LanguageBuildResult language) + { + PrintDiagnostics(language.Diagnostics); + Console.WriteLine(LanguageIntrospection.ToJson(language.Snapshot)); + return language.Success ? 0 : 2; + } + + private static int Check(LanguageSnapshot language, string[] args) + { + if (!TryReadScript(args, out string? source, out int error)) return error; + ClassicCompilation compilation = new ClassicCompiler(language).Compile(source!); + PrintDiagnostics(compilation.Diagnostics); + if (compilation.Success) + Console.WriteLine($"OK: {compilation.Pipelines.Count} pipeline(s)."); + return compilation.Success ? 0 : 2; + } + + private static int Explain(LanguageSnapshot language, string[] args) + { + if (!TryReadScript(args, out string? source, out int error)) return error; + ClassicCompilation compilation = new ClassicCompiler(language).Compile(source!); + PrintDiagnostics(compilation.Diagnostics); + if (!compilation.Success) return 2; + + for (int p = 0; p < compilation.Pipelines.Count; p++) + { + Console.WriteLine($"Pipeline {p + 1}:"); + foreach (var sentence in compilation.Pipelines[p].Sentences) + { + Console.WriteLine($" {sentence.Verb.Text} -> {sentence.Verb.VerbType.FullName}"); + Console.WriteLine($" result: {sentence.ResultType?.FullName ?? "void/unknown"}"); + Console.WriteLine($" cost: {sentence.BindingCost}"); + foreach (var role in sentence.Roles) + Console.WriteLine($" {role.Descriptor.Kind}: {role.Descriptor.ValueType.Name} ({role.Descriptor.Direction}, {role.Descriptor.Cardinality})"); + } + } + return 0; + } + + private static async Task RunScriptAsync(LanguageSnapshot language, string[] args) + { + if (!TryReadScript(args, out string? source, out int error)) return error; + + string[] allowed = ReadAllowedCapabilities(args); + ICapabilityPolicy capabilities = allowed.Length == 0 + ? AllowAllCapabilityPolicy.Instance + : new ExplicitCapabilityPolicy(allowed); + + var engine = new ClassicScriptEngine(language, capabilities: capabilities); + try + { + ClassicScriptResult result = await engine.RunAsync(source!); + PrintDiagnostics(result.Compilation.Diagnostics); + if (!result.Success) return 2; + if (result.Result != null) Console.WriteLine(result.Result); + return 0; + } + catch (CapabilityDeniedException ex) + { + Console.Error.WriteLine(ex.Message); + return 3; + } + catch (Exception ex) + { + Console.Error.WriteLine($"Runtime error: {ex.Message}"); + return 4; + } + } + + private static string[] ReadAllowedCapabilities(string[] args) + { + int index = Array.FindIndex(args, x => x.Equals("--allow", StringComparison.OrdinalIgnoreCase)); + if (index < 0 || index + 1 >= args.Length) return []; + return args[index + 1].Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + + private static bool TryReadScript(string[] args, out string? source, out int error) + { + source = null; + error = 0; + if (args.Length < 2) + { + error = Missing("script file"); + return false; + } + + string file = args[1]; + if (!File.Exists(file)) + { + Console.Error.WriteLine($"Script not found: {file}"); + error = 2; + return false; + } + + source = File.ReadAllText(file); + return true; + } + + private static void PrintDiagnostics(IEnumerable diagnostics) + { + foreach (Diagnostic diagnostic in diagnostics) + { + string location = diagnostic.Span is null ? string.Empty : $" [{diagnostic.Span.Start}..{diagnostic.Span.End})"; + Console.Error.WriteLine($"{diagnostic.Code} {diagnostic.Severity}: {diagnostic.Message}{location}"); + } + } + + private static int Unknown(string command) + { + Console.Error.WriteLine($"Unknown command: {command}"); + PrintHelp(); + return 2; + } + + private static int Missing(string value) + { + Console.Error.WriteLine($"Missing {value}."); + return 2; + } + + private static bool IsHelp(string value) => value is "-h" or "--help" or "help"; + + private static void PrintHelp() + { + Console.WriteLine("FluNET.Classic 0.1"); + Console.WriteLine(); + Console.WriteLine(" flu verbs"); + Console.WriteLine(" flu verb "); + Console.WriteLine(" flu modules"); + Console.WriteLine(" flu language"); + Console.WriteLine(" flu check "); + Console.WriteLine(" flu explain "); + Console.WriteLine(" flu run [--allow capability1,capability2]"); + } +} From 3af3684aed8b825a5e718d5055c0c9962143569c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:33:30 +0200 Subject: [PATCH 14/18] Align new tests with NUnit test stack --- tests/FluNET.Tests/CapabilityPolicyTests.cs | 4 +-- tests/FluNET.Tests/ClassicCompilerTests.cs | 10 +++--- tests/FluNET.Tests/ClassicParserTests.cs | 24 +++++++------- tests/FluNET.Tests/LanguageBuildTests.cs | 6 ++-- .../LanguageCompilerIdentityTests.cs | 26 ++++++--------- .../LanguageIntrospectionTests.cs | 13 ++++---- tests/FluNET.Tests/LanguageMetadataTests.cs | 31 ++++++++---------- tests/FluNET.Tests/LanguagePatternTests.cs | 16 +++------- tests/FluNET.Tests/LanguageRegistryTests.cs | 25 +++++++-------- tests/FluNET.Tests/SemanticBinderTests.cs | 32 +++++++------------ .../ValueConversionRegistryTests.cs | 14 ++++---- .../ValueResolverRegistryTests.cs | 22 ++++++------- tests/FluNET.Tests/VerbActivatorTests.cs | 18 +++-------- 13 files changed, 98 insertions(+), 143 deletions(-) diff --git a/tests/FluNET.Tests/CapabilityPolicyTests.cs b/tests/FluNET.Tests/CapabilityPolicyTests.cs index b0cd4c5..9c55a03 100644 --- a/tests/FluNET.Tests/CapabilityPolicyTests.cs +++ b/tests/FluNET.Tests/CapabilityPolicyTests.cs @@ -5,11 +5,11 @@ namespace FluNET.Tests; public class CapabilityPolicyTests { - [Fact] + [Test] public void Explicit_policy_denies_unlisted_capability() { VerbDescriptor get = new LanguageRegistry().Snapshot.GetVerbOverloads("GET").First(); var policy = new ExplicitCapabilityPolicy(["filesystem.write"]); - Assert.False(policy.IsAllowed("filesystem.read", get)); + Assert.That(policy.IsAllowed("filesystem.read", get), Is.False); } } diff --git a/tests/FluNET.Tests/ClassicCompilerTests.cs b/tests/FluNET.Tests/ClassicCompilerTests.cs index cc0146f..f162283 100644 --- a/tests/FluNET.Tests/ClassicCompilerTests.cs +++ b/tests/FluNET.Tests/ClassicCompilerTests.cs @@ -5,16 +5,14 @@ namespace FluNET.Tests; public class ClassicCompilerTests { - [Fact] + [Test] public void Compiler_binds_a_classic_get_from_source_text() { LanguageSnapshot language = new LanguageRegistry().Snapshot; var compiler = new ClassicCompiler(language); - ClassicCompilation result = compiler.Compile("GET [text] FROM {input.txt}"); - - Assert.True(result.Success); - Assert.Single(result.Pipelines); - Assert.Equal(typeof(string[]), result.Pipelines[0].ResultType); + Assert.That(result.Success, Is.True); + Assert.That(result.Pipelines.Count, Is.EqualTo(1)); + Assert.That(result.Pipelines[0].ResultType, Is.EqualTo(typeof(string[]))); } } diff --git a/tests/FluNET.Tests/ClassicParserTests.cs b/tests/FluNET.Tests/ClassicParserTests.cs index 48e5044..57adf4a 100644 --- a/tests/FluNET.Tests/ClassicParserTests.cs +++ b/tests/FluNET.Tests/ClassicParserTests.cs @@ -6,28 +6,26 @@ namespace FluNET.Tests; public class ClassicParserTests { - [Fact] + [Test] public void Parser_preserves_classic_sentence_shape_and_then_pipeline() { var parser = new ClassicParser(new LanguageRegistry().Snapshot); - ParseResult result = parser.Parse("GET TEXT [data] FROM {input.txt} THEN SAY [data]"); - - Assert.True(result.Success); - PipelineNode pipeline = Assert.Single(result.Script!.Pipelines); - Assert.Equal(2, pipeline.Sentences.Count); - Assert.Equal("GET", pipeline.Sentences[0].Verb); - Assert.Equal("TEXT", pipeline.Sentences[0].Qualifier); - Assert.Contains(pipeline.Sentences[0].Clauses, x => x.Kind == ClauseKind.What && x.Value is VariableExpression); - Assert.Contains(pipeline.Sentences[0].Clauses, x => x.Kind == ClauseKind.From && x.Value is ReferenceExpression); + Assert.That(result.Success, Is.True); + PipelineNode pipeline = result.Script!.Pipelines.Single(); + Assert.That(pipeline.Sentences.Count, Is.EqualTo(2)); + Assert.That(pipeline.Sentences[0].Verb, Is.EqualTo("GET")); + Assert.That(pipeline.Sentences[0].Qualifier, Is.EqualTo("TEXT")); + Assert.That(pipeline.Sentences[0].Clauses.Any(x => x.Kind == ClauseKind.What && x.Value is VariableExpression), Is.True); + Assert.That(pipeline.Sentences[0].Clauses.Any(x => x.Kind == ClauseKind.From && x.Value is ReferenceExpression), Is.True); } - [Fact] + [Test] public void Parser_keeps_multiple_values_for_the_same_role() { var parser = new ClassicParser(new LanguageRegistry().Snapshot); ParseResult result = parser.Parse("GET [data] FROM a.txt b.txt c.txt"); - SentenceNode sentence = Assert.Single(Assert.Single(result.Script!.Pipelines).Sentences); - Assert.Equal(3, sentence.Clauses.Count(x => x.Kind == ClauseKind.From)); + SentenceNode sentence = result.Script!.Pipelines.Single().Sentences.Single(); + Assert.That(sentence.Clauses.Count(x => x.Kind == ClauseKind.From), Is.EqualTo(3)); } } diff --git a/tests/FluNET.Tests/LanguageBuildTests.cs b/tests/FluNET.Tests/LanguageBuildTests.cs index 7581f25..fd06418 100644 --- a/tests/FluNET.Tests/LanguageBuildTests.cs +++ b/tests/FluNET.Tests/LanguageBuildTests.cs @@ -4,11 +4,11 @@ namespace FluNET.Tests; public class LanguageBuildTests { - [Fact] + [Test] public void Registry_build_returns_snapshot_and_language_diagnostics() { LanguageBuildResult result = new LanguageRegistry().Build(); - Assert.NotNull(result.Snapshot); - Assert.NotNull(result.Diagnostics); + Assert.That(result.Snapshot, Is.Not.Null); + Assert.That(result.Diagnostics, Is.Not.Null); } } diff --git a/tests/FluNET.Tests/LanguageCompilerIdentityTests.cs b/tests/FluNET.Tests/LanguageCompilerIdentityTests.cs index 3d4e426..dbb4246 100644 --- a/tests/FluNET.Tests/LanguageCompilerIdentityTests.cs +++ b/tests/FluNET.Tests/LanguageCompilerIdentityTests.cs @@ -6,36 +6,28 @@ namespace FluNET.Tests; public class LanguageCompilerIdentityTests { - [Fact] + [Test] public void Verb_attribute_defines_identity_without_instantiating_the_type() { var compiler = new LanguageCompiler(); - VerbIdentity? identity = compiler.DescribeVerbIdentity(typeof(AbstractAttributedVerb)); - - Assert.NotNull(identity); - Assert.Equal("CUSTOM", identity!.Text); - Assert.Contains("ALT", identity.Synonyms); + Assert.That(identity, Is.Not.Null); + Assert.That(identity!.Text, Is.EqualTo("CUSTOM")); + Assert.That(identity.Synonyms, Does.Contain("ALT")); } - [Fact] + [Test] public void Semantic_family_marker_defines_standard_keyword() { var compiler = new LanguageCompiler(); - VerbIdentity? identity = compiler.DescribeVerbIdentity(typeof(AbstractGetVerb)); - - Assert.NotNull(identity); - Assert.Equal("GET", identity!.Text); + Assert.That(identity, Is.Not.Null); + Assert.That(identity!.Text, Is.EqualTo("GET")); } [Verb("CUSTOM")] [Alias("ALT")] - private abstract class AbstractAttributedVerb : IVerb - { - } + private abstract class AbstractAttributedVerb : IVerb { } - private abstract class AbstractGetVerb : IGet - { - } + private abstract class AbstractGetVerb : IGet { } } diff --git a/tests/FluNET.Tests/LanguageIntrospectionTests.cs b/tests/FluNET.Tests/LanguageIntrospectionTests.cs index ceacbd0..c068681 100644 --- a/tests/FluNET.Tests/LanguageIntrospectionTests.cs +++ b/tests/FluNET.Tests/LanguageIntrospectionTests.cs @@ -4,21 +4,20 @@ namespace FluNET.Tests; public class LanguageIntrospectionTests { - [Fact] + [Test] public void Manifest_contains_compiled_get_metadata() { LanguageSnapshot snapshot = new LanguageRegistry().Snapshot; string json = LanguageIntrospection.ToJson(snapshot); - - Assert.Contains("GET", json); - Assert.Contains("clauses", json); - Assert.Contains("resultType", json); + Assert.That(json, Does.Contain("GET")); + Assert.That(json, Does.Contain("patterns")); + Assert.That(json, Does.Contain("resultType")); } - [Fact] + [Test] public void Language_validator_accepts_standard_snapshot_without_missing_module_dependencies() { LanguageSnapshot snapshot = new LanguageRegistry().Snapshot; - Assert.DoesNotContain(LanguageValidator.Validate(snapshot), x => x.Code == "FLU-LANG-010"); + Assert.That(LanguageValidator.Validate(snapshot).Any(x => x.Code == "FLU-LANG-010"), Is.False); } } diff --git a/tests/FluNET.Tests/LanguageMetadataTests.cs b/tests/FluNET.Tests/LanguageMetadataTests.cs index ffedd4d..ae0084d 100644 --- a/tests/FluNET.Tests/LanguageMetadataTests.cs +++ b/tests/FluNET.Tests/LanguageMetadataTests.cs @@ -5,38 +5,33 @@ namespace FluNET.Tests; public class LanguageMetadataTests { - [Fact] + [Test] public void Type_shape_distinguishes_scalar_from_collection_value() { TypeShape scalar = TypeShape.Analyze(typeof(FileInfo)); TypeShape array = TypeShape.Analyze(typeof(FileInfo[])); - - Assert.False(scalar.IsCollection); - Assert.True(array.IsCollection); - Assert.Equal(typeof(FileInfo), array.ElementType); + Assert.That(scalar.IsCollection, Is.False); + Assert.That(array.IsCollection, Is.True); + Assert.That(array.ElementType, Is.EqualTo(typeof(FileInfo))); } - [Fact] + [Test] public void Constructor_metadata_uses_roles_and_params_for_syntactic_cardinality() { var compiler = new LanguageCompiler(); - - ConstructorDescriptor constructor = Assert.Single(compiler.DescribeConstructors(typeof(ReflectionFixture))); + ConstructorDescriptor constructor = compiler.DescribeConstructors(typeof(ReflectionFixture)).Single(); ParameterDescriptor what = constructor.Parameters[0]; ParameterDescriptor from = constructor.Parameters[1]; - - Assert.Equal(ClauseKind.What, what.Role); - Assert.False(what.IsParams); - Assert.Equal(ClauseKind.From, from.Role); - Assert.True(from.IsParams); - Assert.True(from.Shape.IsCollection); - Assert.Equal(typeof(FileInfo), from.Shape.ElementType); + Assert.That(what.Role, Is.EqualTo(ClauseKind.What)); + Assert.That(what.IsParams, Is.False); + Assert.That(from.Role, Is.EqualTo(ClauseKind.From)); + Assert.That(from.IsParams, Is.True); + Assert.That(from.Shape.IsCollection, Is.True); + Assert.That(from.Shape.ElementType, Is.EqualTo(typeof(FileInfo))); } private sealed class ReflectionFixture { - public ReflectionFixture([What] string what, [From] params FileInfo[] from) - { - } + public ReflectionFixture([What] string what, [From] params FileInfo[] from) { } } } diff --git a/tests/FluNET.Tests/LanguagePatternTests.cs b/tests/FluNET.Tests/LanguagePatternTests.cs index 2ec4865..3bf4bd0 100644 --- a/tests/FluNET.Tests/LanguagePatternTests.cs +++ b/tests/FluNET.Tests/LanguagePatternTests.cs @@ -6,19 +6,14 @@ namespace FluNET.Tests; public class LanguagePatternTests { - [Fact] + [Test] public void Compiler_creates_distinct_sentence_patterns_from_role_constructors() { var compiler = new LanguageCompiler(); - VerbDescriptor descriptor = compiler.DescribeVerb( - typeof(MultiPatternVerb), - "CUSTOM", - [], - () => null); - - Assert.Equal(2, descriptor.Patterns.Count); - Assert.Contains(descriptor.Patterns, x => x.Pattern.Clauses.Count == 1); - Assert.Contains(descriptor.Patterns, x => x.Pattern.Clauses.Count == 2); + VerbDescriptor descriptor = compiler.DescribeVerb(typeof(MultiPatternVerb), "CUSTOM", [], () => null); + Assert.That(descriptor.Patterns.Count, Is.EqualTo(2)); + Assert.That(descriptor.Patterns.Any(x => x.Pattern.Clauses.Count == 1), Is.True); + Assert.That(descriptor.Patterns.Any(x => x.Pattern.Clauses.Count == 2), Is.True); } [Verb("CUSTOM")] @@ -26,7 +21,6 @@ private sealed class MultiPatternVerb : IVerb { public MultiPatternVerb([What] string what) { } public MultiPatternVerb([What] string what, [From] FileInfo from) { } - public string Text => "CUSTOM"; public IWord? Next { get; set; } public IWord? Previous { get; set; } diff --git a/tests/FluNET.Tests/LanguageRegistryTests.cs b/tests/FluNET.Tests/LanguageRegistryTests.cs index 358b9a5..bab1dfb 100644 --- a/tests/FluNET.Tests/LanguageRegistryTests.cs +++ b/tests/FluNET.Tests/LanguageRegistryTests.cs @@ -4,33 +4,30 @@ namespace FluNET.Tests; public class LanguageRegistryTests { - [Fact] + [Test] public void Registry_discovers_standard_verbs_and_builds_sentence_patterns() { var registry = new LanguageRegistry(); - - Assert.True(registry.TryGetVerb("GET", out VerbDescriptor? get)); - Assert.NotNull(get); - Assert.Contains(get!.Pattern.Clauses, x => x.Kind == ClauseKind.What); - Assert.Contains(get.Pattern.Clauses, x => x.Kind == ClauseKind.From); + Assert.That(registry.TryGetVerb("GET", out VerbDescriptor? get), Is.True); + Assert.That(get, Is.Not.Null); + Assert.That(get!.Pattern.Clauses.Any(x => x.Kind == ClauseKind.What), Is.True); + Assert.That(get.Pattern.Clauses.Any(x => x.Kind == ClauseKind.From), Is.True); } - [Theory] - [InlineData("TEXT")] - [InlineData("JSON")] - [InlineData("BINARY")] + [TestCase("TEXT")] + [TestCase("JSON")] + [TestCase("BINARY")] public void Standard_qualifiers_are_registry_entries(string qualifier) { var registry = new LanguageRegistry(); - Assert.True(registry.IsQualifier(qualifier)); + Assert.That(registry.IsQualifier(qualifier), Is.True); } - [Fact] + [Test] public void Modules_can_extend_qualifiers_without_changing_word_factory() { var registry = new LanguageRegistry(); registry.RegisterQualifier("PARQUET"); - - Assert.True(registry.IsQualifier("PARQUET")); + Assert.That(registry.IsQualifier("PARQUET"), Is.True); } } diff --git a/tests/FluNET.Tests/SemanticBinderTests.cs b/tests/FluNET.Tests/SemanticBinderTests.cs index 8be5594..f23a1d4 100644 --- a/tests/FluNET.Tests/SemanticBinderTests.cs +++ b/tests/FluNET.Tests/SemanticBinderTests.cs @@ -6,36 +6,26 @@ namespace FluNET.Tests; public class SemanticBinderTests { - [Fact] + [Test] public void Binder_binds_classic_get_using_compiled_role_and_constructor_metadata() { var registry = new LanguageRegistry(); var binder = new SemanticBinder(registry.Snapshot); - var sentence = new SentenceNode( - "GET", - [ - new ClauseNode(ClauseKind.What, new VariableExpression("text")), - new ClauseNode(ClauseKind.From, new ReferenceExpression("input.txt")) - ]); - + var sentence = new SentenceNode("GET", [new ClauseNode(ClauseKind.What, new VariableExpression("text")), new ClauseNode(ClauseKind.From, new ReferenceExpression("input.txt"))]); BindingResult result = binder.BindSentence(sentence); - - Assert.True(result.Success); - Assert.NotNull(result.Value); - Assert.Equal("GET", result.Value!.Verb.Text, ignoreCase: true); - Assert.Equal(typeof(string[]), result.Value.ResultType); - Assert.Contains(result.Value.Roles, x => x.Descriptor.Kind == ClauseKind.From); + Assert.That(result.Success, Is.True); + Assert.That(result.Value, Is.Not.Null); + Assert.That(result.Value!.Verb.Text, Is.EqualTo("GET").IgnoreCase); + Assert.That(result.Value.ResultType, Is.EqualTo(typeof(string[]))); + Assert.That(result.Value.Roles.Any(x => x.Descriptor.Kind == ClauseKind.From), Is.True); } - [Fact] + [Test] public void Binder_reports_unknown_verbs_without_execution() { var binder = new SemanticBinder(new LanguageRegistry().Snapshot); - var sentence = new SentenceNode("DOES_NOT_EXIST", []); - - BindingResult result = binder.BindSentence(sentence); - - Assert.False(result.Success); - Assert.Contains(result.Diagnostics, x => x.Code == "FLU2001"); + BindingResult result = binder.BindSentence(new SentenceNode("DOES_NOT_EXIST", [])); + Assert.That(result.Success, Is.False); + Assert.That(result.Diagnostics.Any(x => x.Code == "FLU2001"), Is.True); } } diff --git a/tests/FluNET.Tests/ValueConversionRegistryTests.cs b/tests/FluNET.Tests/ValueConversionRegistryTests.cs index ab2787d..cc274a9 100644 --- a/tests/FluNET.Tests/ValueConversionRegistryTests.cs +++ b/tests/FluNET.Tests/ValueConversionRegistryTests.cs @@ -4,14 +4,16 @@ namespace FluNET.Tests; public class ValueConversionRegistryTests { - [Fact] + [Test] public void Numeric_conversion_has_higher_cost_than_exact_match() { var conversions = new ValueConversionRegistry(); - Assert.True(conversions.TryGet(typeof(int), typeof(int), out ValueConversion? exact)); - Assert.True(conversions.TryGet(typeof(int), typeof(long), out ValueConversion? numeric)); - Assert.Equal(0, exact!.Cost); - Assert.True(numeric!.Cost > exact.Cost); - Assert.Equal(42L, numeric.Apply(42)); + Assert.That(conversions.TryGet(typeof(int), typeof(int), out ValueConversion? exact), Is.True); + Assert.That(conversions.TryGet(typeof(int), typeof(long), out ValueConversion? numeric), Is.True); + Assert.That(exact, Is.Not.Null); + Assert.That(numeric, Is.Not.Null); + Assert.That(exact!.Cost, Is.EqualTo(0)); + Assert.That(numeric!.Cost, Is.GreaterThan(exact.Cost)); + Assert.That(numeric.Apply(42), Is.EqualTo(42L)); } } diff --git a/tests/FluNET.Tests/ValueResolverRegistryTests.cs b/tests/FluNET.Tests/ValueResolverRegistryTests.cs index fd303de..453cc9b 100644 --- a/tests/FluNET.Tests/ValueResolverRegistryTests.cs +++ b/tests/FluNET.Tests/ValueResolverRegistryTests.cs @@ -4,27 +4,25 @@ namespace FluNET.Tests; public class ValueResolverRegistryTests { - [Fact] + [Test] public void Reflection_fallback_resolves_enum_and_string_constructor_types() { var resolvers = new ValueResolverRegistry(); - - Assert.True(resolvers.TryResolve("Friday", typeof(DayOfWeek), out object? day)); - Assert.Equal(DayOfWeek.Friday, day); - - Assert.True(resolvers.TryResolve("alpha", typeof(StringConstructed), out object? custom)); - Assert.Equal("alpha", Assert.IsType(custom).Value); + Assert.That(resolvers.TryResolve("Friday", typeof(DayOfWeek), out object? day), Is.True); + Assert.That(day, Is.EqualTo(DayOfWeek.Friday)); + Assert.That(resolvers.TryResolve("alpha", typeof(StringConstructed), out object? custom), Is.True); + Assert.That(custom, Is.TypeOf()); + Assert.That(((StringConstructed)custom!).Value, Is.EqualTo("alpha")); } - [Fact] + [Test] public void Repeated_values_resolve_to_array_shape() { var resolvers = new ValueResolverRegistry(); var context = new ResolutionContext(typeof(FileInfo[])); - - Assert.True(resolvers.TryResolveMany(["a.txt", "b.txt"], typeof(FileInfo[]), context, out object? result)); - FileInfo[] files = Assert.IsType(result); - Assert.Equal(2, files.Length); + Assert.That(resolvers.TryResolveMany(["a.txt", "b.txt"], typeof(FileInfo[]), context, out object? result), Is.True); + Assert.That(result, Is.TypeOf()); + Assert.That(((FileInfo[])result!).Length, Is.EqualTo(2)); } private sealed class StringConstructed diff --git a/tests/FluNET.Tests/VerbActivatorTests.cs b/tests/FluNET.Tests/VerbActivatorTests.cs index 1471c6d..4f0a155 100644 --- a/tests/FluNET.Tests/VerbActivatorTests.cs +++ b/tests/FluNET.Tests/VerbActivatorTests.cs @@ -6,25 +6,17 @@ namespace FluNET.Tests; public class VerbActivatorTests { - [Fact] + [Test] public void Activator_constructs_classic_get_from_bound_constructor_metadata() { var registry = new LanguageRegistry(); var binder = new SemanticBinder(registry.Snapshot); - var sentence = new SentenceNode( - "GET", - [ - new ClauseNode(ClauseKind.What, new VariableExpression("text")), - new ClauseNode(ClauseKind.From, new ReferenceExpression("input.txt")) - ]); - + var sentence = new SentenceNode("GET", [new ClauseNode(ClauseKind.What, new VariableExpression("text")), new ClauseNode(ClauseKind.From, new ReferenceExpression("input.txt"))]); BindingResult binding = binder.BindSentence(sentence); - Assert.True(binding.Success); - + Assert.That(binding.Success, Is.True); var activator = new VerbActivator(); var verb = activator.Create(binding.Value!); - - Assert.Equal("GET", verb.Text, ignoreCase: true); - Assert.Equal("GetText", verb.GetType().Name); + Assert.That(verb.Text, Is.EqualTo("GET").IgnoreCase); + Assert.That(verb.GetType().Name, Is.EqualTo("GetText")); } } From 25a41f88f35f437fd1aa9d8c17d63a3badb3a76a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:34:14 +0200 Subject: [PATCH 15/18] Fix case-sensitive project references --- tests/FluNET.IntegrationTests/FluNET.IntegrationTests.csproj | 4 ++-- tests/FluNET.Tests/FluNET.Tests.csproj | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/FluNET.IntegrationTests/FluNET.IntegrationTests.csproj b/tests/FluNET.IntegrationTests/FluNET.IntegrationTests.csproj index 38c4352..9d3a6aa 100644 --- a/tests/FluNET.IntegrationTests/FluNET.IntegrationTests.csproj +++ b/tests/FluNET.IntegrationTests/FluNET.IntegrationTests.csproj @@ -1,4 +1,4 @@ - + FluNET.IntegrationTests @@ -24,7 +24,7 @@ - + diff --git a/tests/FluNET.Tests/FluNET.Tests.csproj b/tests/FluNET.Tests/FluNET.Tests.csproj index e972d46..5161f53 100644 --- a/tests/FluNET.Tests/FluNET.Tests.csproj +++ b/tests/FluNET.Tests/FluNET.Tests.csproj @@ -6,7 +6,6 @@ net8.0 enable enable - false true @@ -21,7 +20,7 @@ - + From 6859dd15866d6276317ee15132eaf25e6af1e7e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:35:36 +0200 Subject: [PATCH 16/18] Add reflected property access and interpolation --- .../Binding/ExpressionRuntimeEvaluator.cs | 67 ++++++++ src/FluNet.Engine/Binding/SemanticBinder.cs | 155 +++++------------- src/FluNet.Engine/Binding/VerbActivator.cs | 16 +- .../Syntax/Parsing/ClassicParser.cs | 107 +++--------- tests/FluNET.Tests/ExpressionBindingTests.cs | 31 ++++ 5 files changed, 177 insertions(+), 199 deletions(-) create mode 100644 src/FluNet.Engine/Binding/ExpressionRuntimeEvaluator.cs create mode 100644 tests/FluNET.Tests/ExpressionBindingTests.cs diff --git a/src/FluNet.Engine/Binding/ExpressionRuntimeEvaluator.cs b/src/FluNet.Engine/Binding/ExpressionRuntimeEvaluator.cs new file mode 100644 index 0000000..1f12230 --- /dev/null +++ b/src/FluNet.Engine/Binding/ExpressionRuntimeEvaluator.cs @@ -0,0 +1,67 @@ +using FluNET.Syntax.Ast; +using System.Collections.Concurrent; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace FluNET.Binding; + +/// +/// Evaluates bound expression sources against runtime variables/pipeline values. +/// Reflection accessors are cached per CLR type/property. +/// +public sealed class ExpressionRuntimeEvaluator +{ + private static readonly Regex Interpolation = new(@"\[(?[A-Za-z_][A-Za-z0-9_\.]*)\]", RegexOptions.Compiled); + private readonly ConcurrentDictionary<(Type Type, string Property), PropertyInfo?> _properties = new(); + + public object? Evaluate(ExpressionNode expression, ActivationContext context) + { + return expression switch + { + VariableExpression variable => ResolveVariable(variable.Name, context), + PropertyExpression property => ResolveProperty(property, context), + PipelineValueExpression => context.PipelineValue, + InterpolatedStringExpression interpolated => Interpolate(interpolated.Template, context), + LiteralExpression literal => literal.Value, + ReferenceExpression reference => reference.Reference, + _ => null + }; + } + + private object? ResolveVariable(string name, ActivationContext context) + { + if (context.Variables != null && context.Variables.TryGetValue(name, out object? value)) + return value; + throw new InvalidOperationException($"Variable '{name}' has no runtime value."); + } + + private object? ResolveProperty(PropertyExpression property, ActivationContext context) + { + object? target = Evaluate(property.Target, context); + if (target == null) return null; + + PropertyInfo? info = _properties.GetOrAdd((target.GetType(), property.Property), key => + key.Type.GetProperties(BindingFlags.Public | BindingFlags.Instance) + .FirstOrDefault(x => x.Name.Equals(key.Property, StringComparison.OrdinalIgnoreCase))); + + if (info == null) + throw new InvalidOperationException($"Property '{property.Property}' does not exist on '{target.GetType().FullName}'."); + + return info.GetValue(target); + } + + private string Interpolate(string template, ActivationContext context) => + Interpolation.Replace(template, match => + { + ExpressionNode expression = ParsePath(match.Groups["path"].Value); + return Evaluate(expression, context)?.ToString() ?? string.Empty; + }); + + private static ExpressionNode ParsePath(string path) + { + string[] parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + ExpressionNode current = new VariableExpression(parts[0]); + for (int i = 1; i < parts.Length; i++) current = new PropertyExpression(current, parts[i]); + return current; + } +} diff --git a/src/FluNet.Engine/Binding/SemanticBinder.cs b/src/FluNet.Engine/Binding/SemanticBinder.cs index 0e84408..6974489 100644 --- a/src/FluNet.Engine/Binding/SemanticBinder.cs +++ b/src/FluNet.Engine/Binding/SemanticBinder.cs @@ -3,6 +3,7 @@ using FluNET.Language.Metadata; using FluNET.Syntax.Ast; using FluNET.Syntax.Core; +using System.Reflection; namespace FluNET.Binding; @@ -14,174 +15,104 @@ public sealed class SemanticBinder public SemanticBinder(LanguageSnapshot language, ValueResolverRegistry? resolvers = null, ValueConversionRegistry? conversions = null) { - _language = language; - _resolvers = resolvers ?? new ValueResolverRegistry(); - _conversions = conversions ?? new ValueConversionRegistry(); + _language = language; _resolvers = resolvers ?? new ValueResolverRegistry(); _conversions = conversions ?? new ValueConversionRegistry(); } public BindingResult BindSentence(SentenceNode sentence, BindingContext? context = null) { context ??= new BindingContext(); - IReadOnlyList overloads = _language.GetVerbOverloads(sentence.Verb) - .Where(x => QualifierMatches(sentence.Qualifier, x)) - .ToArray(); - if (overloads.Count == 0) return Failure("FLU2001", $"Unknown verb or qualifier combination '{sentence.Verb}{(sentence.Qualifier is null ? "" : " " + sentence.Qualifier)}'."); + IReadOnlyList overloads = _language.GetVerbOverloads(sentence.Verb).Where(x => QualifierMatches(sentence.Qualifier, x)).ToArray(); + if (overloads.Count == 0) return Failure("FLU2001", $"Unknown verb or qualifier combination '{sentence.Verb}{(sentence.Qualifier is null ? "" : " " + sentence.Qualifier)}'.", sentence.Span); var candidates = new List(); foreach (VerbDescriptor overload in overloads) { - IReadOnlyList patterns = overload.Patterns.Count > 0 - ? overload.Patterns - : [new VerbPatternDescriptor(overload.Pattern, overload.Constructors.FirstOrDefault())]; - - foreach (VerbPatternDescriptor pattern in patterns) - { - BoundSentence? candidate = TryBindPattern(sentence, overload, pattern, context); - if (candidate != null) candidates.Add(candidate); - } + IReadOnlyList patterns = overload.Patterns.Count > 0 ? overload.Patterns : [new VerbPatternDescriptor(overload.Pattern, overload.Constructors.FirstOrDefault())]; + foreach (VerbPatternDescriptor pattern in patterns) { BoundSentence? candidate = TryBindPattern(sentence, overload, pattern, context); if (candidate != null) candidates.Add(candidate); } } - if (candidates.Count == 0) - { - string signatures = string.Join(", ", overloads.SelectMany(FormatSignatures)); - return Failure("FLU2101", $"No overload of '{sentence.Verb}' matches this sentence. Available: {signatures}."); - } - - int bestCost = candidates.Min(x => x.BindingCost); - BoundSentence[] best = candidates.Where(x => x.BindingCost == bestCost).ToArray(); - if (best.Length > 1) - { - string matches = string.Join(", ", best.Select(x => FormatSignature(x.Verb.Text, x.Roles.Select(r => r.Descriptor)))); - return Failure("FLU2102", $"Ambiguous '{sentence.Verb}' sentence. Matching overloads: {matches}."); - } + if (candidates.Count == 0) return Failure("FLU2101", $"No overload of '{sentence.Verb}' matches this sentence. Available: {string.Join(", ", overloads.SelectMany(FormatSignatures))}.", sentence.Span); + int bestCost = candidates.Min(x => x.BindingCost); BoundSentence[] best = candidates.Where(x => x.BindingCost == bestCost).ToArray(); + if (best.Length > 1) return Failure("FLU2102", $"Ambiguous '{sentence.Verb}' sentence. Matching overloads: {string.Join(", ", best.Select(x => FormatSignature(x.Verb.Text, x.Roles.Select(r => r.Descriptor))))}.", sentence.Span); return new(best[0], []); } public BindingResult BindPipeline(PipelineNode pipeline, BindingContext? context = null) { - context ??= new BindingContext(); - var bound = new List(); - var diagnostics = new List(); + context ??= new BindingContext(); var bound = new List(); var diagnostics = new List(); var variableTypes = context.VariableTypes != null ? new Dictionary(context.VariableTypes, StringComparer.OrdinalIgnoreCase) : new Dictionary(StringComparer.OrdinalIgnoreCase); Type? pipelineType = context.PipelineType; - foreach (SentenceNode sentence in pipeline.Sentences) { - BindingResult result = BindSentence(sentence, context with { PipelineType = pipelineType, VariableTypes = variableTypes }); - diagnostics.AddRange(result.Diagnostics); - if (!result.Success || result.Value == null) return new(null, diagnostics); - bound.Add(result.Value); - pipelineType = result.Value.ResultType; + BindingResult result = BindSentence(sentence, context with { PipelineType = pipelineType, VariableTypes = variableTypes }); diagnostics.AddRange(result.Diagnostics); if (!result.Success || result.Value == null) return new(null, diagnostics); + bound.Add(result.Value); pipelineType = result.Value.ResultType; foreach (BoundRole role in result.Value.Roles.Where(x => x.Descriptor.Direction is RoleDirection.Output or RoleDirection.InputOutput)) - foreach (BoundValue value in role.Values) - if (value.Source is VariableExpression variable) variableTypes[variable.Name] = role.Descriptor.ValueType; + foreach (BoundValue value in role.Values) if (value.Source is VariableExpression variable) variableTypes[variable.Name] = role.Descriptor.ValueType; } return new(new BoundPipeline(bound, pipelineType), diagnostics); } private BoundSentence? TryBindPattern(SentenceNode sentence, VerbDescriptor verb, VerbPatternDescriptor patternDescriptor, BindingContext context) { - SentencePattern pattern = patternDescriptor.Pattern; - var remaining = sentence.Clauses.GroupBy(x => x.Kind).ToDictionary(x => x.Key, x => new Queue(x)); - var roles = new List(); - int cost = 0; - + SentencePattern pattern = patternDescriptor.Pattern; var remaining = sentence.Clauses.GroupBy(x => x.Kind).ToDictionary(x => x.Key, x => new Queue(x)); var roles = new List(); int cost = 0; foreach (ClauseDescriptor expected in pattern.Clauses) { - int minimum = expected.Cardinality is RoleCardinality.One or RoleCardinality.OneOrMore ? 1 : 0; - bool repeated = expected.Cardinality is RoleCardinality.ZeroOrMore or RoleCardinality.OneOrMore; - var values = new List(); + int minimum = expected.Cardinality is RoleCardinality.One or RoleCardinality.OneOrMore ? 1 : 0; bool repeated = expected.Cardinality is RoleCardinality.ZeroOrMore or RoleCardinality.OneOrMore; var values = new List(); if (remaining.TryGetValue(expected.Kind, out Queue? queue) && queue.Count > 0) { - if (repeated && expected.ElementType != null) - { - BoundValue? collection = TryBindRepeatedValues(queue, expected, verb, context); - if (collection == null) return null; - values.Add(collection); cost += collection.ConversionCost; - } - else - { - BoundValue? value = TryBindValue(queue.Dequeue().Value, expected, verb, context); - if (value == null) return null; - values.Add(value); cost += value.ConversionCost; - } - } - if (values.Count < minimum) - { - BoundValue? implicitPipeline = TryBindPipelineValue(expected, context); - if (implicitPipeline != null) { values.Add(implicitPipeline); cost += implicitPipeline.ConversionCost; } + if (repeated && expected.ElementType != null) { BoundValue? collection = TryBindRepeatedValues(queue, expected, verb, context); if (collection == null) return null; values.Add(collection); cost += collection.ConversionCost; } + else { BoundValue? value = TryBindValue(queue.Dequeue().Value, expected, verb, context); if (value == null) return null; values.Add(value); cost += value.ConversionCost; } } - if (values.Count < minimum) return null; - roles.Add(new BoundRole(expected, values)); + if (values.Count < minimum) { BoundValue? implicitPipeline = TryBindPipelineValue(expected, context); if (implicitPipeline != null) { values.Add(implicitPipeline); cost += implicitPipeline.ConversionCost; } } + if (values.Count < minimum) return null; roles.Add(new BoundRole(expected, values)); } - if (remaining.Values.Any(queue => queue.Count > 0)) return null; return new BoundSentence(verb, patternDescriptor.Constructor, roles, verb.ResultType, cost); } private bool QualifierMatches(string? qualifierText, VerbDescriptor verb) { - if (qualifierText == null) return true; - if (!_language.TryGetQualifier(qualifierText, out QualifierDescriptor? qualifier) || qualifier == null) return false; - if (qualifier.ValueType == null) return true; - IEnumerable candidateTypes = verb.Patterns.SelectMany(p => p.Pattern.Clauses).Where(x => x.Kind == ClauseKind.What).Select(x => x.ValueType); - if (!candidateTypes.Any()) candidateTypes = verb.Pattern.Clauses.Where(x => x.Kind == ClauseKind.What).Select(x => x.ValueType); - if (verb.ResultType != null) candidateTypes = candidateTypes.Prepend(verb.ResultType); - return candidateTypes.Any(type => TypeMatchesQualifier(type, qualifier.ValueType)); - } - - private static bool TypeMatchesQualifier(Type candidate, Type qualifier) - { - if (candidate == qualifier || qualifier.IsAssignableFrom(candidate) || candidate.IsAssignableFrom(qualifier)) return true; - return candidate.IsArray && candidate.GetElementType() == qualifier; + if (qualifierText == null) return true; if (!_language.TryGetQualifier(qualifierText, out QualifierDescriptor? qualifier) || qualifier == null) return false; if (qualifier.ValueType == null) return true; + IEnumerable candidateTypes = verb.Patterns.SelectMany(p => p.Pattern.Clauses).Where(x => x.Kind == ClauseKind.What).Select(x => x.ValueType); if (!candidateTypes.Any()) candidateTypes = verb.Pattern.Clauses.Where(x => x.Kind == ClauseKind.What).Select(x => x.ValueType); if (verb.ResultType != null) candidateTypes = candidateTypes.Prepend(verb.ResultType); + return candidateTypes.Any(type => type == qualifier.ValueType || qualifier.ValueType.IsAssignableFrom(type) || type.IsAssignableFrom(qualifier.ValueType) || (type.IsArray && type.GetElementType() == qualifier.ValueType)); } private BoundValue? TryBindRepeatedValues(Queue queue, ClauseDescriptor expected, VerbDescriptor verb, BindingContext context) { - ClauseNode[] clauses = queue.ToArray(); queue.Clear(); - string[] texts = clauses.Select(x => x.Value switch { LiteralExpression l => l.Value, ReferenceExpression r => r.Reference, _ => null }).Where(x => x != null).Cast().ToArray(); - if (texts.Length != clauses.Length) return null; - ResolutionContext resolution = new(expected.ValueType, expected.Kind, verb, Qualifier: null, Services: context.Services); - if (!_resolvers.TryResolveMany(texts, expected.ValueType, resolution, out object? collection)) return null; - ExpressionNode source = clauses.Length == 1 ? clauses[0].Value : new InterpolatedStringExpression(string.Join(" ", texts)); - return new(source, expected.ValueType, collection?.GetType() ?? expected.ValueType, collection, 2); + ClauseNode[] clauses = queue.ToArray(); queue.Clear(); string[] texts = clauses.Select(x => x.Value switch { LiteralExpression l => l.Value, ReferenceExpression r => r.Reference, _ => null }).Where(x => x != null).Cast().ToArray(); if (texts.Length != clauses.Length) return null; + if (!_resolvers.TryResolveMany(texts, expected.ValueType, new ResolutionContext(expected.ValueType, expected.Kind, verb, Services: context.Services), out object? collection)) return null; + ExpressionNode source = clauses.Length == 1 ? clauses[0].Value : new InterpolatedStringExpression(string.Join(" ", texts)); return new(source, expected.ValueType, collection?.GetType() ?? expected.ValueType, collection, 2); } private BoundValue? TryBindValue(ExpressionNode expression, ClauseDescriptor expected, VerbDescriptor verb, BindingContext context) { if (expected.Direction == RoleDirection.Output && expression is VariableExpression output) return new(output, expected.ValueType, expected.ValueType, null, 0); if (expression is PipelineValueExpression) return BindKnownType(expression, context.PipelineType, expected.ValueType, null); - if (expression is VariableExpression variable) + if (expression is VariableExpression or PropertyExpression) { - Type? actualType = null; context.VariableTypes?.TryGetValue(variable.Name, out actualType); - return BindKnownType(expression, actualType, expected.ValueType, null); + Type? actualType = InferExpressionType(expression, context); return BindKnownType(expression, actualType, expected.ValueType, null); } if (expression is InterpolatedStringExpression interpolated && expected.ValueType == typeof(string)) return new(interpolated, typeof(string), typeof(string), null, 0); - string? text = expression switch { LiteralExpression l => l.Value, ReferenceExpression r => r.Reference, _ => null }; - if (text == null) return null; - ResolutionContext resolution = new(expected.ValueType, expected.Kind, verb, Qualifier: null, Services: context.Services); - if (_resolvers.TryResolve(text, expected.ValueType, resolution, out object? value)) return new(expression, expected.ValueType, value?.GetType() ?? expected.ValueType, value, expected.ValueType == typeof(string) ? 0 : 2); + string? text = expression switch { LiteralExpression l => l.Value, ReferenceExpression r => r.Reference, _ => null }; if (text == null) return null; + if (_resolvers.TryResolve(text, expected.ValueType, new ResolutionContext(expected.ValueType, expected.Kind, verb, Services: context.Services), out object? value)) return new(expression, expected.ValueType, value?.GetType() ?? expected.ValueType, value, expected.ValueType == typeof(string) ? 0 : 2); return null; } - private BoundValue? TryBindPipelineValue(ClauseDescriptor expected, BindingContext context) - { - if (expected.Direction == RoleDirection.Output || context.PipelineType == null) return null; - return BindKnownType(new PipelineValueExpression(), context.PipelineType, expected.ValueType, null); - } - - private BoundValue? BindKnownType(ExpressionNode source, Type? actualType, Type expectedType, object? value) + private Type? InferExpressionType(ExpressionNode expression, BindingContext context) { - if (actualType == null || !_conversions.TryGet(actualType, expectedType, out ValueConversion? conversion) || conversion == null) return null; - return new(source, expectedType, actualType, value, conversion.Cost, conversion); + if (expression is VariableExpression variable) { context.VariableTypes?.TryGetValue(variable.Name, out Type? type); return type; } + if (expression is PipelineValueExpression) return context.PipelineType; + if (expression is PropertyExpression property) + { + Type? targetType = InferExpressionType(property.Target, context); if (targetType == null) return null; + PropertyInfo? info = targetType.GetProperties(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(x => x.Name.Equals(property.Property, StringComparison.OrdinalIgnoreCase)); return info?.PropertyType; + } + return null; } - private static BindingResult Failure(string code, string message) => new(null, [Diagnostic.Error(code, message)]); - private static IEnumerable FormatSignatures(VerbDescriptor verb) - { - IReadOnlyList patterns = verb.Patterns.Count > 0 ? verb.Patterns : [new VerbPatternDescriptor(verb.Pattern, null)]; - return patterns.Select(x => FormatSignature(verb.Text, x.Pattern.Clauses)); - } - private static string FormatSignature(string verb, IEnumerable clauses) => $"{verb} {string.Join(" ", clauses.Select(x => $"{x.Kind.ToString().ToUpperInvariant()}<{FriendlyName(x.ValueType)}>"))}".TrimEnd(); - private static string FriendlyName(Type type) => type.IsArray ? $"{FriendlyName(type.GetElementType()!)}[]" : type.Name; + private BoundValue? TryBindPipelineValue(ClauseDescriptor expected, BindingContext context) => expected.Direction == RoleDirection.Output || context.PipelineType == null ? null : BindKnownType(new PipelineValueExpression(), context.PipelineType, expected.ValueType, null); + private BoundValue? BindKnownType(ExpressionNode source, Type? actualType, Type expectedType, object? value) { if (actualType == null || !_conversions.TryGet(actualType, expectedType, out ValueConversion? conversion) || conversion == null) return null; return new(source, expectedType, actualType, value, conversion.Cost, conversion); } + private static BindingResult Failure(string code, string message, TextSpan? span = null) => new(null, [Diagnostic.Error(code, message, span)]); + private static IEnumerable FormatSignatures(VerbDescriptor verb) { IReadOnlyList patterns = verb.Patterns.Count > 0 ? verb.Patterns : [new VerbPatternDescriptor(verb.Pattern, null)]; return patterns.Select(x => FormatSignature(verb.Text, x.Pattern.Clauses)); } + private static string FormatSignature(string verb, IEnumerable clauses) => $"{verb} {string.Join(" ", clauses.Select(x => $"{x.Kind.ToString().ToUpperInvariant()}<{(x.ValueType.IsArray ? x.ValueType.GetElementType()!.Name + "[]" : x.ValueType.Name)}>"))}".TrimEnd(); } diff --git a/src/FluNet.Engine/Binding/VerbActivator.cs b/src/FluNet.Engine/Binding/VerbActivator.cs index f9ae5cc..669f48d 100644 --- a/src/FluNet.Engine/Binding/VerbActivator.cs +++ b/src/FluNet.Engine/Binding/VerbActivator.cs @@ -8,6 +8,8 @@ public sealed record ActivationContext(IReadOnlyDictionary? Var public sealed class VerbActivator { + private readonly ExpressionRuntimeEvaluator _expressions = new(); + public IVerb Create(BoundSentence sentence, ActivationContext? context = null) { context ??= new ActivationContext(); ConstructorDescriptor? constructor = sentence.Constructor; @@ -24,12 +26,18 @@ public IVerb Create(BoundSentence sentence, ActivationContext? context = null) BoundRole? role = FindRole(p, remainingRoles); if (role == null) { if (p.IsOptional) { arguments[i] = p.Parameter.HasDefaultValue ? p.Parameter.DefaultValue : DefaultValue(p.ParameterType); continue; } throw new InvalidOperationException($"Missing bound role '{p.Role}' for constructor parameter '{p.Name}'."); } remainingRoles.Remove(role); arguments[i] = MaterializeRole(p, role, context); } - object instance = constructor.Activator(arguments); - return instance as IVerb ?? throw new InvalidOperationException($"Constructed type '{instance.GetType().FullName}' is not an IVerb."); + object instance = constructor.Activator(arguments); return instance as IVerb ?? throw new InvalidOperationException($"Constructed type '{instance.GetType().FullName}' is not an IVerb."); } private static BoundRole? FindRole(ParameterDescriptor p, IReadOnlyList roles) { BoundRole? named = roles.FirstOrDefault(x => x.Descriptor.Kind == p.Role && !string.IsNullOrWhiteSpace(x.Descriptor.Name) && x.Descriptor.Name.Equals(p.Name, StringComparison.OrdinalIgnoreCase)); return named ?? roles.FirstOrDefault(x => x.Descriptor.Kind == p.Role); } - private static object? MaterializeRole(ParameterDescriptor p, BoundRole r, ActivationContext c) { if (r.Values.Count == 0) return DefaultValue(p.ParameterType); if (r.Values.Count == 1) return MaterializeValue(r.Values[0], p.ParameterType, r.Descriptor.Direction, c); if (p.ParameterType.IsArray) { Type e = p.ParameterType.GetElementType()!; Array a = Array.CreateInstance(e, r.Values.Count); for (int i = 0; i < r.Values.Count; i++) a.SetValue(MaterializeValue(r.Values[i], e, r.Descriptor.Direction, c), i); return a; } throw new InvalidOperationException($"Role '{r.Descriptor.Kind}' produced multiple values for non-collection parameter '{p.Name}'."); } - private static object? MaterializeValue(BoundValue v, Type t, RoleDirection d, ActivationContext c) { object? raw; if (v.ConstantValue != null) raw = v.ConstantValue; else raw = v.Source switch { VariableExpression variable when d == RoleDirection.Output => DefaultValue(t), VariableExpression variable when c.Variables != null && c.Variables.TryGetValue(variable.Name, out object? vv) => vv, VariableExpression variable => throw new InvalidOperationException($"Variable '{variable.Name}' has no runtime value."), PipelineValueExpression => c.PipelineValue, InterpolatedStringExpression s when t == typeof(string) => s.Template, _ => DefaultValue(t) }; return v.Conversion?.Apply(raw) ?? raw; } + private object? MaterializeRole(ParameterDescriptor p, BoundRole r, ActivationContext c) { if (r.Values.Count == 0) return DefaultValue(p.ParameterType); if (r.Values.Count == 1) return MaterializeValue(r.Values[0], p.ParameterType, r.Descriptor.Direction, c); if (p.ParameterType.IsArray) { Type e = p.ParameterType.GetElementType()!; Array a = Array.CreateInstance(e, r.Values.Count); for (int i = 0; i < r.Values.Count; i++) a.SetValue(MaterializeValue(r.Values[i], e, r.Descriptor.Direction, c), i); return a; } throw new InvalidOperationException($"Role '{r.Descriptor.Kind}' produced multiple values for non-collection parameter '{p.Name}'."); } + private object? MaterializeValue(BoundValue v, Type t, RoleDirection d, ActivationContext c) + { + object? raw; + if (v.ConstantValue != null) raw = v.ConstantValue; + else if (v.Source is VariableExpression && d == RoleDirection.Output) raw = DefaultValue(t); + else raw = _expressions.Evaluate(v.Source, c); + return v.Conversion?.Apply(raw) ?? raw; + } private static object? DefaultValue(Type t) { if (t.IsArray) return Array.CreateInstance(t.GetElementType()!, 0); if (t.IsValueType) return Activator.CreateInstance(t); return null; } } diff --git a/src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs b/src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs index 72e0ee1..474b2e7 100644 --- a/src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs +++ b/src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs @@ -10,66 +10,34 @@ public sealed record ParseResult(ScriptNode? Script, IReadOnlyList D public bool Success => Script != null && Diagnostics.All(x => x.Severity != DiagnosticSeverity.Error); } -/// -/// Parses the Classic sentence form into immutable AST. WHAT is implicit for values that appear -/// after the verb/qualifier and before the first preposition. -/// public sealed class ClassicParser { private static readonly Dictionary RoleKeywords = new(StringComparer.OrdinalIgnoreCase) { - ["WHAT"] = ClauseKind.What, - ["FROM"] = ClauseKind.From, - ["TO"] = ClauseKind.To, - ["USING"] = ClauseKind.Using, - ["WITH"] = ClauseKind.With + ["WHAT"] = ClauseKind.What, ["FROM"] = ClauseKind.From, ["TO"] = ClauseKind.To, ["USING"] = ClauseKind.Using, ["WITH"] = ClauseKind.With }; private readonly LanguageSnapshot _language; private readonly ClassicLexer _lexer; - public ClassicParser(LanguageSnapshot language, ClassicLexer? lexer = null) - { - _language = language; - _lexer = lexer ?? new ClassicLexer(); - } + public ClassicParser(LanguageSnapshot language, ClassicLexer? lexer = null) { _language = language; _lexer = lexer ?? new ClassicLexer(); } public ParseResult Parse(string source) { - IReadOnlyList tokens = _lexer.Lex(source); - var diagnostics = new List(); - var pipelines = new List(); - int index = 0; - + IReadOnlyList tokens = _lexer.Lex(source); var diagnostics = new List(); var pipelines = new List(); int index = 0; while (index < tokens.Count) { - SkipNewLines(tokens, ref index); - if (index >= tokens.Count) break; - + SkipNewLines(tokens, ref index); if (index >= tokens.Count) break; var sentences = new List(); while (index < tokens.Count && tokens[index].Kind != ClassicTokenKind.NewLine) { - SentenceNode? sentence = ParseSentence(tokens, ref index, diagnostics); - if (sentence != null) sentences.Add(sentence); - - if (index < tokens.Count && IsWord(tokens[index], "THEN")) - { - index++; - continue; - } + SentenceNode? sentence = ParseSentence(tokens, ref index, diagnostics); if (sentence != null) sentences.Add(sentence); + if (index < tokens.Count && IsWord(tokens[index], "THEN")) { index++; continue; } break; } - - if (sentences.Count > 0) - { - int start = sentences[0].Span?.Start ?? 0; - int end = sentences[^1].Span?.End ?? start; - pipelines.Add(new PipelineNode(sentences) { Span = new TextSpan(start, Math.Max(0, end - start)) }); - } - + if (sentences.Count > 0) { int start = sentences[0].Span?.Start ?? 0; int end = sentences[^1].Span?.End ?? start; pipelines.Add(new PipelineNode(sentences) { Span = new TextSpan(start, Math.Max(0, end - start)) }); } SkipNewLines(tokens, ref index); } - return new(new ScriptNode(pipelines), diagnostics); } @@ -77,54 +45,28 @@ public ParseResult Parse(string source) { if (index >= tokens.Count || tokens[index].Kind != ClassicTokenKind.Word) { - ClassicToken? token = index < tokens.Count ? tokens[index] : null; - diagnostics.Add(Diagnostic.Error("FLU1001", "Expected a verb at the start of the sentence.", token?.Span)); - SkipUntilBoundary(tokens, ref index); - return null; + ClassicToken? token = index < tokens.Count ? tokens[index] : null; diagnostics.Add(Diagnostic.Error("FLU1001", "Expected a verb at the start of the sentence.", token?.Span)); SkipUntilBoundary(tokens, ref index); return null; } - ClassicToken verbToken = tokens[index++]; - string verb = verbToken.Text.ToUpperInvariant(); - string? qualifier = null; - - if (index < tokens.Count && tokens[index].Kind == ClassicTokenKind.Word && _language.IsQualifier(tokens[index].Text)) - qualifier = tokens[index++].Text.ToUpperInvariant(); - - ClauseKind currentRole = ClauseKind.What; - var clauses = new List(); - int end = verbToken.Span.End; + ClassicToken verbToken = tokens[index++]; string verb = verbToken.Text.ToUpperInvariant(); string? qualifier = null; + if (index < tokens.Count && tokens[index].Kind == ClassicTokenKind.Word && _language.IsQualifier(tokens[index].Text)) qualifier = tokens[index++].Text.ToUpperInvariant(); + ClauseKind currentRole = ClauseKind.What; var clauses = new List(); int end = verbToken.Span.End; while (index < tokens.Count) { - ClassicToken token = tokens[index]; - if (token.Kind == ClassicTokenKind.NewLine || IsWord(token, "THEN")) break; - - if (token.Kind == ClassicTokenKind.Word && RoleKeywords.TryGetValue(token.Text, out ClauseKind role)) - { - currentRole = role; - end = token.Span.End; - index++; - continue; - } - - ExpressionNode expression = ToExpression(token); - clauses.Add(new ClauseNode(currentRole, expression) { Span = token.Span }); - end = token.Span.End; - index++; + ClassicToken token = tokens[index]; if (token.Kind == ClassicTokenKind.NewLine || IsWord(token, "THEN")) break; + if (token.Kind == ClassicTokenKind.Word && RoleKeywords.TryGetValue(token.Text, out ClauseKind role)) { currentRole = role; end = token.Span.End; index++; continue; } + ExpressionNode expression = ToExpression(token); clauses.Add(new ClauseNode(currentRole, expression) { Span = token.Span }); end = token.Span.End; index++; } - return new SentenceNode(verb, clauses) - { - Qualifier = qualifier, - Span = new TextSpan(verbToken.Span.Start, Math.Max(0, end - verbToken.Span.Start)) - }; + return new SentenceNode(verb, clauses) { Qualifier = qualifier, Span = new TextSpan(verbToken.Span.Start, Math.Max(0, end - verbToken.Span.Start)) }; } private static ExpressionNode ToExpression(ClassicToken token) { ExpressionNode expression = token.Kind switch { - ClassicTokenKind.Variable => new VariableExpression(token.Text), + ClassicTokenKind.Variable => ParseVariablePath(token.Text), ClassicTokenKind.Reference => new ReferenceExpression(token.Text), ClassicTokenKind.String when token.Text.Contains('[') => new InterpolatedStringExpression(token.Text), ClassicTokenKind.String => new LiteralExpression(token.Text), @@ -133,16 +75,15 @@ private static ExpressionNode ToExpression(ClassicToken token) return expression with { Span = token.Span }; } - private static bool IsWord(ClassicToken token, string text) => - token.Kind == ClassicTokenKind.Word && token.Text.Equals(text, StringComparison.OrdinalIgnoreCase); - - private static void SkipNewLines(IReadOnlyList tokens, ref int index) + private static ExpressionNode ParseVariablePath(string value) { - while (index < tokens.Count && tokens[index].Kind == ClassicTokenKind.NewLine) index++; + string[] parts = value.Split('.', StringSplitOptions.RemoveEmptyEntries); + ExpressionNode current = new VariableExpression(parts[0]); + for (int i = 1; i < parts.Length; i++) current = new PropertyExpression(current, parts[i]); + return current; } - private static void SkipUntilBoundary(IReadOnlyList tokens, ref int index) - { - while (index < tokens.Count && tokens[index].Kind != ClassicTokenKind.NewLine && !IsWord(tokens[index], "THEN")) index++; - } + private static bool IsWord(ClassicToken token, string text) => token.Kind == ClassicTokenKind.Word && token.Text.Equals(text, StringComparison.OrdinalIgnoreCase); + private static void SkipNewLines(IReadOnlyList tokens, ref int index) { while (index < tokens.Count && tokens[index].Kind == ClassicTokenKind.NewLine) index++; } + private static void SkipUntilBoundary(IReadOnlyList tokens, ref int index) { while (index < tokens.Count && tokens[index].Kind != ClassicTokenKind.NewLine && !IsWord(tokens[index], "THEN")) index++; } } diff --git a/tests/FluNET.Tests/ExpressionBindingTests.cs b/tests/FluNET.Tests/ExpressionBindingTests.cs new file mode 100644 index 0000000..ce31b63 --- /dev/null +++ b/tests/FluNET.Tests/ExpressionBindingTests.cs @@ -0,0 +1,31 @@ +using FluNET.Binding; +using FluNET.Language; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Parsing; + +namespace FluNET.Tests; + +public class ExpressionBindingTests +{ + [Test] + public void Parser_builds_property_expression_for_variable_path() + { + var parser = new ClassicParser(new LanguageRegistry().Snapshot); + var result = parser.Parse("SAY [user.name]"); + SentenceNode sentence = result.Script!.Pipelines.Single().Sentences.Single(); + Assert.That(sentence.Clauses.Single().Value, Is.TypeOf()); + } + + [Test] + public void Runtime_evaluator_reads_properties_and_interpolates_variables() + { + var evaluator = new ExpressionRuntimeEvaluator(); + var context = new ActivationContext(new Dictionary { ["user"] = new User("Ada") }); + object? property = evaluator.Evaluate(new PropertyExpression(new VariableExpression("user"), "name"), context); + object? text = evaluator.Evaluate(new InterpolatedStringExpression("Hello [user.name]"), context); + Assert.That(property, Is.EqualTo("Ada")); + Assert.That(text, Is.EqualTo("Hello Ada")); + } + + private sealed record User(string Name); +} From 6144455b154c86c7f7abe296b069733bf59eb412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:36:43 +0200 Subject: [PATCH 17/18] Infer capabilities and execution traits from metadata --- .../Language/LanguageCompiler.cs | 173 +++++++++++++++--- .../Language/MetadataConventionInference.cs | 47 +++++ .../MetadataConventionInferenceTests.cs | 17 ++ 3 files changed, 214 insertions(+), 23 deletions(-) create mode 100644 src/FluNet.Engine/Language/MetadataConventionInference.cs create mode 100644 tests/FluNET.Tests/MetadataConventionInferenceTests.cs diff --git a/src/FluNet.Engine/Language/LanguageCompiler.cs b/src/FluNet.Engine/Language/LanguageCompiler.cs index 1c8952d..2f47062 100644 --- a/src/FluNet.Engine/Language/LanguageCompiler.cs +++ b/src/FluNet.Engine/Language/LanguageCompiler.cs @@ -14,7 +14,12 @@ public sealed class LanguageCompiler VerbAttribute? explicitVerb = verbType.GetCustomAttribute(true); string? text = explicitVerb?.Text ?? InferFamilyKeyword(verbType) ?? prototype?.Text; if (string.IsNullOrWhiteSpace(text)) return null; - string[] synonyms = verbType.GetCustomAttributes(true).Select(x => x.Value).Concat(prototype?.Synonyms ?? []).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + string[] synonyms = verbType.GetCustomAttributes(true) + .Select(x => x.Value) + .Concat(prototype?.Synonyms ?? []) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); return new VerbIdentity(text.ToUpperInvariant(), synonyms); } @@ -23,20 +28,31 @@ public VerbDescriptor DescribeVerb(Type verbType, string text, IReadOnlyList constructors = DescribeConstructors(verbType); IReadOnlyList patterns = BuildPatterns(verbType, text, constructors); SentencePattern compatibilityPattern = patterns.FirstOrDefault()?.Pattern ?? BuildInterfacePattern(verbType, text); + string[] explicitCapabilities = verbType.GetCustomAttributes(true) + .Select(x => x.Capability) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return new VerbDescriptor(verbType, text, synonyms, compatibilityPattern, factory) { Constructors = constructors, Patterns = patterns, ResultType = InferResultType(verbType), FamilyType = InferFamilyType(verbType), - Capabilities = verbType.GetCustomAttributes(true).Select(x => x.Capability).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), - Traits = new(typeof(IPureOperation).IsAssignableFrom(verbType), typeof(IIdempotentOperation).IsAssignableFrom(verbType), typeof(IRetryableOperation).IsAssignableFrom(verbType), typeof(ITransactionalOperation).IsAssignableFrom(verbType), typeof(ILongRunningOperation).IsAssignableFrom(verbType), typeof(ISideEffectingOperation).IsAssignableFrom(verbType)) + Capabilities = MetadataConventionInference.InferCapabilities(text, patterns, explicitCapabilities, verbType), + Traits = MetadataConventionInference.InferTraits(text, verbType) }; } - public IReadOnlyList DescribeConstructors(Type type) => type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) - .Select(c => new ConstructorDescriptor(c, c.GetParameters().Select(p => DescribeParameter(type, p)).ToArray(), ConstructorActivatorCompiler.Compile(c))) - .OrderByDescending(x => x.RoleParameterCount).ThenBy(x => x.ServiceParameterCount).ToArray(); + public IReadOnlyList DescribeConstructors(Type type) => + type.GetConstructors(BindingFlags.Public | BindingFlags.Instance) + .Select(c => new ConstructorDescriptor( + c, + c.GetParameters().Select(p => DescribeParameter(type, p)).ToArray(), + ConstructorActivatorCompiler.Compile(c))) + .OrderByDescending(x => x.RoleParameterCount) + .ThenBy(x => x.ServiceParameterCount) + .ToArray(); private IReadOnlyList BuildPatterns(Type verbType, string text, IReadOnlyList constructors) { @@ -46,20 +62,38 @@ private IReadOnlyList BuildPatterns(Type verbType, string ClauseDescriptor[] clauses = constructor.Parameters.Where(x => x.Role != null).Select(ToClause).ToArray(); if (clauses.Length > 0) patterns.Add(new(new SentencePattern(text.ToUpperInvariant(), clauses), constructor)); } - if (patterns.Count == 0) patterns.Add(new(BuildInterfacePattern(verbType, text), constructors.FirstOrDefault())); + + if (patterns.Count == 0) + patterns.Add(new(BuildInterfacePattern(verbType, text), constructors.FirstOrDefault())); + return patterns.DistinctBy(x => PatternKey(x.Pattern), StringComparer.OrdinalIgnoreCase).ToArray(); } - private static string PatternKey(SentencePattern pattern) => string.Join("|", pattern.Clauses.Select(x => $"{x.Kind}:{x.Name}:{x.ValueType.FullName}:{x.Cardinality}:{x.Direction}")); - private static ClauseDescriptor ToClause(ParameterDescriptor p) => new(p.Role!.Value, p.ParameterType, !p.IsOptional, p.Name, p.Direction, p.IsParams ? RoleCardinality.ZeroOrMore : (p.IsOptional ? RoleCardinality.ZeroOrOne : RoleCardinality.One), p.Shape.ElementType); + private static string PatternKey(SentencePattern pattern) => string.Join("|", pattern.Clauses.Select(x => + $"{x.Kind}:{x.Name}:{x.ValueType.FullName}:{x.Cardinality}:{x.Direction}")); + + private static ClauseDescriptor ToClause(ParameterDescriptor p) => new( + p.Role!.Value, + p.ParameterType, + !p.IsOptional, + p.Name, + p.Direction, + p.IsParams ? RoleCardinality.ZeroOrMore : (p.IsOptional ? RoleCardinality.ZeroOrOne : RoleCardinality.One), + p.Shape.ElementType); private SentencePattern BuildInterfacePattern(Type verbType, string text) { var fallback = new List(); foreach (Type contract in verbType.GetInterfaces().Where(x => x.IsGenericType)) { - Type definition = contract.GetGenericTypeDefinition(); Type valueType = contract.GetGenericArguments()[0]; ClauseKind? kind = RoleKindFor(definition); if (kind == null) continue; - TypeShape shape = TypeShape.Analyze(valueType); RoleDirection direction = kind == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; + Type definition = contract.GetGenericTypeDefinition(); + Type valueType = contract.GetGenericArguments()[0]; + ClauseKind? kind = RoleKindFor(definition); + if (kind == null) continue; + TypeShape shape = TypeShape.Analyze(valueType); + RoleDirection direction = kind == ClauseKind.What && IsFamily(verbType, typeof(IGet), "Get") + ? RoleDirection.Output + : RoleDirection.Input; fallback.Add(new(kind.Value, valueType, true, null, direction, RoleCardinality.One, shape.ElementType)); } return new SentencePattern(text.ToUpperInvariant(), fallback); @@ -67,28 +101,121 @@ private SentencePattern BuildInterfacePattern(Type verbType, string text) private ParameterDescriptor DescribeParameter(Type verbType, ParameterInfo p) { - ClauseKind? role = InferRole(p); NullabilityInfo n = _nullability.Create(p); bool isParams = p.GetCustomAttribute() != null; + ClauseKind? role = InferRole(p); + NullabilityInfo n = _nullability.Create(p); + bool isParams = p.GetCustomAttribute() != null; bool optional = p.IsOptional || p.HasDefaultValue || p.GetCustomAttribute() != null || n.ReadState == NullabilityState.Nullable; - return new(p, p.Name ?? $"arg{p.Position}", p.ParameterType, role, InferDirection(verbType, p, role), optional, isParams, p.GetCustomAttribute() != null, n.ReadState, n.WriteState, TypeShape.Analyze(p.ParameterType)); + return new( + p, + p.Name ?? $"arg{p.Position}", + p.ParameterType, + role, + InferDirection(verbType, p, role), + optional, + isParams, + p.GetCustomAttribute() != null, + n.ReadState, + n.WriteState, + TypeShape.Analyze(p.ParameterType)); + } + + private static ClauseKind? InferRole(ParameterInfo p) + { + RoleAttribute? a = p.GetCustomAttribute(); + if (a != null) return a.Kind; + return p.Name?.ToLowerInvariant() switch + { + "what" => ClauseKind.What, + "from" => ClauseKind.From, + "to" => ClauseKind.To, + "using" => ClauseKind.Using, + "with" => ClauseKind.With, + "then" => ClauseKind.Then, + _ => null + }; + } + + private static RoleDirection InferDirection(Type t, ParameterInfo p, ClauseKind? r) + { + if (p.GetCustomAttribute() != null) return RoleDirection.Output; + if (p.GetCustomAttribute() != null) return RoleDirection.InputOutput; + if (p.GetCustomAttribute() != null) return RoleDirection.Input; + return r == ClauseKind.What && IsFamily(t, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; } - private static ClauseKind? InferRole(ParameterInfo p) { RoleAttribute? a = p.GetCustomAttribute(); if (a != null) return a.Kind; return p.Name?.ToLowerInvariant() switch { "what" => ClauseKind.What, "from" => ClauseKind.From, "to" => ClauseKind.To, "using" => ClauseKind.Using, "with" => ClauseKind.With, "then" => ClauseKind.Then, _ => null }; } - private static RoleDirection InferDirection(Type t, ParameterInfo p, ClauseKind? r) { if (p.GetCustomAttribute() != null) return RoleDirection.Output; if (p.GetCustomAttribute() != null) return RoleDirection.InputOutput; if (p.GetCustomAttribute() != null) return RoleDirection.Input; return r == ClauseKind.What && IsFamily(t, typeof(IGet), "Get") ? RoleDirection.Output : RoleDirection.Input; } - private static ClauseKind? RoleKindFor(Type d) => d == typeof(IWhat<>) ? ClauseKind.What : d == typeof(IFrom<>) ? ClauseKind.From : d == typeof(ITo<>) ? ClauseKind.To : d == typeof(IUsing<>) ? ClauseKind.Using : d == typeof(IWith<>) ? ClauseKind.With : d == typeof(IThen<>) ? ClauseKind.Then : null; - private static Type? InferResultType(Type t) => t.GetInterfaces().FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<>))?.GetGenericArguments()[0]; + private static ClauseKind? RoleKindFor(Type d) => + d == typeof(IWhat<>) ? ClauseKind.What : + d == typeof(IFrom<>) ? ClauseKind.From : + d == typeof(ITo<>) ? ClauseKind.To : + d == typeof(IUsing<>) ? ClauseKind.Using : + d == typeof(IWith<>) ? ClauseKind.With : + d == typeof(IThen<>) ? ClauseKind.Then : null; + + private static Type? InferResultType(Type t) => + t.GetInterfaces().FirstOrDefault(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IVerb<>))?.GetGenericArguments()[0]; private static Type? InferFamilyType(Type t) { - Type[] f = [typeof(IGet), typeof(ISave), typeof(ILoad), typeof(ISend), typeof(IDelete), typeof(IDownload), typeof(IPost), typeof(ITransform), typeof(ISay)]; Type? marker = f.FirstOrDefault(x => x.IsAssignableFrom(t)); if (marker != null) return marker; - Type? c = t.BaseType; while (c != null && c != typeof(object)) { Type candidate = c.IsGenericType ? c.GetGenericTypeDefinition() : c; if (KnownFamilyKeyword(candidate.Name) != null) return candidate; c = c.BaseType; } return null; + Type[] families = [typeof(IGet), typeof(ISave), typeof(ILoad), typeof(ISend), typeof(IDelete), typeof(IDownload), typeof(IPost), typeof(ITransform), typeof(ISay)]; + Type? marker = families.FirstOrDefault(x => x.IsAssignableFrom(t)); + if (marker != null) return marker; + Type? current = t.BaseType; + while (current != null && current != typeof(object)) + { + Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; + if (KnownFamilyKeyword(candidate.Name) != null) return candidate; + current = current.BaseType; + } + return null; } private static string? InferFamilyKeyword(Type t) { - if (typeof(IGet).IsAssignableFrom(t)) return "GET"; if (typeof(ISave).IsAssignableFrom(t)) return "SAVE"; if (typeof(ILoad).IsAssignableFrom(t)) return "LOAD"; if (typeof(ISend).IsAssignableFrom(t)) return "SEND"; if (typeof(IDelete).IsAssignableFrom(t)) return "DELETE"; if (typeof(IDownload).IsAssignableFrom(t)) return "DOWNLOAD"; if (typeof(IPost).IsAssignableFrom(t)) return "POST"; if (typeof(ITransform).IsAssignableFrom(t)) return "TRANSFORM"; if (typeof(ISay).IsAssignableFrom(t)) return "SAY"; - Type? c = t.BaseType; while (c != null && c != typeof(object)) { Type candidate = c.IsGenericType ? c.GetGenericTypeDefinition() : c; string? k = KnownFamilyKeyword(candidate.Name); if (k != null) return k; c = c.BaseType; } return null; + if (typeof(IGet).IsAssignableFrom(t)) return "GET"; + if (typeof(ISave).IsAssignableFrom(t)) return "SAVE"; + if (typeof(ILoad).IsAssignableFrom(t)) return "LOAD"; + if (typeof(ISend).IsAssignableFrom(t)) return "SEND"; + if (typeof(IDelete).IsAssignableFrom(t)) return "DELETE"; + if (typeof(IDownload).IsAssignableFrom(t)) return "DOWNLOAD"; + if (typeof(IPost).IsAssignableFrom(t)) return "POST"; + if (typeof(ITransform).IsAssignableFrom(t)) return "TRANSFORM"; + if (typeof(ISay).IsAssignableFrom(t)) return "SAY"; + + Type? current = t.BaseType; + while (current != null && current != typeof(object)) + { + Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; + string? keyword = KnownFamilyKeyword(candidate.Name); + if (keyword != null) return keyword; + current = current.BaseType; + } + return null; } - private static string? KnownFamilyKeyword(string n) => n.Split('`')[0].ToUpperInvariant() switch { "GET" => "GET", "SAVE" => "SAVE", "LOAD" => "LOAD", "SEND" => "SEND", "DELETE" => "DELETE", "DOWNLOAD" => "DOWNLOAD", "POST" => "POST", "TRANSFORM" => "TRANSFORM", "SAY" => "SAY", _ => null }; - private static bool IsFamily(Type t, Type marker, string legacy) { if (marker.IsAssignableFrom(t)) return true; Type? c = t.BaseType; while (c != null && c != typeof(object)) { Type candidate = c.IsGenericType ? c.GetGenericTypeDefinition() : c; if (candidate.Name.Split('`')[0].Equals(legacy, StringComparison.OrdinalIgnoreCase)) return true; c = c.BaseType; } return false; } + private static string? KnownFamilyKeyword(string n) => n.Split('`')[0].ToUpperInvariant() switch + { + "GET" => "GET", + "SAVE" => "SAVE", + "LOAD" => "LOAD", + "SEND" => "SEND", + "DELETE" => "DELETE", + "DOWNLOAD" => "DOWNLOAD", + "POST" => "POST", + "TRANSFORM" => "TRANSFORM", + "SAY" => "SAY", + _ => null + }; + + private static bool IsFamily(Type t, Type marker, string legacy) + { + if (marker.IsAssignableFrom(t)) return true; + Type? current = t.BaseType; + while (current != null && current != typeof(object)) + { + Type candidate = current.IsGenericType ? current.GetGenericTypeDefinition() : current; + if (candidate.Name.Split('`')[0].Equals(legacy, StringComparison.OrdinalIgnoreCase)) return true; + current = current.BaseType; + } + return false; + } } diff --git a/src/FluNet.Engine/Language/MetadataConventionInference.cs b/src/FluNet.Engine/Language/MetadataConventionInference.cs new file mode 100644 index 0000000..1a2ab4f --- /dev/null +++ b/src/FluNet.Engine/Language/MetadataConventionInference.cs @@ -0,0 +1,47 @@ +using FluNET.Syntax.Core; + +namespace FluNET.Language; + +internal static class MetadataConventionInference +{ + public static IReadOnlyList InferCapabilities( + string keyword, + IReadOnlyList patterns, + IEnumerable explicitCapabilities, + Type verbType) + { + var capabilities = new HashSet(explicitCapabilities, StringComparer.OrdinalIgnoreCase); + Type[] types = patterns.SelectMany(x => x.Pattern.Clauses).SelectMany(x => Flatten(x.ValueType)).Distinct().ToArray(); + string verb = keyword.ToUpperInvariant(); + + bool hasFileSystemType = types.Any(x => x == typeof(FileInfo) || x == typeof(DirectoryInfo)); + bool hasUri = types.Any(x => x == typeof(Uri)); + + if (hasFileSystemType && verb is "GET" or "LOAD") capabilities.Add("filesystem.read"); + if (hasFileSystemType && verb is "SAVE" or "DELETE" or "DOWNLOAD") capabilities.Add("filesystem.write"); + if (hasUri || verb is "POST" or "DOWNLOAD" or "SEND") capabilities.Add("network"); + if (verb == "SEND" && verbType.Name.Contains("Email", StringComparison.OrdinalIgnoreCase)) capabilities.Add("email.send"); + + return capabilities.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToArray(); + } + + public static ExecutionTraitsDescriptor InferTraits(string keyword, Type verbType) + { + string verb = keyword.ToUpperInvariant(); + return new( + Pure: typeof(IPureOperation).IsAssignableFrom(verbType), + Idempotent: typeof(IIdempotentOperation).IsAssignableFrom(verbType) || verb is "GET" or "LOAD", + Retryable: typeof(IRetryableOperation).IsAssignableFrom(verbType) || verb is "GET" or "LOAD", + Transactional: typeof(ITransactionalOperation).IsAssignableFrom(verbType), + LongRunning: typeof(ILongRunningOperation).IsAssignableFrom(verbType), + SideEffecting: typeof(ISideEffectingOperation).IsAssignableFrom(verbType) || verb is "SAVE" or "DELETE" or "POST" or "SEND" or "DOWNLOAD" or "SAY"); + } + + private static IEnumerable Flatten(Type type) + { + yield return type; + if (type.IsArray && type.GetElementType() is Type element) yield return element; + foreach (Type contract in type.GetInterfaces().Where(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>))) + yield return contract.GetGenericArguments()[0]; + } +} diff --git a/tests/FluNET.Tests/MetadataConventionInferenceTests.cs b/tests/FluNET.Tests/MetadataConventionInferenceTests.cs new file mode 100644 index 0000000..04d7b24 --- /dev/null +++ b/tests/FluNET.Tests/MetadataConventionInferenceTests.cs @@ -0,0 +1,17 @@ +using FluNET.Language; + +namespace FluNET.Tests; + +public class MetadataConventionInferenceTests +{ + [Test] + public void Standard_file_get_infers_read_capability_and_idempotent_traits() + { + VerbDescriptor get = new LanguageRegistry().Snapshot.GetVerbOverloads("GET") + .First(x => x.Patterns.SelectMany(p => p.Pattern.Clauses).Any(c => c.ValueType == typeof(FileInfo))); + + Assert.That(get.Capabilities, Does.Contain("filesystem.read")); + Assert.That(get.Traits.Idempotent, Is.True); + Assert.That(get.Traits.Retryable, Is.True); + } +} From 97b8d0d443f67621b01de897324e162b43f6c53a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pot=C4=99pa?= Date: Sun, 16 Aug 2026 18:37:36 +0200 Subject: [PATCH 18/18] Project multiple output bindings from CLR results --- .../Execution/BoundPipelineExecutor.cs | 30 ++--- .../Execution/OutputBindingProjector.cs | 104 ++++++++++++++++++ .../OutputBindingProjectorTests.cs | 38 +++++++ 3 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 src/FluNet.Engine/Execution/OutputBindingProjector.cs create mode 100644 tests/FluNET.Tests/OutputBindingProjectorTests.cs diff --git a/src/FluNet.Engine/Execution/BoundPipelineExecutor.cs b/src/FluNet.Engine/Execution/BoundPipelineExecutor.cs index 892712b..1c7952e 100644 --- a/src/FluNet.Engine/Execution/BoundPipelineExecutor.cs +++ b/src/FluNet.Engine/Execution/BoundPipelineExecutor.cs @@ -1,7 +1,5 @@ using FluNET.Binding; using FluNET.Execution.Capabilities; -using FluNET.Syntax.Ast; -using FluNET.Syntax.Core; namespace FluNET.Execution; @@ -12,19 +10,22 @@ public sealed record BoundPipelineExecutionResult( /// /// Executes a semantically bound pipeline. Values flow through THEN implicitly and output -/// WHAT variables are populated from each sentence result for Classic compatibility. +/// bindings are projected from each CLR result by name or position. /// public sealed class BoundPipelineExecutor { private readonly BoundSentenceExecutor _sentenceExecutor; private readonly ICapabilityPolicy _capabilities; + private readonly OutputBindingProjector _outputs; public BoundPipelineExecutor( BoundSentenceExecutor? sentenceExecutor = null, - ICapabilityPolicy? capabilities = null) + ICapabilityPolicy? capabilities = null, + OutputBindingProjector? outputs = null) { _sentenceExecutor = sentenceExecutor ?? new BoundSentenceExecutor(); _capabilities = capabilities ?? AllowAllCapabilityPolicy.Instance; + _outputs = outputs ?? new OutputBindingProjector(); } public async ValueTask ExecuteAsync( @@ -42,12 +43,13 @@ public async ValueTask ExecuteAsync( foreach (BoundSentence sentence in pipeline.Sentences) { EnsureCapabilities(sentence); - var activation = new ActivationContext(variables, pipelineValue, services); BoundExecutionResult execution = await _sentenceExecutor.ExecuteAsync(sentence, activation, cancellationToken); executions.Add(execution); pipelineValue = execution.Result; - StoreOutputBindings(sentence, execution.Result, variables); + + foreach (KeyValuePair binding in _outputs.Project(sentence, execution.Result)) + variables[binding.Key] = binding.Value; } return new(pipelineValue, variables, executions); @@ -61,20 +63,4 @@ private void EnsureCapabilities(BoundSentence sentence) throw new CapabilityDeniedException(capability, sentence.Verb); } } - - private static void StoreOutputBindings( - BoundSentence sentence, - object? result, - IDictionary variables) - { - foreach (BoundRole role in sentence.Roles.Where(x => - x.Descriptor.Direction is RoleDirection.Output or RoleDirection.InputOutput)) - { - foreach (BoundValue value in role.Values) - { - if (value.Source is VariableExpression variable) - variables[variable.Name] = result; - } - } - } } diff --git a/src/FluNet.Engine/Execution/OutputBindingProjector.cs b/src/FluNet.Engine/Execution/OutputBindingProjector.cs new file mode 100644 index 0000000..8dbfee0 --- /dev/null +++ b/src/FluNet.Engine/Execution/OutputBindingProjector.cs @@ -0,0 +1,104 @@ +using FluNET.Binding; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Core; +using System.Collections; +using System.Reflection; +using System.Runtime.CompilerServices; + +namespace FluNET.Execution; + +/// +/// Projects a single CLR result into one or many output WHAT bindings. Named slots prefer +/// dictionary/property metadata; positional tuples/lists are used as a fallback. +/// +public sealed class OutputBindingProjector +{ + public IReadOnlyDictionary Project(BoundSentence sentence, object? result) + { + var bindings = sentence.Roles + .Where(x => x.Descriptor.Direction is RoleDirection.Output or RoleDirection.InputOutput) + .SelectMany(role => role.Values + .Where(value => value.Source is VariableExpression) + .Select(value => new OutputSlot( + ((VariableExpression)value.Source).Name, + role.Descriptor.Name))) + .ToArray(); + + if (bindings.Length == 0) + return new Dictionary(); + + if (bindings.Length == 1) + return new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [bindings[0].VariableName] = result + }; + + var projected = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (TryProjectDictionary(result, bindings, projected)) return projected; + if (TryProjectProperties(result, bindings, projected)) return projected; + if (TryProjectTuple(result, bindings, projected)) return projected; + if (TryProjectList(result, bindings, projected)) return projected; + + throw new InvalidOperationException( + $"Verb '{sentence.Verb.Text}' returned '{result?.GetType().FullName ?? "null"}' for {bindings.Length} output bindings, but the result cannot be projected by name or position."); + } + + private static bool TryProjectDictionary(object? result, OutputSlot[] slots, IDictionary output) + { + if (result is IReadOnlyDictionary readOnly) + { + foreach (OutputSlot slot in slots) + { + string key = slot.SlotName ?? slot.VariableName; + KeyValuePair match = readOnly.FirstOrDefault(x => x.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); + if (match.Key == null) { output.Clear(); return false; } + output[slot.VariableName] = match.Value; + } + return true; + } + + if (result is IDictionary dictionary) + { + foreach (OutputSlot slot in slots) + { + string key = slot.SlotName ?? slot.VariableName; + object? foundKey = dictionary.Keys.Cast().FirstOrDefault(x => x?.ToString()?.Equals(key, StringComparison.OrdinalIgnoreCase) == true); + if (foundKey == null) { output.Clear(); return false; } + output[slot.VariableName] = dictionary[foundKey]; + } + return true; + } + + return false; + } + + private static bool TryProjectProperties(object? result, OutputSlot[] slots, IDictionary output) + { + if (result == null) return false; + PropertyInfo[] properties = result.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (OutputSlot slot in slots) + { + string name = slot.SlotName ?? slot.VariableName; + PropertyInfo? property = properties.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (property == null) { output.Clear(); return false; } + output[slot.VariableName] = property.GetValue(result); + } + return true; + } + + private static bool TryProjectTuple(object? result, OutputSlot[] slots, IDictionary output) + { + if (result is not ITuple tuple || tuple.Length != slots.Length) return false; + for (int i = 0; i < slots.Length; i++) output[slots[i].VariableName] = tuple[i]; + return true; + } + + private static bool TryProjectList(object? result, OutputSlot[] slots, IDictionary output) + { + if (result is not IList list || list.Count != slots.Length) return false; + for (int i = 0; i < slots.Length; i++) output[slots[i].VariableName] = list[i]; + return true; + } + + private sealed record OutputSlot(string VariableName, string? SlotName); +} diff --git a/tests/FluNET.Tests/OutputBindingProjectorTests.cs b/tests/FluNET.Tests/OutputBindingProjectorTests.cs new file mode 100644 index 0000000..d6c41bc --- /dev/null +++ b/tests/FluNET.Tests/OutputBindingProjectorTests.cs @@ -0,0 +1,38 @@ +using FluNET.Binding; +using FluNET.Execution; +using FluNET.Language; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Core; + +namespace FluNET.Tests; + +public class OutputBindingProjectorTests +{ + [Test] + public void Projector_maps_multiple_outputs_from_named_properties() + { + var verb = new VerbDescriptor(typeof(DummyVerb), "GET", [], new SentencePattern("GET", []), () => null) + { + ResultType = typeof(UserResult) + }; + BoundRole name = OutputRole("name", "userName"); + BoundRole email = OutputRole("email", "userEmail"); + var sentence = new BoundSentence(verb, null, [name, email], typeof(UserResult), 0); + + IReadOnlyDictionary projected = new OutputBindingProjector() + .Project(sentence, new UserResult("Ada", "ada@example.test")); + + Assert.That(projected["userName"], Is.EqualTo("Ada")); + Assert.That(projected["userEmail"], Is.EqualTo("ada@example.test")); + } + + private static BoundRole OutputRole(string slot, string variable) + { + var descriptor = new ClauseDescriptor(ClauseKind.What, typeof(string), true, slot, RoleDirection.Output); + var value = new BoundValue(new VariableExpression(variable), typeof(string), typeof(string), null, 0); + return new BoundRole(descriptor, [value]); + } + + private sealed class DummyVerb { } + private sealed record UserResult(string Name, string Email); +}