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 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]"); + } +} diff --git a/src/FluNet.Engine/Binding/BoundNodes.cs b/src/FluNet.Engine/Binding/BoundNodes.cs new file mode 100644 index 0000000..b387232 --- /dev/null +++ b/src/FluNet.Engine/Binding/BoundNodes.cs @@ -0,0 +1,28 @@ +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, + 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) +{ + 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/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/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/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/SemanticBinder.cs b/src/FluNet.Engine/Binding/SemanticBinder.cs new file mode 100644 index 0000000..6974489 --- /dev/null +++ b/src/FluNet.Engine/Binding/SemanticBinder.cs @@ -0,0 +1,118 @@ +using FluNET.Diagnostics; +using FluNET.Language; +using FluNET.Language.Metadata; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Core; +using System.Reflection; + +namespace FluNET.Binding; + +public sealed class SemanticBinder +{ + private readonly LanguageSnapshot _language; + private readonly ValueResolverRegistry _resolvers; + private readonly ValueConversionRegistry _conversions; + + 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).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); } + } + + 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(); + 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; + 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 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 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 { 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; + 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 => 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; + 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 or PropertyExpression) + { + 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; + 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 Type? InferExpressionType(ExpressionNode expression, BindingContext context) + { + 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 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/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/Binding/VerbActivator.cs b/src/FluNet.Engine/Binding/VerbActivator.cs new file mode 100644 index 0000000..669f48d --- /dev/null +++ b/src/FluNet.Engine/Binding/VerbActivator.cs @@ -0,0 +1,43 @@ +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); + +public sealed class VerbActivator +{ + private readonly ExpressionRuntimeEvaluator _expressions = new(); + + 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 p = constructor.Parameters[i]; + if (p.FromServices || p.Role == null) + { + 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}'."); } + } + 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."); + } + + 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 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/Compilation/ClassicCompiler.cs b/src/FluNet.Engine/Compilation/ClassicCompiler.cs new file mode 100644 index 0000000..35053fc --- /dev/null +++ b/src/FluNet.Engine/Compilation/ClassicCompiler.cs @@ -0,0 +1,39 @@ +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); +} + +public sealed class ClassicCompiler +{ + private readonly ClassicParser _parser; + private readonly SemanticBinder _binder; + + public ClassicCompiler(LanguageSnapshot language, ValueResolverRegistry? resolvers = null, ValueConversionRegistry? conversions = null) + { + _parser = new ClassicParser(language); + _binder = new SemanticBinder(language, resolvers, conversions); + } + + 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/Execution/BoundPipelineExecutor.cs b/src/FluNet.Engine/Execution/BoundPipelineExecutor.cs new file mode 100644 index 0000000..1c7952e --- /dev/null +++ b/src/FluNet.Engine/Execution/BoundPipelineExecutor.cs @@ -0,0 +1,66 @@ +using FluNET.Binding; +using FluNET.Execution.Capabilities; + +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 +/// 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, + OutputBindingProjector? outputs = null) + { + _sentenceExecutor = sentenceExecutor ?? new BoundSentenceExecutor(); + _capabilities = capabilities ?? AllowAllCapabilityPolicy.Instance; + _outputs = outputs ?? new OutputBindingProjector(); + } + + 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; + + foreach (KeyValuePair binding in _outputs.Project(sentence, execution.Result)) + variables[binding.Key] = binding.Value; + } + + 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); + } + } +} 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/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/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/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/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/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/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 new file mode 100644 index 0000000..2f47062 --- /dev/null +++ b/src/FluNet.Engine/Language/LanguageCompiler.cs @@ -0,0 +1,221 @@ +using FluNET.Language.Metadata; +using FluNET.Syntax.Core; +using FluNET.Syntax.Nouns; +using System.Reflection; + +namespace FluNET.Language; + +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, IReadOnlyList synonyms, Func factory) + { + 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 = 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(); + + 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 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; + 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 p) + { + 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? 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(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? 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? 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/LanguageDescriptors.cs b/src/FluNet.Engine/Language/LanguageDescriptors.cs new file mode 100644 index 0000000..1b7d6bb --- /dev/null +++ b/src/FluNet.Engine/Language/LanguageDescriptors.cs @@ -0,0 +1,48 @@ +using FluNET.Language.Metadata; +using FluNET.Syntax.Core; + +namespace FluNET.Language; + +public sealed record VerbIdentity(string Text, IReadOnlyList Synonyms); + +public sealed record ExecutionTraitsDescriptor( + bool Pure, + bool Idempotent, + bool Retryable, + bool Transactional, + 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}"; + 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 IReadOnlyList Patterns { get; init; } = []; + 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 +{ + 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..96f578e --- /dev/null +++ b/src/FluNet.Engine/Language/LanguageIntrospection.cs @@ -0,0 +1,52 @@ +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, + patterns = (v.Patterns.Count > 0 ? v.Patterns.Select(x => x.Pattern) : [v.Pattern]).Select(pattern => new + { + 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 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.SelectMany(v => + { + 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/src/FluNet.Engine/Language/LanguageRegistry.cs b/src/FluNet.Engine/Language/LanguageRegistry.cs index b50de7e..a622421 100644 --- a/src/FluNet.Engine/Language/LanguageRegistry.cs +++ b/src/FluNet.Engine/Language/LanguageRegistry.cs @@ -1,186 +1,70 @@ using FluNET.Keywords; +using FluNET.Language.Metadata; 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. -/// 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 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.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 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 RegisterAssemblies(IEnumerable assemblies) { foreach (Assembly assembly in assemblies) { - if (!_assemblies.Add(assembly)) - continue; - - Type[] types; - try - { - types = assembly.GetTypes(); - } - catch (ReflectionTypeLoadException ex) + 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) { - types = ex.Types.Where(x => x != null).Cast().ToArray(); + QualifierAttribute? qualifier = type.GetCustomAttribute(true); + if (qualifier != null) RegisterQualifier(qualifier.Text, qualifier.ValueType); + if (typeof(IWord).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface) RegisterWord(type); } - - 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? 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 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) => - _verbs.TryGetValue(text, out descriptor); - - public Type? GetVerbBaseType(string text) - { - if (!_verbs.TryGetValue(text, out VerbDescriptor? descriptor)) - 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(); - 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[keyword.Text] = word; - foreach (string synonym in synonyms) - _words[synonym] = word; - - 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; - foreach (string synonym in synonyms) - _verbs[synonym] = verbDescriptor; - } - } - - private static SentencePattern BuildPattern(Type verbType, string text) - { - List clauses = []; - foreach (Type contract in verbType.GetInterfaces().Where(x => x.IsGenericType)) + Func factory = () => CreatePrototype(type) as IWord; IWord? prototype = factory(); + if (typeof(IVerb).IsAssignableFrom(type)) { - 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 - { - 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; + 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 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 RegisterStandardQualifiers() - { - foreach (string qualifier in new[] { "TEXT", "JSON", "XML", "BINARY", "CSV", "HTML", "YAML", "IMAGE", "VIDEO", "AUDIO" }) - RegisterQualifier(qualifier); - } + 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/LanguageSnapshot.cs b/src/FluNet.Engine/Language/LanguageSnapshot.cs new file mode 100644 index 0000000..9d21abf --- /dev/null +++ b/src/FluNet.Engine/Language/LanguageSnapshot.cs @@ -0,0 +1,31 @@ +namespace FluNET.Language; + +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? 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; } + var verbMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); + 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 ?? 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/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/src/FluNet.Engine/Language/Metadata/Descriptors.cs b/src/FluNet.Engine/Language/Metadata/Descriptors.cs new file mode 100644 index 0000000..7c0b014 --- /dev/null +++ b/src/FluNet.Engine/Language/Metadata/Descriptors.cs @@ -0,0 +1,26 @@ +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, + Func Activator) +{ + public int RoleParameterCount => Parameters.Count(x => x.Role != null); + public int ServiceParameterCount => Parameters.Count(x => x.FromServices || x.Role == null); +} diff --git a/src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs b/src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs new file mode 100644 index 0000000..e402c97 --- /dev/null +++ b/src/FluNet.Engine/Language/Metadata/LanguageAttributes.cs @@ -0,0 +1,54 @@ +namespace FluNET.Language.Metadata; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = false, Inherited = true)] +public sealed class VerbAttribute(string text) : Attribute +{ + public string Text { get; } = text; +} + +[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/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/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/Ast/AstNodes.cs b/src/FluNet.Engine/Syntax/Ast/AstNodes.cs index 19420be..cb543c9 100644 --- a/src/FluNet.Engine/Syntax/Ast/AstNodes.cs +++ b/src/FluNet.Engine/Syntax/Ast/AstNodes.cs @@ -1,28 +1,30 @@ +using FluNET.Diagnostics; using FluNET.Language; namespace FluNET.Syntax.Ast; /// -/// Stable 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; public sealed record PropertyExpression(ExpressionNode Target, string Property) : ExpressionNode; public sealed record InterpolatedStringExpression(string Template) : ExpressionNode; +public sealed record PipelineValueExpression : ExpressionNode; 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/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/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/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/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/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/src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs b/src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs new file mode 100644 index 0000000..474b2e7 --- /dev/null +++ b/src/FluNet.Engine/Syntax/Parsing/ClassicParser.cs @@ -0,0 +1,89 @@ +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); +} + +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 => 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), + _ => new LiteralExpression(token.Text) + }; + return expression with { Span = token.Span }; + } + + private static ExpressionNode ParseVariablePath(string value) + { + 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 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.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/CapabilityPolicyTests.cs b/tests/FluNET.Tests/CapabilityPolicyTests.cs new file mode 100644 index 0000000..9c55a03 --- /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 +{ + [Test] + public void Explicit_policy_denies_unlisted_capability() + { + VerbDescriptor get = new LanguageRegistry().Snapshot.GetVerbOverloads("GET").First(); + var policy = new ExplicitCapabilityPolicy(["filesystem.write"]); + Assert.That(policy.IsAllowed("filesystem.read", get), Is.False); + } +} diff --git a/tests/FluNET.Tests/ClassicCompilerTests.cs b/tests/FluNET.Tests/ClassicCompilerTests.cs new file mode 100644 index 0000000..f162283 --- /dev/null +++ b/tests/FluNET.Tests/ClassicCompilerTests.cs @@ -0,0 +1,18 @@ +using FluNET.Compilation; +using FluNET.Language; + +namespace FluNET.Tests; + +public class ClassicCompilerTests +{ + [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.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 new file mode 100644 index 0000000..57adf4a --- /dev/null +++ b/tests/FluNET.Tests/ClassicParserTests.cs @@ -0,0 +1,31 @@ +using FluNET.Language; +using FluNET.Syntax.Ast; +using FluNET.Syntax.Parsing; + +namespace FluNET.Tests; + +public class ClassicParserTests +{ + [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.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); + } + + [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 = 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/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); +} 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 @@ - + diff --git a/tests/FluNET.Tests/LanguageBuildTests.cs b/tests/FluNET.Tests/LanguageBuildTests.cs new file mode 100644 index 0000000..fd06418 --- /dev/null +++ b/tests/FluNET.Tests/LanguageBuildTests.cs @@ -0,0 +1,14 @@ +using FluNET.Language; + +namespace FluNET.Tests; + +public class LanguageBuildTests +{ + [Test] + public void Registry_build_returns_snapshot_and_language_diagnostics() + { + LanguageBuildResult result = new LanguageRegistry().Build(); + 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 new file mode 100644 index 0000000..dbb4246 --- /dev/null +++ b/tests/FluNET.Tests/LanguageCompilerIdentityTests.cs @@ -0,0 +1,33 @@ +using FluNET.Language; +using FluNET.Language.Metadata; +using FluNET.Syntax.Core; + +namespace FluNET.Tests; + +public class LanguageCompilerIdentityTests +{ + [Test] + public void Verb_attribute_defines_identity_without_instantiating_the_type() + { + var compiler = new LanguageCompiler(); + VerbIdentity? identity = compiler.DescribeVerbIdentity(typeof(AbstractAttributedVerb)); + Assert.That(identity, Is.Not.Null); + Assert.That(identity!.Text, Is.EqualTo("CUSTOM")); + Assert.That(identity.Synonyms, Does.Contain("ALT")); + } + + [Test] + public void Semantic_family_marker_defines_standard_keyword() + { + var compiler = new LanguageCompiler(); + VerbIdentity? identity = compiler.DescribeVerbIdentity(typeof(AbstractGetVerb)); + 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 AbstractGetVerb : IGet { } +} diff --git a/tests/FluNET.Tests/LanguageIntrospectionTests.cs b/tests/FluNET.Tests/LanguageIntrospectionTests.cs new file mode 100644 index 0000000..c068681 --- /dev/null +++ b/tests/FluNET.Tests/LanguageIntrospectionTests.cs @@ -0,0 +1,23 @@ +using FluNET.Language; + +namespace FluNET.Tests; + +public class LanguageIntrospectionTests +{ + [Test] + public void Manifest_contains_compiled_get_metadata() + { + LanguageSnapshot snapshot = new LanguageRegistry().Snapshot; + string json = LanguageIntrospection.ToJson(snapshot); + Assert.That(json, Does.Contain("GET")); + Assert.That(json, Does.Contain("patterns")); + Assert.That(json, Does.Contain("resultType")); + } + + [Test] + public void Language_validator_accepts_standard_snapshot_without_missing_module_dependencies() + { + LanguageSnapshot snapshot = new LanguageRegistry().Snapshot; + 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 new file mode 100644 index 0000000..ae0084d --- /dev/null +++ b/tests/FluNET.Tests/LanguageMetadataTests.cs @@ -0,0 +1,37 @@ +using FluNET.Language; +using FluNET.Language.Metadata; + +namespace FluNET.Tests; + +public class LanguageMetadataTests +{ + [Test] + public void Type_shape_distinguishes_scalar_from_collection_value() + { + TypeShape scalar = TypeShape.Analyze(typeof(FileInfo)); + TypeShape array = TypeShape.Analyze(typeof(FileInfo[])); + Assert.That(scalar.IsCollection, Is.False); + Assert.That(array.IsCollection, Is.True); + Assert.That(array.ElementType, Is.EqualTo(typeof(FileInfo))); + } + + [Test] + public void Constructor_metadata_uses_roles_and_params_for_syntactic_cardinality() + { + var compiler = new LanguageCompiler(); + ConstructorDescriptor constructor = compiler.DescribeConstructors(typeof(ReflectionFixture)).Single(); + ParameterDescriptor what = constructor.Parameters[0]; + ParameterDescriptor from = constructor.Parameters[1]; + 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) { } + } +} diff --git a/tests/FluNET.Tests/LanguagePatternTests.cs b/tests/FluNET.Tests/LanguagePatternTests.cs new file mode 100644 index 0000000..3bf4bd0 --- /dev/null +++ b/tests/FluNET.Tests/LanguagePatternTests.cs @@ -0,0 +1,30 @@ +using FluNET.Language; +using FluNET.Language.Metadata; +using FluNET.Syntax.Core; + +namespace FluNET.Tests; + +public class LanguagePatternTests +{ + [Test] + public void Compiler_creates_distinct_sentence_patterns_from_role_constructors() + { + var compiler = new LanguageCompiler(); + 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")] + 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(); + } +} 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/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); + } +} 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); +} diff --git a/tests/FluNET.Tests/SemanticBinderTests.cs b/tests/FluNET.Tests/SemanticBinderTests.cs new file mode 100644 index 0000000..f23a1d4 --- /dev/null +++ b/tests/FluNET.Tests/SemanticBinderTests.cs @@ -0,0 +1,31 @@ +using FluNET.Binding; +using FluNET.Language; +using FluNET.Syntax.Ast; + +namespace FluNET.Tests; + +public class SemanticBinderTests +{ + [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"))]); + BindingResult result = binder.BindSentence(sentence); + 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); + } + + [Test] + public void Binder_reports_unknown_verbs_without_execution() + { + var binder = new SemanticBinder(new LanguageRegistry().Snapshot); + 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 new file mode 100644 index 0000000..cc274a9 --- /dev/null +++ b/tests/FluNET.Tests/ValueConversionRegistryTests.cs @@ -0,0 +1,19 @@ +using FluNET.Binding; + +namespace FluNET.Tests; + +public class ValueConversionRegistryTests +{ + [Test] + public void Numeric_conversion_has_higher_cost_than_exact_match() + { + var conversions = new ValueConversionRegistry(); + 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 new file mode 100644 index 0000000..453cc9b --- /dev/null +++ b/tests/FluNET.Tests/ValueResolverRegistryTests.cs @@ -0,0 +1,33 @@ +using FluNET.Binding; + +namespace FluNET.Tests; + +public class ValueResolverRegistryTests +{ + [Test] + public void Reflection_fallback_resolves_enum_and_string_constructor_types() + { + var resolvers = new ValueResolverRegistry(); + 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")); + } + + [Test] + public void Repeated_values_resolve_to_array_shape() + { + var resolvers = new ValueResolverRegistry(); + var context = new ResolutionContext(typeof(FileInfo[])); + 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 + { + public StringConstructed(string value) => Value = value; + public string Value { get; } + } +} diff --git a/tests/FluNET.Tests/VerbActivatorTests.cs b/tests/FluNET.Tests/VerbActivatorTests.cs new file mode 100644 index 0000000..4f0a155 --- /dev/null +++ b/tests/FluNET.Tests/VerbActivatorTests.cs @@ -0,0 +1,22 @@ +using FluNET.Binding; +using FluNET.Language; +using FluNET.Syntax.Ast; + +namespace FluNET.Tests; + +public class VerbActivatorTests +{ + [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"))]); + BindingResult binding = binder.BindSentence(sentence); + Assert.That(binding.Success, Is.True); + var activator = new VerbActivator(); + var verb = activator.Create(binding.Value!); + Assert.That(verb.Text, Is.EqualTo("GET").IgnoreCase); + Assert.That(verb.GetType().Name, Is.EqualTo("GetText")); + } +}