diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..cbfd1173a5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +Power Fx is a low-code general-purpose programming language based on spreadsheet-like formulas. This is the open-source C# implementation used across Microsoft Power Platform. The codebase contains a complete compiler and interpreter. + +## Build Commands + +```bash +# Build (from repo root) +dotnet build src/Microsoft.PowerFx.sln + +# Build specific configuration (Debug, Release, DebugAll, ReleaseAll, Debug462, Debug70) +dotnet build src/Microsoft.PowerFx.sln -p:Configuration=Release + +# Run all tests +dotnet test src/Microsoft.PowerFx.sln + +# Run tests for a specific project +dotnet test src/tests/Microsoft.PowerFx.Core.Tests.Shared/Microsoft.PowerFx.Core.Tests.Shared.csproj + +# Run a single test by name +dotnet test src/Microsoft.PowerFx.sln --filter "FullyQualifiedName~YourTestName" + +# Run tests by category +dotnet test src/Microsoft.PowerFx.sln --filter "Category=ExpressionTest" + +# Build local NuGet packages (from src/ directory, outputs to src/outputpackages/) +.\src\buildLocalPackages.cmd [Configuration] + +# Run tests on both net462 and net7.0 via VSTest (from src/ directory) +.\src\runLocalTests.cmd [Configuration] +``` + +**Build configurations**: `Debug`/`Release` (single-target), `Debug462`/`Release462` (net462 only), `Debug70`/`Release70` (net7.0 only), `DebugAll`/`ReleaseAll` (multi-target, used by local build scripts). + +## Architecture + +### Compiler Pipeline + +``` +Expression Text -> [Lexer] -> Tokens -> [Parser] -> AST -> [Binder] -> Typed AST -> [IR Translator] -> IR -> [Interpreter/Backend] -> Result +``` + +### Core Libraries + +1. **Microsoft.PowerFx.Core** - Compiler only (no evaluation) + - `Lexer/` - Tokenization with culture-aware parsing + - `Parser/` - Recursive descent parser producing AST nodes (`Syntax/Nodes/`) + - `Binding/Binder.cs` (~240KB) - Heart of semantic analysis, symbol resolution, type checking + - `Types/DType.cs` (~160KB) - Internal discriminated union type representation (via `DKind` enum) + - `IR/` - Intermediate Representation with explicit coercion nodes, normalized operators + - `Texl/Builtins/` - Function signatures and type checking only (no implementations) + - `Public/Engine.cs` - Main entry point for compilation + - `Public/CheckResult.cs` - Lazy compilation pipeline result + +2. **Microsoft.PowerFx.Interpreter** - Execution engine + - `EvalVisitor.cs` - Walks IR tree to compute results + - `Functions/Library*.cs` (~144KB) - Function implementations + - `RecalcEngine.cs` - Extends `Engine` with evaluation and reactive formulas + +3. **Microsoft.PowerFx.Connectors** - OpenAPI connector support for external APIs +4. **Microsoft.PowerFx.Json** - JSON serialization/deserialization +5. **Microsoft.PowerFx.LanguageServerProtocol** - LSP for IDE integration +6. **Microsoft.PowerFx.Repl** - Read-Eval-Print-Loop + +### Key Architecture Patterns + +**Separation of Compilation and Execution**: Core compiles to IR with zero evaluation code. Multiple backends (JavaScript, SQL, etc.) can consume the same IR independently. + +**Symbol Table Composition**: Layered symbol tables (Config -> Engine -> Parameters) for flexible scoping: +- `SymbolTable` (mutable) / `ReadOnlySymbolTable` (immutable, composable) for definitions +- `SymbolValues` for runtime values paired with a SymbolTable + +**CheckResult Workflow**: +```csharp +var check = engine.Check(expressionText, parameterType); +check.ThrowOnErrors(); +var result = check.Eval(); // Interpreter only +``` + +**Type System**: `DType` is the internal representation, `FormulaType` is the public API wrapper. `CoercionMatrix.cs` defines valid conversions, `BinaryOpMatrix.cs` defines operator type rules. + +**Display Names vs Logical Names**: Dual tracking throughout - display names are user-facing/localized (e.g., "First Name"), logical names are internal identifiers (e.g., "nwind_firstname"). + +### Naming: "Texl" = Power Fx + +"Texl" is the internal codename (from Excel heritage). `TexlFunction`, `TexlLexer`, `TexlParser` all refer to Power Fx components. + +## Adding a New Built-in Function + +1. **Define signature** in `src/libraries/Microsoft.PowerFx.Core/Texl/Builtins/NewFunction.cs`: + - Extend `TexlFunction`, implement `CheckInvocation` for type checking + - Register in `BuiltinFunctionsCore._library` + +2. **Implement logic** in `src/libraries/Microsoft.PowerFx.Interpreter/Functions/Library*.cs`: + - Return `FormulaValue` results + - Register in appropriate `Library` category + +3. **Add tests** in `src/tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/NewFunction.txt` + +## Expression Test Cases + +Tests use a `.txt` format in `tests/Microsoft.PowerFx.Core.Tests.Shared/ExpressionTestCases/`: + +``` +>> If(true, "yes", "no") +"yes" + +>> 1+1 +2 + +>> 1/0 +Error({Kind:ErrorKind.Div0}) +``` + +- `BaseRunner.cs` is the test harness infrastructure +- Tests run across multiple backends/configurations +- Special markers: `#skip`, `#error`, `#novalue` +- 60-second timeout per test + +## Important Concepts + +- **Cooperative Cancellation**: Interpreter checks `CancellationToken` and calls `Governor.Poll()` in loops to prevent runaway evaluation. +- **Immutability**: Most core data structures (DType, IR nodes, bound trees) are immutable. Symbol tables are mutable but have version hashes to detect concurrent mutations. +- **DPath**: Represents paths through type structure (e.g., "record.field.subfield"), used for type navigation and error reporting. +- **GuardSingleThreaded**: Detects concurrent mutations of mutable structures in development builds. +- **Feature Flags**: `Features` class controls language behavior. `Features.PowerFxV1` is the current standard. Configured via `PowerFxConfig`. +- **UDFs**: User-defined functions support recursion with stack depth tracking. Added via `Engine.AddUserDefinedFunction()`. + +## Code Quality + +- **StyleCop analyzers** enforce code style (configured in `src/PowerFx.ruleset`, relaxed in `src/PowerFx.Tests.ruleset`) +- **Warnings treated as errors** in Release builds +- C# 10.0 language version, .editorconfig enforces formatting (4-space indent, CRLF, UTF-8 BOM) +- Multi-targets: .NET Standard 2.0, .NET Framework 4.6.2, .NET 7.0 +- Tests use **xUnit** with aggressive parallelization +- Test projects organized into `.Net4.6.2/` and `.Net7.0/` folders with `.Shared/` projects for shared code +- Versioning via Nerdbank.GitVersioning (NBGV), config in `version.json` (base version: 1.8) diff --git a/src/libraries/Microsoft.PowerFx.Core/Functions/UserDefinedFunction.cs b/src/libraries/Microsoft.PowerFx.Core/Functions/UserDefinedFunction.cs index 93e2d20cb2..48e2078185 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Functions/UserDefinedFunction.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Functions/UserDefinedFunction.cs @@ -25,6 +25,7 @@ using Microsoft.PowerFx.Syntax; using Microsoft.PowerFx.Types; using static Microsoft.PowerFx.Core.Localization.TexlStrings; +using Attribute = Microsoft.PowerFx.Core.Parser.Attribute; using CallNode = Microsoft.PowerFx.Syntax.CallNode; namespace Microsoft.PowerFx.Core.Functions @@ -40,6 +41,9 @@ internal class UserDefinedFunction : TexlFunction, IExternalPageableSymbol private readonly IEnumerable _args; private TexlBinding _binding; private readonly IdentToken _returnTypeName; + private readonly IReadOnlyList _attributes; + + internal IReadOnlyList Attributes => _attributes; public override bool IsAsync => _binding.IsAsync(UdfBody); @@ -84,6 +88,18 @@ public override bool SupportsPaging(CallNode callNode, TexlBinding binding) public TexlBinding Binding => _binding; + internal IReadOnlyList GetPublicParameters() + { + var result = new List(); + foreach (var arg in _args.OrderBy(a => a.ArgIndex)) + { + var formulaType = FormulaType.Build(ParamTypes[arg.ArgIndex]); + result.Add(new NamedFormulaType(arg.NameIdent.Name.Value, formulaType)); + } + + return result; + } + public bool TryGetExternalDataSource(out IExternalDataSource dataSource) { return ArgValidators.DelegatableDataSourceInfoValidator.TryGetValidValue(_binding.Top, _binding, out dataSource); @@ -128,12 +144,14 @@ public override bool CheckTypes(CheckTypesContext context, TexlNode[] args, DTyp /// /// Array of argTypes in order. /// Name of the type in the decleration, used by error messages. - public UserDefinedFunction(string functionName, DType returnType, TexlNode body, bool isImperative, ISet args, DType[] argTypes, IdentToken returnTypeName) + /// Attributes applied to this UDF. + public UserDefinedFunction(string functionName, DType returnType, TexlNode body, bool isImperative, ISet args, DType[] argTypes, IdentToken returnTypeName, IReadOnlyList attributes = null) : base(DPath.Root, functionName, functionName, SG(functionName), FunctionCategories.UserDefined, returnType, 0, args.Count, args.Count, argTypes) { this._args = args; this._isImperative = isImperative; this._returnTypeName = returnTypeName; + this._attributes = attributes ?? Array.Empty(); this.UdfBody = body; } @@ -300,7 +318,7 @@ public UserDefinedFunction WithBinding(INameResolver nameResolver, IBinderGlue b throw new ArgumentNullException(nameof(binderGlue)); } - var func = new UserDefinedFunction(Name, ReturnType, UdfBody, _isImperative, new HashSet(_args), ParamTypes, _returnTypeName); + var func = new UserDefinedFunction(Name, ReturnType, UdfBody, _isImperative, new HashSet(_args), ParamTypes, _returnTypeName, _attributes); binding = func.BindBody(nameResolver, binderGlue, bindingConfig, features, rule, updateDisplayNames); return func; @@ -369,7 +387,7 @@ public static IEnumerable CreateFunctions(IEnumerable errors.Add(new TexlError(udf.Ident, DocumentErrorSeverity.Warning, TexlStrings.WrnUDF_ShadowingBuiltInFunction, udfName)); } - var func = new UserDefinedFunction(udfName.Value, returnType, udf.Body, udf.IsImperative, udf.Args, parameterTypes, udf.ReturnType); + var func = new UserDefinedFunction(udfName.Value, returnType, udf.Body, udf.IsImperative, udf.Args, parameterTypes, udf.ReturnType, udf.Attributes); texlFunctionSet.Add(func); userDefinedFunctions.Add(func); @@ -427,7 +445,7 @@ internal static UserDefinedFunction CreatePartialFunction(UDF udf, INameResolver var dummyref = 0; var udfBody = udf.Body ?? new PowerFx.Syntax.ErrorNode(ref dummyref, new CommentToken("dummy token", new Span(0, 0)), "dummy error"); - var func = new UserDefinedFunction(udfName.Value, returnType, udfBody, udf.IsImperative, udf.Args, parameterTypes, udf.ReturnType); + var func = new UserDefinedFunction(udfName.Value, returnType, udfBody, udf.IsImperative, udf.Args, parameterTypes, udf.ReturnType, udf.Attributes); return func; } @@ -542,11 +560,12 @@ public bool HasSameDefintion(string definitionsScript, UserDefinedFunction targe Contracts.AssertValue(targetUDF); if (Name != targetUDF.Name || - UdfBody.GetCompleteSpan().GetFragment(definitionsScript) != targetUDFbody || + UdfBody.GetCompleteSpan().GetFragment(definitionsScript) != targetUDFbody || _args.Count() != targetUDF._args.Count() || ReturnType.AssociatedDataSources.SetEquals(targetUDF.ReturnType.AssociatedDataSources) == false || ReturnType != targetUDF.ReturnType || - _isImperative != targetUDF._isImperative) + _isImperative != targetUDF._isImperative || + !AttributesEqual(_attributes, targetUDF._attributes)) { return false; } @@ -568,6 +587,25 @@ public bool HasSameDefintion(string definitionsScript, UserDefinedFunction targe return true; } + private static bool AttributesEqual(IReadOnlyList a, IReadOnlyList b) + { + if (a.Count != b.Count) + { + return false; + } + + for (var i = 0; i < a.Count; i++) + { + if (a[i].Name.Name != b[i].Name.Name || + !a[i].Arguments.SequenceEqual(b[i].Arguments)) + { + return false; + } + } + + return true; + } + /// /// NameResolver that combines global named resolver and params for user defined function. /// diff --git a/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs b/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs index b744d5ad57..3685d79b52 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Localization/Strings.cs @@ -915,6 +915,9 @@ internal static class TexlStrings public static ErrorResourceKey ErrOperationDoesntMatch = new ErrorResourceKey("ErrOperationDoesntMatch"); public static ErrorResourceKey ErrUnknownPartialOp = new ErrorResourceKey("ErrUnknownPartialOp"); + public static ErrorResourceKey ErrUnknownAttribute = new ErrorResourceKey("ErrUnknownAttribute"); + public static ErrorResourceKey ErrAttributeArgCount = new ErrorResourceKey("ErrAttributeArgCount"); + public static ErrorResourceKey ErrTruncatedArgWarning = new ErrorResourceKey("ErrTruncatedArgWarning"); public static ErrorResourceKey ErrNeedPrimitive = new ErrorResourceKey("ErrNeedPrimitive"); diff --git a/src/libraries/Microsoft.PowerFx.Core/Parser/Attribute.cs b/src/libraries/Microsoft.PowerFx.Core/Parser/Attribute.cs new file mode 100644 index 0000000000..bd3e028b40 --- /dev/null +++ b/src/libraries/Microsoft.PowerFx.Core/Parser/Attribute.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Collections.Generic; +using System.Linq; +using Microsoft.PowerFx.Syntax; + +namespace Microsoft.PowerFx.Core.Parser +{ + internal sealed class Attribute + { + public readonly IdentToken Name; + + public readonly IReadOnlyList Arguments; + + public readonly IReadOnlyList ArgumentTokens; + + public readonly Token OpenBracket; + + public Attribute(IdentToken name, IReadOnlyList argumentTokens, Token openBracket) + { + Name = name; + Arguments = argumentTokens.Select(t => t.As().Value).ToList(); + ArgumentTokens = argumentTokens; + OpenBracket = openBracket; + } + } +} diff --git a/src/libraries/Microsoft.PowerFx.Core/Parser/NamedFormula.cs b/src/libraries/Microsoft.PowerFx.Core/Parser/NamedFormula.cs index f7f6f2ebc3..9cd3518aa9 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Parser/NamedFormula.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Parser/NamedFormula.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. +using System; +using System.Collections.Generic; using Microsoft.PowerFx.Core.Utils; using Microsoft.PowerFx.Syntax; @@ -14,12 +16,12 @@ internal class NamedFormula internal int StartingIndex { get; } - internal PartialAttribute Attribute { get; } + internal IReadOnlyList Attributes { get; } // used by the pretty printer to get the proper operator in the output internal bool ColonEqual { get; } - public NamedFormula(IdentToken ident, Formula formula, int startingIndex, bool colonEqual, PartialAttribute attribute = null) + public NamedFormula(IdentToken ident, Formula formula, int startingIndex, bool colonEqual, IReadOnlyList attributes = null) { Contracts.AssertValue(ident); Contracts.AssertValue(formula); @@ -28,7 +30,7 @@ public NamedFormula(IdentToken ident, Formula formula, int startingIndex, bool c Formula = formula; StartingIndex = startingIndex; ColonEqual = colonEqual; - Attribute = attribute; + Attributes = attributes ?? Array.Empty(); } } } diff --git a/src/libraries/Microsoft.PowerFx.Core/Parser/ParseFormulasResult.cs b/src/libraries/Microsoft.PowerFx.Core/Parser/ParseFormulasResult.cs index aee5452b20..d8be454e86 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Parser/ParseFormulasResult.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Parser/ParseFormulasResult.cs @@ -159,6 +159,11 @@ internal class UDF /// internal bool IsParseValid { get; } + /// + /// Gets the attributes applied to this UDF. + /// + internal IReadOnlyList Attributes { get; } + /// /// Initializes a new instance of the class with the specified properties. /// @@ -170,7 +175,8 @@ internal class UDF /// A value indicating whether the UDF is imperative. /// A value indicating whether numbers are treated as floats in the UDF. /// A value indicating whether the UDF is parse valid. - public UDF(IdentToken ident, Token colonToken, IdentToken returnType, HashSet args, TexlNode body, bool isImperative, bool numberIsFloat, bool isValid) + /// The attributes applied to this UDF. + public UDF(IdentToken ident, Token colonToken, IdentToken returnType, HashSet args, TexlNode body, bool isImperative, bool numberIsFloat, bool isValid, IReadOnlyList attributes = null) { Ident = ident; ReturnType = returnType; @@ -180,6 +186,7 @@ public UDF(IdentToken ident, Token colonToken, IdentToken returnType, HashSet(); } } diff --git a/src/libraries/Microsoft.PowerFx.Core/Parser/PartialAttribute.cs b/src/libraries/Microsoft.PowerFx.Core/Parser/PartialAttribute.cs deleted file mode 100644 index 94112945dc..0000000000 --- a/src/libraries/Microsoft.PowerFx.Core/Parser/PartialAttribute.cs +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -using Microsoft.PowerFx.Syntax; - -namespace Microsoft.PowerFx.Core.Parser -{ - internal sealed class PartialAttribute - { - public enum AttributeOperationKind - { - Error, - PartialAnd, - PartialOr, - PartialTable, - PartialRecord - } - - public readonly IdentToken AttributeName; - - // This probably should be a TexlNode, but for now we're prototyping a simple case - // of just [Partial And]-type attributes, where they're pairs of name and operation. - public readonly Token AttributeOperationToken; - - public readonly AttributeOperationKind AttributeOperation; - - public PartialAttribute(IdentToken attributeName, Token attributeOperationToken) - { - AttributeName = attributeName; - AttributeOperationToken = attributeOperationToken; - AttributeOperation = ToKind(attributeOperationToken); - } - - private AttributeOperationKind ToKind(Token attributeOperation) - { - if (attributeOperation is KeyToken keyTok) - { - if (keyTok.Kind == TokKind.KeyAnd) - { - return AttributeOperationKind.PartialAnd; - } - else if (keyTok.Kind == TokKind.KeyOr) - { - return AttributeOperationKind.PartialOr; - } - } - else if (attributeOperation is IdentToken identTok) - { - if (identTok.Name.Value == "Table") - { - return AttributeOperationKind.PartialTable; - } - else if (identTok.Name.Value == "Record") - { - return AttributeOperationKind.PartialRecord; - } - } - - return AttributeOperationKind.Error; - } - - public bool SameAttribute(PartialAttribute other) - { - return AttributeName.Name.Value == other.AttributeName.Name.Value && - AttributeOperation == other.AttributeOperation; - } - } -} diff --git a/src/libraries/Microsoft.PowerFx.Core/Parser/TexlParser.cs b/src/libraries/Microsoft.PowerFx.Core/Parser/TexlParser.cs index 8afc06cd28..378261fd6b 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Parser/TexlParser.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Parser/TexlParser.cs @@ -227,49 +227,61 @@ private TypeLiteralNode ParseTypeLiteral(Identifier typeLiteralIdentifier) return new TypeLiteralNode(ref _idNext, parenOpen, expr, new SourceList(sourceList)); } - private PartialAttribute MaybeParseAttribute() + private IReadOnlyList MaybeParseAttributes() { - if (_curs.TidCur != TokKind.BracketOpen) - { - return null; - } - - _curs.TokMove(); - ParseTrivia(); + var attributes = new List(); - var attributeName = TokEat(TokKind.Ident); - if (attributeName == null) + while (_curs.TidCur == TokKind.BracketOpen) { - CreateError(_curs.TokCur, TexlStrings.ErrNamedFormula_MissingValue); + var openBracket = _curs.TokCur; _curs.TokMove(); - return null; - } + ParseTrivia(); - ParseTrivia(); + var annotationName = TokEat(TokKind.Ident); + if (annotationName == null) + { + CreateError(_curs.TokCur, TexlStrings.ErrNamedFormula_MissingValue); + _curs.TokMove(); + break; + } - // For this prototype, the attribute op can be an ident, And or Or. - // Definitely not the long term impl here, but works for now. - Token attributeOp; - if (_curs.TokCur.Kind == TokKind.Ident || - _curs.TokCur.Kind == TokKind.KeyAnd || - _curs.TokCur.Kind == TokKind.KeyOr) - { - attributeOp = _curs.TokCur; - _curs.TokMove(); - } - else - { - // Use TokEat here to post an error that we expected an ident. - TokEat(TokKind.Ident, addError: true); - _curs.TokMove(); - return null; - } + ParseTrivia(); - ParseTrivia(); - TokEat(TokKind.BracketClose); - ParseTrivia(); + var argumentTokens = new List(); + + if (_curs.TidCur == TokKind.ParenOpen) + { + _curs.TokMove(); + ParseTrivia(); + + while (_curs.TidCur == TokKind.StrLit) + { + argumentTokens.Add(_curs.TokCur); + _curs.TokMove(); + ParseTrivia(); + + if (_curs.TidCur == TokKind.Comma) + { + _curs.TokMove(); + ParseTrivia(); + } + else + { + break; + } + } + + TokEat(TokKind.ParenClose); + ParseTrivia(); + } + + TokEat(TokKind.BracketClose); + ParseTrivia(); + + attributes.Add(new Attribute(annotationName.As(), argumentTokens, openBracket)); + } - return new PartialAttribute(attributeName.As(), attributeOp); + return attributes; } private ParseUserDefinitionResult ParseUDFsAndNamedFormulas(string script, ParserOptions parserOptions) @@ -287,10 +299,10 @@ private ParseUserDefinitionResult ParseUDFsAndNamedFormulas(string script, Parse while (_curs.TokCur.Kind != TokKind.Eof) { - PartialAttribute attribute = null; + IReadOnlyList attributes = null; if (_flagsMode.Peek().HasFlag(Flags.AllowAttributes)) { - attribute = MaybeParseAttribute(); + attributes = MaybeParseAttributes(); } var thisIdentifier = TokEat(TokKind.Ident); @@ -341,7 +353,7 @@ private ParseUserDefinitionResult ParseUDFsAndNamedFormulas(string script, Parse } else { - namedFormulas.Add(new NamedFormula(thisIdentifier.As(), new Formula(result.GetCompleteSpan().GetFragment(script), result), _startingIndex, colonEqual: true, attribute)); + namedFormulas.Add(new NamedFormula(thisIdentifier.As(), new Formula(result.GetCompleteSpan().GetFragment(script), result), _startingIndex, colonEqual: true, attributes)); userDefinitionSourceInfos.Add(new UserDefinitionSourceInfo(index++, UserDefinitionType.NamedFormula, thisIdentifier.As(), declaration, new SourceList(definitionBeforeTrivia), GetExtraTriviaSourceList())); definitionBeforeTrivia = new List(); } @@ -394,7 +406,7 @@ private ParseUserDefinitionResult ParseUDFsAndNamedFormulas(string script, Parse definitionsLikely = true; - namedFormulas.Add(new NamedFormula(thisIdentifier.As(), new Formula(result.GetCompleteSpan().GetFragment(script), result), _startingIndex, colonEqual: false, attribute)); + namedFormulas.Add(new NamedFormula(thisIdentifier.As(), new Formula(result.GetCompleteSpan().GetFragment(script), result), _startingIndex, colonEqual: false, attributes)); userDefinitionSourceInfos.Add(new UserDefinitionSourceInfo(index++, UserDefinitionType.NamedFormula, thisIdentifier.As(), declaration, new SourceList(definitionBeforeTrivia), GetExtraTriviaSourceList())); definitionBeforeTrivia = new List(); @@ -416,7 +428,7 @@ private ParseUserDefinitionResult ParseUDFsAndNamedFormulas(string script, Parse { if (!ParseUDFArgs(out HashSet args)) { - udfs.Add(new UDF(thisIdentifier.As(), colonToken: null, returnType: null, new HashSet(args), body: null, _hasSemicolon, parserOptions.NumberIsFloat, isValid: false)); + udfs.Add(new UDF(thisIdentifier.As(), colonToken: null, returnType: null, new HashSet(args), body: null, _hasSemicolon, parserOptions.NumberIsFloat, isValid: false, attributes)); userDefinitionSourceInfos.Add(new UserDefinitionSourceInfo(index++, UserDefinitionType.UDF, thisIdentifier.As(), script.Substring(declarationStart, _curs.TokCur.Span.Min - declarationStart), new SourceList(definitionBeforeTrivia), GetExtraTriviaSourceList())); declarationStart = _curs.TokCur.Span.Min; definitionBeforeTrivia = new List(); @@ -443,7 +455,7 @@ private ParseUserDefinitionResult ParseUDFsAndNamedFormulas(string script, Parse if (returnType == null) { CreateError(_curs.TokCur, TexlStrings.ErrUDF_MissingReturnType); - udfs.Add(new UDF(thisIdentifier.As(), colonToken, returnType: null, new HashSet(args), body: null, _hasSemicolon, parserOptions.NumberIsFloat, isValid: false)); + udfs.Add(new UDF(thisIdentifier.As(), colonToken, returnType: null, new HashSet(args), body: null, _hasSemicolon, parserOptions.NumberIsFloat, isValid: false, attributes)); // Return type not found, move to next user definition MoveToNextUserDefinition(); @@ -479,14 +491,14 @@ private ParseUserDefinitionResult ParseUDFsAndNamedFormulas(string script, Parse if (TokEat(TokKind.CurlyClose) == null) { // Add incomplete UDF as they are needed for intellisense - udfs.Add(new UDF(thisIdentifier.As(), colonToken, returnType.As(), new HashSet(args), exp_result, isImperative: isImperative, parserOptions.NumberIsFloat, isValid: false)); + udfs.Add(new UDF(thisIdentifier.As(), colonToken, returnType.As(), new HashSet(args), exp_result, isImperative: isImperative, parserOptions.NumberIsFloat, isValid: false, attributes)); userDefinitionSourceInfos.Add(new UserDefinitionSourceInfo(index++, UserDefinitionType.UDF, thisIdentifier.As(), declaration, new SourceList(definitionBeforeTrivia), GetExtraTriviaSourceList())); break; } var bodyParseValid = _errors?.Count == errorCount; - udfs.Add(new UDF(thisIdentifier.As(), colonToken, returnType.As(), new HashSet(args), exp_result, isImperative: isImperative, parserOptions.NumberIsFloat, isValid: bodyParseValid)); + udfs.Add(new UDF(thisIdentifier.As(), colonToken, returnType.As(), new HashSet(args), exp_result, isImperative: isImperative, parserOptions.NumberIsFloat, isValid: bodyParseValid, attributes)); userDefinitionSourceInfos.Add(new UserDefinitionSourceInfo(index++, UserDefinitionType.UDF, thisIdentifier.As(), declaration, new SourceList(definitionBeforeTrivia), GetExtraTriviaSourceList())); definitionBeforeTrivia = new List(); @@ -506,7 +518,7 @@ private ParseUserDefinitionResult ParseUDFsAndNamedFormulas(string script, Parse var bodyParseValid = _errors?.Count == errorCount; - udfs.Add(new UDF(thisIdentifier.As(), colonToken, returnType.As(), new HashSet(args), result, isImperative: isImperative, parserOptions.NumberIsFloat, isValid: bodyParseValid)); + udfs.Add(new UDF(thisIdentifier.As(), colonToken, returnType.As(), new HashSet(args), result, isImperative: isImperative, parserOptions.NumberIsFloat, isValid: bodyParseValid, attributes)); userDefinitionSourceInfos.Add(new UserDefinitionSourceInfo(index++, UserDefinitionType.UDF, thisIdentifier.As(), declaration, new SourceList(definitionBeforeTrivia), GetExtraTriviaSourceList())); definitionBeforeTrivia = new List(); @@ -515,7 +527,7 @@ private ParseUserDefinitionResult ParseUDFsAndNamedFormulas(string script, Parse else { CreateError(_curs.TokCur, TexlStrings.ErrUDF_MissingFunctionBody); - udfs.Add(new UDF(thisIdentifier.As(), colonToken, returnType.As(), new HashSet(args), body: null, false, parserOptions.NumberIsFloat, isValid: false)); + udfs.Add(new UDF(thisIdentifier.As(), colonToken, returnType.As(), new HashSet(args), body: null, false, parserOptions.NumberIsFloat, isValid: false, attributes)); userDefinitionSourceInfos.Add(new UserDefinitionSourceInfo(index++, UserDefinitionType.UDF, thisIdentifier.As(), script.Substring(declarationStart), new SourceList(definitionBeforeTrivia), GetExtraTriviaSourceList())); break; } diff --git a/src/libraries/Microsoft.PowerFx.Core/Public/Config/AttributeDefinition.cs b/src/libraries/Microsoft.PowerFx.Core/Public/Config/AttributeDefinition.cs new file mode 100644 index 0000000000..52a1bff1fb --- /dev/null +++ b/src/libraries/Microsoft.PowerFx.Core/Public/Config/AttributeDefinition.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using Microsoft.PowerFx.Core.Utils; + +namespace Microsoft.PowerFx +{ + /// + /// Defines a known attribute that can be applied to UDFs and NamedFormulas. + /// + [ThreadSafeImmutable] + internal class AttributeDefinition + { + /// + /// Gets the name of the attribute. + /// + public string Name { get; } + + /// + /// Gets the minimum number of arguments expected. + /// + public int MinArgCount { get; } + + /// + /// Gets the maximum number of arguments expected. + /// + public int MaxArgCount { get; } + + /// + /// Gets an optional validation callback invoked after the UDF is created. + /// Returns warning message strings (empty or null = valid). + /// + public Func> Validator { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The attribute name. + /// Minimum number of string arguments. + /// Maximum number of string arguments. + /// Optional validation callback for semantic checks against the UDF signature. + public AttributeDefinition(string name, int minArgCount = 0, int maxArgCount = 0, Func> validator = null) + { + Contracts.AssertNonEmpty(name); + Contracts.Assert(minArgCount >= 0); + Contracts.Assert(maxArgCount >= minArgCount); + + Name = name; + MinArgCount = minArgCount; + MaxArgCount = maxArgCount; + Validator = validator; + } + } +} diff --git a/src/libraries/Microsoft.PowerFx.Core/Public/Config/AttributeValidationContext.cs b/src/libraries/Microsoft.PowerFx.Core/Public/Config/AttributeValidationContext.cs new file mode 100644 index 0000000000..a3aeda2cea --- /dev/null +++ b/src/libraries/Microsoft.PowerFx.Core/Public/Config/AttributeValidationContext.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System.Collections.Generic; +using Microsoft.PowerFx.Core.Utils; +using Microsoft.PowerFx.Types; + +namespace Microsoft.PowerFx +{ + /// + /// Provides context about the definition an attribute is attached to, + /// for use by custom attribute validation callbacks. + /// + [ThreadSafeImmutable] + internal class AttributeValidationContext + { + /// + /// Gets the name of the UDF this attribute is on. + /// + public string DefinitionName { get; } + + /// + /// Gets the string arguments from the attribute syntax. + /// + public IReadOnlyList AttributeArguments { get; } + + /// + /// Gets the return type of the UDF. + /// + public FormulaType ReturnType { get; } + + /// + /// Gets the parameters of the UDF in order. + /// + public IReadOnlyList Parameters { get; } + + internal AttributeValidationContext( + string definitionName, + IReadOnlyList attributeArguments, + FormulaType returnType, + IReadOnlyList parameters) + { + DefinitionName = definitionName; + AttributeArguments = attributeArguments; + ReturnType = returnType; + Parameters = parameters; + } + } +} diff --git a/src/libraries/Microsoft.PowerFx.Core/Public/Config/ComposedReadOnlySymbolTable.cs b/src/libraries/Microsoft.PowerFx.Core/Public/Config/ComposedReadOnlySymbolTable.cs index 886daedfe0..900663520e 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Public/Config/ComposedReadOnlySymbolTable.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Public/Config/ComposedReadOnlySymbolTable.cs @@ -183,6 +183,20 @@ public override IEnumerable> OptionSets } } + internal override bool TryGetAttributeDefinition(string name, out AttributeDefinition definition) + { + foreach (var table in _symbolTables) + { + if (table.TryGetAttributeDefinition(name, out definition)) + { + return true; + } + } + + definition = null; + return false; + } + internal override bool TryGetVariable(DName name, out NameLookupInfo symbol, out DName displayName) { foreach (ReadOnlySymbolTable st in _symbolTables) diff --git a/src/libraries/Microsoft.PowerFx.Core/Public/Config/ReadOnlySymbolTable.cs b/src/libraries/Microsoft.PowerFx.Core/Public/Config/ReadOnlySymbolTable.cs index 1a68d86279..4fd7bbd840 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Public/Config/ReadOnlySymbolTable.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Public/Config/ReadOnlySymbolTable.cs @@ -1,57 +1,57 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using Microsoft.PowerFx.Core; -using Microsoft.PowerFx.Core.App; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Microsoft.PowerFx.Core; +using Microsoft.PowerFx.Core.App; using Microsoft.PowerFx.Core.App.Controls; -using Microsoft.PowerFx.Core.Binding; -using Microsoft.PowerFx.Core.Binding.BindInfo; -using Microsoft.PowerFx.Core.Entities; -using Microsoft.PowerFx.Core.Functions; +using Microsoft.PowerFx.Core.Binding; +using Microsoft.PowerFx.Core.Binding.BindInfo; +using Microsoft.PowerFx.Core.Entities; +using Microsoft.PowerFx.Core.Functions; using Microsoft.PowerFx.Core.Types; -using Microsoft.PowerFx.Core.Types.Enums; -using Microsoft.PowerFx.Core.Utils; -using Microsoft.PowerFx.Types; - -namespace Microsoft.PowerFx -{ - /// - /// ReadOnly version of a Symbol Table. This feeds functions, variables, enums, etc into - /// the binder. - /// See for mutable version. - /// - [DebuggerDisplay("{_debugName}_{GetHashCode()}")] - [ThreadSafeImmutable] - public abstract class ReadOnlySymbolTable : INameResolver, IGlobalSymbolNameResolver, IEnumStore - { - // Changed on each update. - // Host can use to ensure that a symbol table wasn't mutated on us. - private protected VersionHash _version = VersionHash.New(); - - /// - /// This can be compared to determine if the symbol table was mutated during an operation. - /// - internal virtual VersionHash VersionHash => _version; - - /// - /// Notify the symbol table has changed. - /// - public void Inc() - { - _version.Inc(); - } - - private protected string _debugName = "SymbolTable"; - - // Helper in debugging. Useful when we have multiple symbol tables chained. - public string DebugName - { - get => _debugName; - init => _debugName = value; +using Microsoft.PowerFx.Core.Types.Enums; +using Microsoft.PowerFx.Core.Utils; +using Microsoft.PowerFx.Types; + +namespace Microsoft.PowerFx +{ + /// + /// ReadOnly version of a Symbol Table. This feeds functions, variables, enums, etc into + /// the binder. + /// See for mutable version. + /// + [DebuggerDisplay("{_debugName}_{GetHashCode()}")] + [ThreadSafeImmutable] + public abstract class ReadOnlySymbolTable : INameResolver, IGlobalSymbolNameResolver, IEnumStore + { + // Changed on each update. + // Host can use to ensure that a symbol table wasn't mutated on us. + private protected VersionHash _version = VersionHash.New(); + + /// + /// This can be compared to determine if the symbol table was mutated during an operation. + /// + internal virtual VersionHash VersionHash => _version; + + /// + /// Notify the symbol table has changed. + /// + public void Inc() + { + _version.Inc(); + } + + private protected string _debugName = "SymbolTable"; + + // Helper in debugging. Useful when we have multiple symbol tables chained. + public string DebugName + { + get => _debugName; + init => _debugName = value; } /// @@ -177,9 +177,9 @@ internal Exception NewBadSlotException(ISymbolSlot slot) public static ReadOnlySymbolTable NewFromDeferred( DisplayNameProvider map, Func fetchTypeInfo, - string debugName = null) + string debugName = null) { - return NewFromDeferred(map, fetchTypeInfo, FormulaType.Deferred, debugName); + return NewFromDeferred(map, fetchTypeInfo, FormulaType.Deferred, debugName); } /// @@ -196,7 +196,7 @@ public static ReadOnlySymbolTable NewFromDeferred( DisplayNameProvider map, Func fetchTypeInfo, FormulaType placeHolderType, - string debugName = null) + string debugName = null) { if (map == null) { @@ -212,42 +212,42 @@ public static ReadOnlySymbolTable NewFromDeferred( { throw new ArgumentNullException(nameof(placeHolderType)); } - + return new DeferredSymbolTable(map, fetchTypeInfo, placeHolderType._type) { DebugName = debugName - }; + }; } public static ReadOnlySymbolTable NewFromDeferred( DisplayNameProvider map, Func fetchTypeInfo, - string debugName = null) + string debugName = null) { - return NewFromDeferred(map, fetchTypeInfo, FormulaType.Deferred, debugName); + return NewFromDeferred(map, fetchTypeInfo, FormulaType.Deferred, debugName); } public static ReadOnlySymbolTable NewFromDeferred( DisplayNameProvider map, Func fetchTypeInfo, FormulaType placeHolderType, - string debugName = null) + string debugName = null) { Func fetchTypeInfo2 = - (logical, display) => new DeferredSymbolPlaceholder(fetchTypeInfo(logical, display)); - return NewFromDeferred(map, fetchTypeInfo2, placeHolderType, debugName); + (logical, display) => new DeferredSymbolPlaceholder(fetchTypeInfo(logical, display)); + return NewFromDeferred(map, fetchTypeInfo2, placeHolderType, debugName); } public static ReadOnlySymbolTable NewFromRecord( RecordType type, string debugName = null, bool allowThisRecord = false, - bool allowMutable = false) - { + bool allowMutable = false) + { return new SymbolTableOverRecordType(type ?? RecordType.Empty(), null, mutable: allowMutable, allowThisRecord: allowThisRecord) { DebugName = debugName ?? (allowThisRecord ? "RowScope" : "FromRecord") - }; + }; } public static ReadOnlySymbolTable NewFromRecordWithoutImplicitThisRecord( @@ -261,24 +261,24 @@ public static ReadOnlySymbolTable NewFromRecordWithoutImplicitThisRecord( }; } - public static ReadOnlySymbolTable Compose(params ReadOnlySymbolTable[] tables) - { - return new ComposedReadOnlySymbolTable(tables); - } - - // Helper to create a ReadOnly symbol table around a set of core functions. - // Important that this is readonly so that it can be safely shared across engines. - internal static ReadOnlySymbolTable NewDefault(TexlFunctionSet coreFunctions) - { - var s = new SymbolTable - { - EnumStoreBuilder = new EnumStoreBuilder(), - DebugName = $"BuiltinFunctions ({coreFunctions.Count()})" - }; - - s.AddFunctions(coreFunctions); - - return s; + public static ReadOnlySymbolTable Compose(params ReadOnlySymbolTable[] tables) + { + return new ComposedReadOnlySymbolTable(tables); + } + + // Helper to create a ReadOnly symbol table around a set of core functions. + // Important that this is readonly so that it can be safely shared across engines. + internal static ReadOnlySymbolTable NewDefault(TexlFunctionSet coreFunctions) + { + var s = new SymbolTable + { + EnumStoreBuilder = new EnumStoreBuilder(), + DebugName = $"BuiltinFunctions ({coreFunctions.Count()})" + }; + + s.AddFunctions(coreFunctions); + + return s; } internal static ReadOnlySymbolTable NewDefault(IEnumerable functions) @@ -288,19 +288,19 @@ internal static ReadOnlySymbolTable NewDefault(IEnumerable functio foreach (TexlFunction function in functions) { tfs.Add(function); - } - + } + return NewDefault(tfs); } - // Helper to create a ReadOnly symbol table around a set of core types. - internal static ReadOnlySymbolTable NewDefaultTypes(IEnumerable> types, FormulaType numberTypeIs = null) + // Helper to create a ReadOnly symbol table around a set of core types. + internal static ReadOnlySymbolTable NewDefaultTypes(IEnumerable> types, FormulaType numberTypeIs = null) { Contracts.AssertValue(types); - - var s = new SymbolTable - { - DebugName = $"BuiltinTypes ({types?.Count()})" + + var s = new SymbolTable + { + DebugName = $"BuiltinTypes ({types?.Count()})" }; s.AddTypes(types); @@ -309,45 +309,45 @@ internal static ReadOnlySymbolTable NewDefaultTypes(IEnumerable> types) + internal static ReadOnlySymbolTable NewDefault(TexlFunctionSet coreFunctions, IEnumerable> types) { Contracts.AssertValue(types); Contracts.AssertValue(coreFunctions); - - var s = new SymbolTable - { - EnumStoreBuilder = new EnumStoreBuilder(), - DebugName = $"BuiltinFunctions ({coreFunctions.Count()}), BuiltinTypes ({types?.Count()})" - }; - + + var s = new SymbolTable + { + EnumStoreBuilder = new EnumStoreBuilder(), + DebugName = $"BuiltinFunctions ({coreFunctions.Count()}), BuiltinTypes ({types?.Count()})" + }; + s.AddFunctions(coreFunctions); - s.AddTypes(types); - return s; - } - - /// - /// Helper to create a symbol table around a set of core functions. - /// Important that this is mutable so that it can be changed across engines. - /// - /// SymbolTable with supported functions. - public SymbolTable GetMutableCopyOfFunctions() - { - var s = new SymbolTable() - { + s.AddTypes(types); + return s; + } + + /// + /// Helper to create a symbol table around a set of core functions. + /// Important that this is mutable so that it can be changed across engines. + /// + /// SymbolTable with supported functions. + public SymbolTable GetMutableCopyOfFunctions() + { + var s = new SymbolTable() + { DebugName = DebugName + " (Functions only)", - }; - - s.AddFunctions(this.Functions); - - return s; + }; + + s.AddFunctions(this.Functions); + + return s; } - internal readonly Dictionary _variables = new Dictionary(); + internal readonly Dictionary _variables = new Dictionary(); public IEnumerable FunctionNames => this.Functions.FunctionNames; @@ -358,112 +358,118 @@ public SymbolTable GetMutableCopyOfFunctions() // These do not compose - only bottom one wins. // ComposedReadOnlySymbolTable will handle composition by looking up in each symbol table. private protected EnumStoreBuilder _enumStoreBuilder; - + private EnumSymbol[] _enumSymbolCache; - private EnumSymbol[] GetEnumSymbolSnapshot - { - get - { - // The caller may add to the builder after we've assigned. - // So delay snapshot until we actually need to read it. - if (_enumStoreBuilder == null) - { - _enumSymbolCache = new EnumSymbol[] { }; - } - - if (_enumSymbolCache == null) - { - _enumSymbolCache = _enumStoreBuilder.Build().EnumSymbols.ToArray(); - } - - return _enumSymbolCache; - } - } - - IEnumerable IEnumStore.EnumSymbols => GetEnumSymbolSnapshot; - - internal TexlFunctionSet Functions => ((INameResolver)this).Functions; + private EnumSymbol[] GetEnumSymbolSnapshot + { + get + { + // The caller may add to the builder after we've assigned. + // So delay snapshot until we actually need to read it. + if (_enumStoreBuilder == null) + { + _enumSymbolCache = new EnumSymbol[] { }; + } + + if (_enumSymbolCache == null) + { + _enumSymbolCache = _enumStoreBuilder.Build().EnumSymbols.ToArray(); + } + + return _enumSymbolCache; + } + } + + IEnumerable IEnumStore.EnumSymbols => GetEnumSymbolSnapshot; + + internal TexlFunctionSet Functions => ((INameResolver)this).Functions; // Base implementation is readonly and does not allow adding functions. private readonly TexlFunctionSet _emptyFunctionSet = TexlFunctionSet.Empty(); - + TexlFunctionSet INameResolver.Functions => _emptyFunctionSet; - IEnumerable> IGlobalSymbolNameResolver.GlobalSymbols => _variables; - - /// - /// Get symbol names in this current scope. - /// - public IEnumerable SymbolNames - { + IEnumerable> IGlobalSymbolNameResolver.GlobalSymbols => _variables; + + /// + /// Get symbol names in this current scope. + /// + public IEnumerable SymbolNames + { get - { - IGlobalSymbolNameResolver globals = this; + { + IGlobalSymbolNameResolver globals = this; - // GlobalSymbols are virtual, so we get derived behavior via that. - foreach (var kv in globals.GlobalSymbols) - { - var type = FormulaType.Build(kv.Value.Type); - var displayName = kv.Value.DisplayName != default ? kv.Value.DisplayName.Value : null; - yield return new NamedFormulaType(kv.Key, type, displayName); - } - } + // GlobalSymbols are virtual, so we get derived behavior via that. + foreach (var kv in globals.GlobalSymbols) + { + var type = FormulaType.Build(kv.Value.Type); + var displayName = kv.Value.DisplayName != default ? kv.Value.DisplayName.Value : null; + yield return new NamedFormulaType(kv.Key, type, displayName); + } + } } // Hook from Lookup - Get just variables. - internal virtual bool TryGetVariable(DName name, out NameLookupInfo symbol, out DName displayName) + internal virtual bool TryGetAttributeDefinition(string name, out AttributeDefinition definition) + { + definition = null; + return false; + } + + internal virtual bool TryGetVariable(DName name, out NameLookupInfo symbol, out DName displayName) { symbol = default; - displayName = default; - return false; - } - - // Derived symbol tables can hook. - // NameLookupPreferences is just for legacy lookup behavior, so we don't need to pass it to this hook - internal virtual bool TryLookup(DName name, out NameLookupInfo nameInfo) - { - nameInfo = default; - return false; - } - - bool INameResolver.Lookup(DName name, out NameLookupInfo nameInfo, NameLookupPreferences preferences) - { - if (TryLookup(name, out nameInfo)) - { - return true; + displayName = default; + return false; + } + + // Derived symbol tables can hook. + // NameLookupPreferences is just for legacy lookup behavior, so we don't need to pass it to this hook + internal virtual bool TryLookup(DName name, out NameLookupInfo nameInfo) + { + nameInfo = default; + return false; + } + + bool INameResolver.Lookup(DName name, out NameLookupInfo nameInfo, NameLookupPreferences preferences) + { + if (TryLookup(name, out nameInfo)) + { + return true; } // This does a display-name aware lookup from _variables if (TryGetVariable(name, out nameInfo, out _)) { return true; - } - - var enumValue = GetEnumSymbolSnapshot.FirstOrDefault(symbol => symbol.EntityName.Value == name); - if (enumValue != null) - { - nameInfo = new NameLookupInfo(BindKind.Enum, enumValue.EnumType, DPath.Root, 0, enumValue); - return true; - } - - nameInfo = default; - return false; - } - - IEnumerable INameResolver.LookupFunctions(DPath theNamespace, string name, bool localeInvariant) - { - Contracts.Check(theNamespace.IsValid, "The namespace is invalid."); - Contracts.CheckNonEmpty(name, "name"); - + } + + var enumValue = GetEnumSymbolSnapshot.FirstOrDefault(symbol => symbol.EntityName.Value == name); + if (enumValue != null) + { + nameInfo = new NameLookupInfo(BindKind.Enum, enumValue.EnumType, DPath.Root, 0, enumValue); + return true; + } + + nameInfo = default; + return false; + } + + IEnumerable INameResolver.LookupFunctions(DPath theNamespace, string name, bool localeInvariant) + { + Contracts.Check(theNamespace.IsValid, "The namespace is invalid."); + Contracts.CheckNonEmpty(name, "name"); + return localeInvariant ? Functions.WithInvariantName(name, theNamespace) - : Functions.WithName(name, theNamespace); - } - - IEnumerable INameResolver.LookupFunctionsInNamespace(DPath nameSpace) - { - Contracts.Check(nameSpace.IsValid, "The namespace is invalid."); + : Functions.WithName(name, theNamespace); + } + + IEnumerable INameResolver.LookupFunctionsInNamespace(DPath nameSpace) + { + Contracts.Check(nameSpace.IsValid, "The namespace is invalid."); return this.Functions.WithNamespace(nameSpace); } @@ -493,57 +499,57 @@ internal class EnumerateNamesOptions IExternalEntityScope INameResolver.EntityScope => InternalEntityScope; #endregion - - #region INameResolver - not implemented + + #region INameResolver - not implemented // Methods from INameResolver that we default / don't implement - IExternalDocument INameResolver.Document => default; - - IExternalEntity INameResolver.CurrentEntity => default; - - DName INameResolver.CurrentProperty => default; - - DPath INameResolver.CurrentEntityPath => default; - + IExternalDocument INameResolver.Document => default; + + IExternalEntity INameResolver.CurrentEntity => default; + + DName INameResolver.CurrentProperty => default; + + DPath INameResolver.CurrentEntityPath => default; + bool INameResolver.SuggestUnqualifiedEnums => false; IEnumerable> INameResolver.NamedTypes => Enumerable.Empty>(); - bool INameResolver.LookupParent(out NameLookupInfo lookupInfo) - { - lookupInfo = default; - return false; - } - - bool INameResolver.LookupSelf(out NameLookupInfo lookupInfo) - { - lookupInfo = default; - return false; - } - - bool INameResolver.LookupGlobalEntity(DName name, out NameLookupInfo lookupInfo) - { - lookupInfo = default; - return false; - } - - bool INameResolver.TryLookupEnum(DName name, out NameLookupInfo lookupInfo) + bool INameResolver.LookupParent(out NameLookupInfo lookupInfo) + { + lookupInfo = default; + return false; + } + + bool INameResolver.LookupSelf(out NameLookupInfo lookupInfo) { lookupInfo = default; - return false; - } - - bool INameResolver.TryGetInnermostThisItemScope(out NameLookupInfo nameInfo) - { - nameInfo = default; - return false; - } - - bool INameResolver.LookupDataControl(DName name, out NameLookupInfo lookupInfo, out DName dataControlName) - { - dataControlName = default; - lookupInfo = default; - return false; + return false; + } + + bool INameResolver.LookupGlobalEntity(DName name, out NameLookupInfo lookupInfo) + { + lookupInfo = default; + return false; + } + + bool INameResolver.TryLookupEnum(DName name, out NameLookupInfo lookupInfo) + { + lookupInfo = default; + return false; + } + + bool INameResolver.TryGetInnermostThisItemScope(out NameLookupInfo nameInfo) + { + nameInfo = default; + return false; + } + + bool INameResolver.LookupDataControl(DName name, out NameLookupInfo lookupInfo, out DName dataControlName) + { + dataControlName = default; + lookupInfo = default; + return false; } bool INameResolver.LookupExpandedControlType(IExternalControl control, out DType controlType) @@ -552,11 +558,11 @@ bool INameResolver.LookupExpandedControlType(IExternalControl control, out DType } bool INameResolver.LookupType(DName name, out FormulaType fType) - { - fType = default; - return false; + { + fType = default; + return false; } - #endregion - } -} + #endregion + } +} diff --git a/src/libraries/Microsoft.PowerFx.Core/Public/Config/SymbolTable.cs b/src/libraries/Microsoft.PowerFx.Core/Public/Config/SymbolTable.cs index 27dda00cab..007ac3c057 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Public/Config/SymbolTable.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Public/Config/SymbolTable.cs @@ -1,36 +1,36 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. - -using System; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +using System; using System.Collections.Generic; -using System.Diagnostics; +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Text; -using System.Threading.Tasks; -using Microsoft.PowerFx.Core; +using System.Threading.Tasks; +using Microsoft.PowerFx.Core; using Microsoft.PowerFx.Core.Annotations; -using Microsoft.PowerFx.Core.Binding; -using Microsoft.PowerFx.Core.Binding.BindInfo; -using Microsoft.PowerFx.Core.Entities; +using Microsoft.PowerFx.Core.Binding; +using Microsoft.PowerFx.Core.Binding.BindInfo; +using Microsoft.PowerFx.Core.Entities; using Microsoft.PowerFx.Core.Errors; -using Microsoft.PowerFx.Core.Functions; -using Microsoft.PowerFx.Core.Glue; -using Microsoft.PowerFx.Core.Types.Enums; -using Microsoft.PowerFx.Core.Utils; +using Microsoft.PowerFx.Core.Functions; +using Microsoft.PowerFx.Core.Glue; +using Microsoft.PowerFx.Core.Types.Enums; +using Microsoft.PowerFx.Core.Utils; using Microsoft.PowerFx.Syntax; -using Microsoft.PowerFx.Types; - -namespace Microsoft.PowerFx -{ - /// - /// Provides symbols to the engine. This includes variables (locals, globals), enums, options sets, and functions. - /// SymbolTables are mutable to support sessionful scenarios and can be chained together. - /// This is a publicly facing class around a . - /// +using Microsoft.PowerFx.Types; + +namespace Microsoft.PowerFx +{ + /// + /// Provides symbols to the engine. This includes variables (locals, globals), enums, options sets, and functions. + /// SymbolTables are mutable to support sessionful scenarios and can be chained together. + /// This is a publicly facing class around a . + /// [DebuggerDisplay("{DebugName}")] - [NotThreadSafe] - public class SymbolTable : ReadOnlySymbolTable, IGlobalSymbolNameResolver + [NotThreadSafe] + public class SymbolTable : ReadOnlySymbolTable, IGlobalSymbolNameResolver { private readonly GuardSingleThreaded _guard = new GuardSingleThreaded(); @@ -40,6 +40,8 @@ public class SymbolTable : ReadOnlySymbolTable, IGlobalSymbolNameResolver private readonly IDictionary _namedTypes = new Dictionary(); + private readonly Dictionary _knownAttributes = new Dictionary(StringComparer.Ordinal); + private DisplayNameProvider _environmentSymbolDisplayNameProvider = new SingleSourceDisplayNameProvider(); IEnumerable> IGlobalSymbolNameResolver.GlobalSymbols => _variables; @@ -55,15 +57,15 @@ public class SymbolTable : ReadOnlySymbolTable, IGlobalSymbolNameResolver /// True if we have AddVariables, but not needed if we just have constants or functions. /// public bool NeedsValues => !_slots.IsEmpty; - - private DName ValidateName(string name) - { - if (!DName.IsValidDName(name)) - { - throw new ArgumentException("Invalid name: ${name}"); + + private DName ValidateName(string name) + { + if (!DName.IsValidDName(name)) + { + throw new ArgumentException("Invalid name: ${name}"); } - return new DName(name); + return new DName(name); } public SymbolTable() @@ -83,21 +85,21 @@ public override FormulaType GetTypeFromSlot(ISymbolSlot slot) throw NewBadSlotException(slot); } - internal override bool TryGetVariable(DName name, out NameLookupInfo symbol, out DName displayName) - { + internal override bool TryGetVariable(DName name, out NameLookupInfo symbol, out DName displayName) + { var lookupName = name; - - if (_environmentSymbolDisplayNameProvider.TryGetDisplayName(name, out displayName)) - { - // do nothing as provided name can be used for lookup with logical name - } - else if (_environmentSymbolDisplayNameProvider.TryGetLogicalName(name, out var logicalName)) + + if (_environmentSymbolDisplayNameProvider.TryGetDisplayName(name, out displayName)) + { + // do nothing as provided name can be used for lookup with logical name + } + else if (_environmentSymbolDisplayNameProvider.TryGetLogicalName(name, out var logicalName)) { - lookupName = logicalName; - displayName = name; - } - - return _variables.TryGetValue(lookupName, out symbol); + lookupName = logicalName; + displayName = name; + } + + return _variables.TryGetValue(lookupName, out symbol); } // Exists for binary backcompat. @@ -112,16 +114,16 @@ public ISymbolSlot AddVariable(string name, FormulaType type, bool mutable = fal return AddVariable(name, type, props, displayName); } - /// - /// Provide variable for binding only. + /// + /// Provide variable for binding only. /// Value must be provided at runtime. - /// This can throw an exception in case of there is a conflict in name with existing names. - /// - /// + /// This can throw an exception in case of there is a conflict in name with existing names. + /// + /// /// /// - /// - public ISymbolSlot AddVariable(string name, FormulaType type, SymbolProperties props, string displayName = null) + /// + public ISymbolSlot AddVariable(string name, FormulaType type, SymbolProperties props, string displayName = null) { if (props == null) { @@ -130,7 +132,7 @@ public ISymbolSlot AddVariable(string name, FormulaType type, SymbolProperties p } using var guard = _guard.Enter(); // Region is single threaded. - + Inc(); DName displayDName = default; DName varDName = ValidateName(name); @@ -157,11 +159,11 @@ public ISymbolSlot AddVariable(string name, FormulaType type, SymbolProperties p SlotIndex = slotIndex }; - var info = new NameLookupInfo( - BindKind.PowerFxResolvedObject, - type._type, - DPath.Root, - 0, + var info = new NameLookupInfo( + BindKind.PowerFxResolvedObject, + type._type, + DPath.Root, + 0, data: data, displayName: displayDName); @@ -173,43 +175,43 @@ public ISymbolSlot AddVariable(string name, FormulaType type, SymbolProperties p { _environmentSymbolDisplayNameProvider = ssDnp.AddField(varDName, displayDName != default ? displayDName : varDName); } - + _variables.Add(name, info); // can't exist - return data; + return data; } - /// - /// Add a constant. This is like a variable, but the value is known at bind time. - /// - /// - /// - public void AddConstant(string name, FormulaValue data) + /// + /// Add a constant. This is like a variable, but the value is known at bind time. + /// + /// + /// + public void AddConstant(string name, FormulaValue data) { using var guard = _guard.Enter(); // Region is single threaded. - - var type = data.Type; - - Inc(); - ValidateName(name); - - var info = new NameLookupInfo( - BindKind.PowerFxResolvedObject, - type._type, - DPath.Root, - 0, + + var type = data.Type; + + Inc(); + ValidateName(name); + + var info = new NameLookupInfo( + BindKind.PowerFxResolvedObject, + type._type, + DPath.Root, + 0, data); - // Attempt to update display name provider before symbol table, - // since it can throw on collision and we want to leave the config in a good state. - // add (logical, logical) pair to display name provider so it still can be included in collision checks. - if (_environmentSymbolDisplayNameProvider is SingleSourceDisplayNameProvider ssDnp) + // Attempt to update display name provider before symbol table, + // since it can throw on collision and we want to leave the config in a good state. + // add (logical, logical) pair to display name provider so it still can be included in collision checks. + if (_environmentSymbolDisplayNameProvider is SingleSourceDisplayNameProvider ssDnp) { - var dName = new DName(name); - _environmentSymbolDisplayNameProvider = ssDnp.AddField(dName, dName); - } - - _variables.Add(name, info); // can't exist + var dName = new DName(name); + _environmentSymbolDisplayNameProvider = ssDnp.AddField(dName, dName); + } + + _variables.Add(name, info); // can't exist } internal static ParserOptions GetUDFParserOptions(CultureInfo culture, bool allowSideEffects) @@ -230,7 +232,7 @@ internal static ParserOptions GetUDFParserOptions(CultureInfo culture, bool allo /// Additional symbols to bind UDF. /// Allow for curly brace parsing. /// Features in effect for processing the body of the UDF. - internal DefinitionsCheckResult AddUserDefinedFunction(string script, CultureInfo parseCulture = null, ReadOnlySymbolTable symbolTable = null, ReadOnlySymbolTable extraSymbolTable = null, bool allowSideEffects = false, Features features = null) + internal DefinitionsCheckResult AddUserDefinedFunction(string script, CultureInfo parseCulture = null, ReadOnlySymbolTable symbolTable = null, ReadOnlySymbolTable extraSymbolTable = null, bool allowSideEffects = false, Features features = null) { var composedSymbolTable = ReadOnlySymbolTable.Compose(this, symbolTable, extraSymbolTable); var checkResult = GetDefinitionsCheckResult(script, parseCulture, composedSymbolTable, allowSideEffects, features); @@ -244,7 +246,7 @@ internal DefinitionsCheckResult AddUserDefinedFunction(string script, CultureInf AddFunctions(udfs); } - return checkResult; + return checkResult; } internal static DefinitionsCheckResult GetDefinitionsCheckResult(string script, CultureInfo parseCulture = null, ReadOnlySymbolTable symbolTable = null, bool allowSideEffects = false, Features features = null) @@ -253,16 +255,16 @@ internal static DefinitionsCheckResult GetDefinitionsCheckResult(string script, var checkResult = new DefinitionsCheckResult(features); return checkResult.SetText(script, options) .SetBindingInfo(symbolTable); - } - - /// - /// Remove variable, entity or constant of a given name. - /// - /// display or logical name for the variable or entity to be removed. Logical name of constant to be removed. - public void RemoveVariable(string name) + } + + /// + /// Remove variable, entity or constant of a given name. + /// + /// display or logical name for the variable or entity to be removed. Logical name of constant to be removed. + public void RemoveVariable(string name) { using var guard = _guard.Enter(); // Region is single threaded. - + Inc(); // Also remove from display name provider @@ -281,7 +283,7 @@ public void RemoveVariable(string name) } _environmentSymbolDisplayNameProvider = ssDP.RemoveField(lookupName); - } + } if (_variables.TryGetValue(name, out var info)) { @@ -291,74 +293,74 @@ public void RemoveVariable(string name) info2.DisposeSlot(); } } - - // Ok to remove if missing. - _variables.Remove(name); - } - - /// - /// Remove function of given name. - /// - /// - public void RemoveFunction(string name) + + // Ok to remove if missing. + _variables.Remove(name); + } + + /// + /// Remove function of given name. + /// + /// + public void RemoveFunction(string name) { - using var guard = _guard.Enter(); // Region is single threaded. - Inc(); - - _functions.RemoveAll(name); - } - - internal void RemoveFunction(TexlFunction function) + using var guard = _guard.Enter(); // Region is single threaded. + Inc(); + + _functions.RemoveAll(name); + } + + internal void RemoveFunction(TexlFunction function) { - using var guard = _guard.Enter(); // Region is single threaded. - Inc(); - - _functions.RemoveAll(function); + using var guard = _guard.Enter(); // Region is single threaded. + Inc(); + + _functions.RemoveAll(function); } - internal void AddFunctions(TexlFunctionSet functions) + internal void AddFunctions(TexlFunctionSet functions) { - using var guard = _guard.Enter(); // Region is single threaded. + using var guard = _guard.Enter(); // Region is single threaded. Inc(); if (functions._count == 0) { return; } - - _functions.Add(functions); - - // Add any associated enums - EnumStoreBuilder?.WithRequiredEnums(functions); - } - - internal void AddFunction(TexlFunction function) + + _functions.Add(functions); + + // Add any associated enums + EnumStoreBuilder?.WithRequiredEnums(functions); + } + + internal void AddFunction(TexlFunction function) { - using var guard = _guard.Enter(); // Region is single threaded. - Inc(); - _functions.Add(function); - - // Add any associated enums - EnumStoreBuilder?.WithRequiredEnums(new TexlFunctionSet(function)); - } - - internal EnumStoreBuilder EnumStoreBuilder - { - get => _enumStoreBuilder; - set - { - Inc(); - _enumStoreBuilder = value; - } - } + using var guard = _guard.Enter(); // Region is single threaded. + Inc(); + _functions.Add(function); + + // Add any associated enums + EnumStoreBuilder?.WithRequiredEnums(new TexlFunctionSet(function)); + } + + internal EnumStoreBuilder EnumStoreBuilder + { + get => _enumStoreBuilder; + set + { + Inc(); + _enumStoreBuilder = value; + } + } public void AddOptionSet(OptionSet optionSet, DName displayName = default) { AddEntity(optionSet, displayName); } - - internal void AddEntity(IExternalEntity entity, DName displayName = default) - { + + internal void AddEntity(IExternalEntity entity, DName displayName = default) + { Inc(); NameLookupInfo nameInfo; @@ -378,13 +380,13 @@ internal void AddEntity(IExternalEntity entity, DName displayName = default) // Attempt to update display name provider before symbol table, // since it can throw on collision and we want to leave the config in a good state. // For entities without a display name, add (logical, logical) pair to still be included in collision checks. - if (_environmentSymbolDisplayNameProvider is SingleSourceDisplayNameProvider ssDnp) + if (_environmentSymbolDisplayNameProvider is SingleSourceDisplayNameProvider ssDnp) { - displayName = displayName != default ? displayName : entity.EntityName; - _environmentSymbolDisplayNameProvider = ssDnp.AddField(entity.EntityName, displayName); + displayName = displayName != default ? displayName : entity.EntityName; + _environmentSymbolDisplayNameProvider = ssDnp.AddField(entity.EntityName, displayName); } - _variables.Add(entity.EntityName, nameInfo); + _variables.Add(entity.EntityName, nameInfo); } // Sync version for convenience. @@ -413,11 +415,11 @@ public void AddHostObject(string name, FormulaType type, Func> types) } } - /// - /// Helper to create a symbol table with named types. - /// + /// + /// Registers a known attribute definition for validation. + /// + /// The attribute definition to register. + internal void AddAttribute(AttributeDefinition attributeDefinition) + { + Contracts.AssertValue(attributeDefinition); + + using var guard = _guard.Enter(); // Region is single threaded. + Inc(); + + if (_knownAttributes.ContainsKey(attributeDefinition.Name)) + { + throw new InvalidOperationException($"Attribute '{attributeDefinition.Name}' is already defined."); + } + + _knownAttributes.Add(attributeDefinition.Name, attributeDefinition); + } + + internal override bool TryGetAttributeDefinition(string name, out AttributeDefinition definition) + { + return _knownAttributes.TryGetValue(name, out definition); + } + + /// + /// Helper to create a symbol table with named types. + /// /// SymbolTable with named types. public static SymbolTable WithNamedTypes(Dictionary namedTypes) { - var s = new SymbolTable - { - DebugName = $"SymbolTable with NamedTypes" + var s = new SymbolTable + { + DebugName = $"SymbolTable with NamedTypes" }; s.AddTypes(namedTypes); @@ -514,5 +540,5 @@ bool INameResolver.LookupType(DName name, out FormulaType fType) return false; } - } -} + } +} diff --git a/src/libraries/Microsoft.PowerFx.Core/Public/DefinitionsCheckResult.cs b/src/libraries/Microsoft.PowerFx.Core/Public/DefinitionsCheckResult.cs index 5286739741..ab415f8ee0 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Public/DefinitionsCheckResult.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Public/DefinitionsCheckResult.cs @@ -11,11 +11,13 @@ using Microsoft.PowerFx.Core.Errors; using Microsoft.PowerFx.Core.Functions; using Microsoft.PowerFx.Core.Glue; +using Microsoft.PowerFx.Core.Localization; using Microsoft.PowerFx.Core.Parser; using Microsoft.PowerFx.Core.Types; using Microsoft.PowerFx.Core.Utils; using Microsoft.PowerFx.Syntax; using Microsoft.PowerFx.Types; +using Attribute = Microsoft.PowerFx.Core.Parser.Attribute; namespace Microsoft.PowerFx { @@ -154,6 +156,101 @@ internal IReadOnlyDictionary ApplyResolveTypes() return this._resolvedTypes; } + private static readonly IReadOnlyDictionary _builtInAttributes = + new Dictionary(StringComparer.Ordinal) + { + { "Partial", new AttributeDefinition("Partial", minArgCount: 1, maxArgCount: 1) } + }; + + private List ValidateAttributes() + { + var errors = new List(); + + if (!_parserOptions.AllowAttributes) + { + return errors; + } + + foreach (var udf in _parse.UDFs) + { + ValidateAttributeList(udf.Attributes, errors); + } + + foreach (var nf in _parse.NamedFormulas) + { + ValidateAttributeList(nf.Attributes, errors); + } + + return errors; + } + + private void ValidateAttributeList(IReadOnlyList attributes, List errors) + { + foreach (var attr in attributes) + { + var attrName = attr.Name.Name.Value; + + if (!_builtInAttributes.TryGetValue(attrName, out var definition) && + !_symbols.TryGetAttributeDefinition(attrName, out definition)) + { + errors.Add(new TexlError(attr.Name, DocumentErrorSeverity.Severe, TexlStrings.ErrUnknownAttribute, attrName)); + continue; + } + + var argCount = attr.Arguments.Count; + if (argCount < definition.MinArgCount || argCount > definition.MaxArgCount) + { + errors.Add(new TexlError(attr.OpenBracket, DocumentErrorSeverity.Severe, TexlStrings.ErrAttributeArgCount, attrName, definition.MinArgCount, definition.MaxArgCount, argCount)); + } + } + } + + private List RunCustomAttributeValidation(IEnumerable udfs) + { + var errors = new List(); + + if (!_parserOptions.AllowAttributes) + { + return errors; + } + + foreach (var udf in udfs) + { + foreach (var attr in udf.Attributes) + { + var attrName = attr.Name.Name.Value; + + if (!_builtInAttributes.TryGetValue(attrName, out var definition)) + { + _symbols.TryGetAttributeDefinition(attrName, out definition); + } + + if (definition?.Validator == null) + { + continue; + } + + var context = new AttributeValidationContext( + definitionName: udf.Name, + attributeArguments: attr.Arguments, + returnType: FormulaType.Build(udf.ReturnType), + parameters: udf.GetPublicParameters()); + + foreach (var errorMsg in definition.Validator(context) ?? Enumerable.Empty()) + { + errors.Add(new ExpressionError + { + Message = errorMsg, + Span = attr.Name.Span, + Severity = ErrorSeverity.Warning + }); + } + } + } + + return errors; + } + internal TexlFunctionSet ApplyCreateUserDefinedFunctions() { if (_parse == null) @@ -175,6 +272,12 @@ internal TexlFunctionSet ApplyCreateUserDefinedFunctions() { _userDefinedFunctions = new TexlFunctionSet(); + var attrErrors = ValidateAttributes(); + if (attrErrors.Any()) + { + _errors.AddRange(ExpressionError.New(attrErrors, _defaultErrorCulture)); + } + var partialUDFs = UserDefinedFunction.CreateFunctions(_parse.UDFs.Where(udf => udf.IsParseValid), UDFBindingSymbols, out var errors); if (errors.Any()) @@ -182,6 +285,12 @@ internal TexlFunctionSet ApplyCreateUserDefinedFunctions() _errors.AddRange(ExpressionError.New(errors, _defaultErrorCulture)); } + var customErrors = RunCustomAttributeValidation(partialUDFs); + if (customErrors.Any()) + { + _errors.AddRange(customErrors); + } + foreach (var udf in partialUDFs) { var binding = udf.BindBody(UDFBindingSymbols, new Glue2DocumentBinderGlue(), UDFBindingConfig, _features); diff --git a/src/libraries/Microsoft.PowerFx.Core/Syntax/UserDefinitions.cs b/src/libraries/Microsoft.PowerFx.Core/Syntax/UserDefinitions.cs index 44358d5e64..c65f85797f 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Syntax/UserDefinitions.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Syntax/UserDefinitions.cs @@ -15,6 +15,7 @@ using Microsoft.PowerFx.Core.Types; using Microsoft.PowerFx.Core.Utils; using Microsoft.PowerFx.Syntax.SourceInformation; +using Attribute = Microsoft.PowerFx.Core.Parser.Attribute; namespace Microsoft.PowerFx.Syntax { @@ -63,6 +64,37 @@ public static ParseUserDefinitionResult Parse(string script, ParserOptions parse private static readonly string _renamedFormulaGuid = Guid.NewGuid().ToString("N"); + private enum PartialOperationKind + { + Error, + And, + Or, + Table, + Record + } + + private static PartialOperationKind GetPartialOperationKind(string operationName) + { + switch (operationName) + { + case "And": + return PartialOperationKind.And; + case "Or": + return PartialOperationKind.Or; + case "Table": + return PartialOperationKind.Table; + case "Record": + return PartialOperationKind.Record; + default: + return PartialOperationKind.Error; + } + } + + private static Attribute GetPartialAttribute(IReadOnlyList attributes) + { + return attributes?.FirstOrDefault(a => a.Name.Name.Value == "Partial"); + } + /// /// For NamedFormulas with partial attributes, /// validates that the same attribute is applied to all matching names, @@ -78,39 +110,47 @@ private ParseUserDefinitionResult ProcessPartialAttributes(ParseUserDefinitionRe foreach (var nameGroup in groupedFormulas) { var name = nameGroup.Key; - var firstAttribute = nameGroup.Select(nf => nf.Attribute).FirstOrDefault(att => att != null); + var firstPartialAttribute = nameGroup.Select(nf => GetPartialAttribute(nf.Attributes)).FirstOrDefault(a => a != null); - if (firstAttribute == null || nameGroup.Count() == 1) + if (firstPartialAttribute == null || nameGroup.Count() == 1) { newFormulas.AddRange(nameGroup); continue; } + var firstOperationName = firstPartialAttribute.Arguments.Count > 0 ? firstPartialAttribute.Arguments[0] : null; + var firstOperation = firstOperationName != null ? GetPartialOperationKind(firstOperationName) : PartialOperationKind.Error; + var updatedGroupFormulas = new List(); var id = 0; foreach (var formula in nameGroup) { - // This is just for the prototype, since we only have the one kind. - if (formula.Attribute.AttributeName.Name != "Partial") + var partialAttribute = GetPartialAttribute(formula.Attributes); + + if (partialAttribute == null || partialAttribute.Name.Name.Value != "Partial") { - errors.Add(new TexlError(formula.Attribute.AttributeOperationToken, DocumentErrorSeverity.Severe, TexlStrings.ErrOnlyPartialAttribute)); + errors.Add(new TexlError(formula.Ident, DocumentErrorSeverity.Severe, TexlStrings.ErrOnlyPartialAttribute)); continue; } - if (!firstAttribute.SameAttribute(formula.Attribute)) + var operationName = partialAttribute.Arguments.Count > 0 ? partialAttribute.Arguments[0] : null; + var operation = operationName != null ? GetPartialOperationKind(operationName) : PartialOperationKind.Error; + + if (operation != firstOperation || operationName != firstOperationName) { - errors.Add(new TexlError(formula.Attribute.AttributeOperationToken, DocumentErrorSeverity.Severe, TexlStrings.ErrOperationDoesntMatch)); + errors.Add(new TexlError(partialAttribute.Name, DocumentErrorSeverity.Severe, TexlStrings.ErrOperationDoesntMatch)); continue; } var newName = new IdentToken(name + _renamedFormulaGuid + id, formula.Ident.Span, isNonSourceIdentToken: true); id++; - updatedGroupFormulas.Add(new NamedFormula(newName, formula.Formula, formula.StartingIndex, formula.ColonEqual, formula.Attribute)); + updatedGroupFormulas.Add(new NamedFormula(newName, formula.Formula, formula.StartingIndex, formula.ColonEqual, formula.Attributes)); } - if (firstAttribute.AttributeOperation == PartialAttribute.AttributeOperationKind.Error) + if (firstOperation == PartialOperationKind.Error) { - errors.Add(new TexlError(firstAttribute.AttributeOperationToken, DocumentErrorSeverity.Severe, TexlStrings.ErrUnknownPartialOp)); + var errorToken = firstPartialAttribute.Arguments.Count > 0 ? firstPartialAttribute.ArgumentTokens[0] : (Token)firstPartialAttribute.Name; + errors.Add(new TexlError(errorToken, DocumentErrorSeverity.Severe, TexlStrings.ErrUnknownPartialOp)); // None of the "namemangled" formulas are valid at this point, even if they all matched, as we're not using a valid partial operation. updatedGroupFormulas.Clear(); @@ -126,24 +166,24 @@ private ParseUserDefinitionResult ProcessPartialAttributes(ParseUserDefinitionRe newFormulas.AddRange(updatedGroupFormulas); newFormulas.Add( new NamedFormula( - new IdentToken(name, firstAttribute.AttributeName.Span, isNonSourceIdentToken: true), - GetPartialCombinedFormula(name, firstAttribute.AttributeOperation, updatedGroupFormulas), + new IdentToken(name, firstPartialAttribute.Name.Span, isNonSourceIdentToken: true), + GetPartialCombinedFormula(name, firstOperation, updatedGroupFormulas), 0, colonEqual: true, - firstAttribute)); + nameGroup.First().Attributes)); } return new ParseUserDefinitionResult(newFormulas, parsed.UDFs, parsed.DefinedTypes, errors, parsed.Comments, parsed.UserDefinitionSourceInfos, parsed.DefinitionsLikely); } - private Formula GetPartialCombinedFormula(string name, PartialAttribute.AttributeOperationKind operationKind, IList formulas) + private Formula GetPartialCombinedFormula(string name, PartialOperationKind operationKind, IList formulas) { return operationKind switch { - PartialAttribute.AttributeOperationKind.PartialAnd => GeneratePartialFunction("And", name, formulas), - PartialAttribute.AttributeOperationKind.PartialOr => GeneratePartialFunction("Or", name, formulas), - PartialAttribute.AttributeOperationKind.PartialTable => GeneratePartialFunction("Table", name, formulas), - PartialAttribute.AttributeOperationKind.PartialRecord => GeneratePartialFunction("MergeRecords", name, formulas), + PartialOperationKind.And => GeneratePartialFunction("And", name, formulas), + PartialOperationKind.Or => GeneratePartialFunction("Or", name, formulas), + PartialOperationKind.Table => GeneratePartialFunction("Table", name, formulas), + PartialOperationKind.Record => GeneratePartialFunction("MergeRecords", name, formulas), _ => throw new InvalidOperationException("Unknown partial op while generating merged NF") }; } @@ -162,16 +202,17 @@ private Formula GeneratePartialFunction(string functionName, string name, IList< arguments.Add(new FirstNameNode(ref id, nf.Ident, new Identifier(nf.Ident))); } - var firstAttributeOpToken = formulas.First().Attribute.AttributeOperationToken; + var firstPartialAttribute = GetPartialAttribute(formulas.First().Attributes); + var firstToken = (Token)firstPartialAttribute.Name; var functionCall = new CallNode( ref id, - firstAttributeOpToken, - new SourceList(firstAttributeOpToken), - new Identifier(new IdentToken(functionName, firstAttributeOpToken.Span, true)), + firstToken, + new SourceList(firstToken), + new Identifier(new IdentToken(functionName, firstToken.Span, true)), headNode: null, - args: new ListNode(ref id, tok: firstAttributeOpToken, args: arguments.ToArray(), delimiters: null, sourceList: new SourceList(firstAttributeOpToken)), - tokParenClose: firstAttributeOpToken); + args: new ListNode(ref id, tok: firstToken, args: arguments.ToArray(), delimiters: null, sourceList: new SourceList(firstToken)), + tokParenClose: firstToken); return new Formula(script, functionCall); } diff --git a/src/strings/PowerFxResources.en-US.resx b/src/strings/PowerFxResources.en-US.resx index d5e350d6d7..26c051bc3a 100644 --- a/src/strings/PowerFxResources.en-US.resx +++ b/src/strings/PowerFxResources.en-US.resx @@ -5046,6 +5046,14 @@ A partial operator is the 2nd part of a statement `[Partial Op]` and can be one of "And", "Or", "Table" or "Record". It's used to determine how to combine multiple expressions with the same name and operator. + + Unknown attribute '{0}'. + Error when an unregistered attribute is used on a definition. + + + Attribute '{0}' expects between {1} and {2} arguments, but {3} were provided. + Error when an attribute has the wrong number of arguments. + The datasource to add data to. diff --git a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/AttributeParserTests.cs b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/AttributeParserTests.cs index 995d891fc6..6333fe3697 100644 --- a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/AttributeParserTests.cs +++ b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/AttributeParserTests.cs @@ -5,7 +5,11 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using Microsoft.PowerFx.Core.Functions; +using Microsoft.PowerFx.Core.Parser; +using Microsoft.PowerFx.Core.Utils; using Microsoft.PowerFx.Syntax; +using Microsoft.PowerFx.Types; using Xunit; namespace Microsoft.PowerFx.Core.Tests @@ -14,20 +18,54 @@ public class AttributeParserTests { private readonly ParserOptions _parseOptions = new ParserOptions() { AllowAttributes = true, AllowEqualOnlyNamedFormulas = true }; + private DefinitionsCheckResult GetCheckResult(string script, SymbolTable symbolTable = null) + { + var symbols = symbolTable ?? new SymbolTable(); + + // Add primitive types needed for UDF validation + if (!symbols.NamedTypes.ContainsKey(new DName("Number"))) + { + symbols.AddType(new DName("Number"), FormulaType.Number); + } + + var composedSymbolTable = ReadOnlySymbolTable.Compose(symbols); + var checkResult = new DefinitionsCheckResult(); + return checkResult.SetText(script, _parseOptions) + .SetBindingInfo(composedSymbolTable); + } + [Fact] - public void TestNamedFormulaAttributes() + public void TestNamedFormulaAnnotations() { var result = UserDefinitions.Parse( @" - [SomeName Ident] + [SomeName] Foo = 123; ", _parseOptions); Assert.False(result.HasErrors); Assert.Equal("Foo", result.NamedFormulas.First().Ident.Name.Value); - Assert.Equal("SomeName", result.NamedFormulas.First().Attribute.AttributeName.Name.Value); - Assert.Equal(Parser.PartialAttribute.AttributeOperationKind.Error, result.NamedFormulas.First().Attribute.AttributeOperation); + Assert.Equal("SomeName", result.NamedFormulas.First().Attributes[0].Name.Name.Value); + Assert.Empty(result.NamedFormulas.First().Attributes[0].Arguments); + } + + [Fact] + public void TestAnnotationWithStringArgs() + { + var result = UserDefinitions.Parse( + @" + [MyAttr(""hello"", ""world"")] + Foo = 123; + ", _parseOptions); + + Assert.False(result.HasErrors); + + var attribute = result.NamedFormulas.First().Attributes[0]; + Assert.Equal("MyAttr", attribute.Name.Name.Value); + Assert.Equal(2, attribute.Arguments.Count); + Assert.Equal("hello", attribute.Arguments[0]); + Assert.Equal("world", attribute.Arguments[1]); } [Fact] @@ -35,7 +73,7 @@ public void TestNFAttributeSingleKeyAnd() { var result = UserDefinitions.Parse( @" - [Partial And] + [Partial(""And"")] Foo = 123; ", _parseOptions); @@ -45,9 +83,9 @@ [Partial And] Assert.Single(result.NamedFormulas); - Assert.Equal("Foo", formulas[0].Ident.Name.Value); - Assert.Equal("Partial", formulas[0].Attribute.AttributeName.Name.Value); - Assert.Equal(Parser.PartialAttribute.AttributeOperationKind.PartialAnd, formulas[0].Attribute.AttributeOperation); + Assert.Equal("Foo", formulas[0].Ident.Name.Value); + Assert.Equal("Partial", formulas[0].Attributes[0].Name.Name.Value); + Assert.Equal("And", formulas[0].Attributes[0].Arguments[0]); } [Theory] @@ -59,9 +97,9 @@ public void TestNFAttributeOperationsCombined(string op, string combinedFunction { var result = UserDefinitions.Parse( $@" - [Partial {op}] + [Partial(""{op}"")] Foo = false; - [Partial {op}] + [Partial(""{op}"")] Foo = true; ", _parseOptions); @@ -84,9 +122,9 @@ public void TestNFAttributeOperationInvalid() { var result = UserDefinitions.Parse( $@" - [Partial Unknown] + [Partial(""Unknown"")] Foo = false; - [Partial Unknown] + [Partial(""Unknown"")] Foo = true; ", _parseOptions); @@ -97,17 +135,17 @@ [Partial Unknown] } [Fact] - public void TestMultipleNamedFormulaAttributes() + public void TestMultipleNamedFormulaAnnotations() { var result = UserDefinitions.Parse( @" - [SomeName Ident] + [SomeName(""arg1"")] Foo = 123; - [SomeName1 Ident1] + [SomeName1(""arg2"")] Foo1 = 123; - [SomeName2 Ident2] + [SomeName2(""arg3"")] Foo2 = 123; ", _parseOptions); @@ -118,47 +156,485 @@ [SomeName2 Ident2] Assert.Equal(3, result.NamedFormulas.Count()); Assert.Equal("Foo", formulas[0].Ident.Name.Value); - Assert.Equal("SomeName", formulas[0].Attribute.AttributeName.Name.Value); - Assert.Equal("Ident", formulas[0].Attribute.AttributeOperationToken.As().Name.Value); + Assert.Equal("SomeName", formulas[0].Attributes[0].Name.Name.Value); + Assert.Equal("arg1", formulas[0].Attributes[0].Arguments[0]); Assert.Equal("Foo1", formulas[1].Ident.Name.Value); - Assert.Equal("SomeName1", formulas[1].Attribute.AttributeName.Name.Value); - Assert.Equal("Ident1", formulas[1].Attribute.AttributeOperationToken.As().Name.Value); + Assert.Equal("SomeName1", formulas[1].Attributes[0].Name.Name.Value); + Assert.Equal("arg2", formulas[1].Attributes[0].Arguments[0]); Assert.Equal("Foo2", formulas[2].Ident.Name.Value); - Assert.Equal("SomeName2", formulas[2].Attribute.AttributeName.Name.Value); - Assert.Equal("Ident2", formulas[2].Attribute.AttributeOperationToken.As().Name.Value); + Assert.Equal("SomeName2", formulas[2].Attributes[0].Name.Name.Value); + Assert.Equal("arg3", formulas[2].Attributes[0].Arguments[0]); + } + + [Fact] + public void TestAnnotationOnUDF() + { + var result = UserDefinitions.Parse( + @" + [MyAnnotation(""Foo"")] + MyFunc(): Number = 1; + ", _parseOptions); + + Assert.False(result.HasErrors); + + var udf = result.UDFs.First(); + Assert.Equal("MyFunc", udf.Ident.Name.Value); + Assert.Single(udf.Attributes); + Assert.Equal("MyAnnotation", udf.Attributes[0].Name.Name.Value); + Assert.Single(udf.Attributes[0].Arguments); + Assert.Equal("Foo", udf.Attributes[0].Arguments[0]); } [Fact] - public void TestErrorNamedFormulaAttributes() + public void TestMultipleAnnotationsOnUDF() { var result = UserDefinitions.Parse( @" - [SomeNa.me Iden()t] + [Ann1(""x"")] [Ann2(""y"", ""z"")] + MyFunc(): Number = 1; + ", _parseOptions); + + Assert.False(result.HasErrors); + + var udf = result.UDFs.First(); + Assert.Equal("MyFunc", udf.Ident.Name.Value); + Assert.Equal(2, udf.Attributes.Count); + + Assert.Equal("Ann1", udf.Attributes[0].Name.Name.Value); + Assert.Equal("x", udf.Attributes[0].Arguments[0]); + + Assert.Equal("Ann2", udf.Attributes[1].Name.Name.Value); + Assert.Equal(2, udf.Attributes[1].Arguments.Count); + Assert.Equal("y", udf.Attributes[1].Arguments[0]); + Assert.Equal("z", udf.Attributes[1].Arguments[1]); + } + + [Fact] + public void TestAnnotationNoArgs() + { + var result = UserDefinitions.Parse( + @" + [NoArgs] Foo = 123; + ", _parseOptions); - [SomeName1 Ident1] - Foo1 = 123; + Assert.False(result.HasErrors); - [SomeName2 Ident2] - Foo2 = 123; + var formula = result.NamedFormulas.First(); + Assert.Single(formula.Attributes); + Assert.Equal("NoArgs", formula.Attributes[0].Name.Name.Value); + Assert.Empty(formula.Attributes[0].Arguments); + } + + [Fact] + public void TestAnnotationEmptyParens() + { + var result = UserDefinitions.Parse( + @" + [NoArgs()] + Foo = 123; ", _parseOptions); - Assert.True(result.HasErrors); + Assert.False(result.HasErrors); - Assert.Equal(2, result.NamedFormulas.Count()); + var formula = result.NamedFormulas.First(); + Assert.Single(formula.Attributes); + Assert.Equal("NoArgs", formula.Attributes[0].Name.Name.Value); + Assert.Empty(formula.Attributes[0].Arguments); + } - // Error in the first definition's attribute, it gets skipped and we restart on the next one - var formulas = result.NamedFormulas.ToList(); + [Fact] + public void TestAnnotationFlowsToUserDefinedFunction() + { + // Verify attributes are preserved on parsed UDF and survive through CreateFunctions + var parseResult = UserDefinitions.Parse( + @"[MyAnnotation(""TestValue"")] MyFunc(): Number = 1;", + _parseOptions); + + Assert.False(parseResult.HasErrors); + + var parsedUdf = parseResult.UDFs.First(); + Assert.True(parsedUdf.IsParseValid); + Assert.Single(parsedUdf.Attributes); + Assert.Equal("MyAnnotation", parsedUdf.Attributes[0].Name.Name.Value); + Assert.Equal("TestValue", parsedUdf.Attributes[0].Arguments[0]); + + // Verify attributes survive through CreateFunctions using a name resolver with built-in types + var primitiveTypes = new SymbolTable(); + primitiveTypes.AddType(new DName("Number"), FormulaType.Number); + + var udfs = UserDefinedFunction.CreateFunctions( + parseResult.UDFs.Where(u => u.IsParseValid), + primitiveTypes, + out var errors); + + Assert.Empty(errors); + + var func = udfs.First(); + Assert.Single(func.Attributes); + Assert.Equal("MyAnnotation", func.Attributes[0].Name.Name.Value); + Assert.Equal("TestValue", func.Attributes[0].Arguments[0]); + } + + [Fact] + public void TestUDFWithNoAnnotations() + { + var result = UserDefinitions.Parse( + @" + MyFunc(): Number = 1; + ", _parseOptions); + + Assert.False(result.HasErrors); + + var udf = result.UDFs.First(); + Assert.Empty(udf.Attributes); + } + + [Fact] + public void TestMultipleAnnotationsOnMultipleUDFs() + { + var result = UserDefinitions.Parse( + @" + [A(""1"")] + Func1(): Number = 1; + [B(""2"")] [C(""3"")] + Func2(): Number = 2; + ", _parseOptions); + + Assert.False(result.HasErrors); + + var udfs = result.UDFs.ToList(); + Assert.Equal(2, udfs.Count); + + Assert.Single(udfs[0].Attributes); + Assert.Equal("A", udfs[0].Attributes[0].Name.Name.Value); + + Assert.Equal(2, udfs[1].Attributes.Count); + Assert.Equal("B", udfs[1].Attributes[0].Name.Name.Value); + Assert.Equal("C", udfs[1].Attributes[1].Name.Name.Value); + } + + [Fact] + public void TestUnknownAttributeOnNFProducesError() + { + var checkResult = GetCheckResult("[UnknownAttr] X = 1;"); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.False(checkResult.IsSuccess); + Assert.Contains(checkResult.Errors, e => e.Message.Contains("UnknownAttr")); + } + + [Fact] + public void TestUnknownAttributeOnUDFProducesError() + { + var checkResult = GetCheckResult("[UnknownAttr] MyFunc(): Number = 1;"); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.False(checkResult.IsSuccess); + Assert.Contains(checkResult.Errors, e => e.Message.Contains("UnknownAttr")); + } + + [Fact] + public void TestRegisteredAttributeOnNFSucceeds() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition("MyAttr", 0, 0)); - Assert.Equal("Foo1", formulas[0].Ident.Name.Value); - Assert.Equal("SomeName1", formulas[0].Attribute.AttributeName.Name.Value); - Assert.Equal("Ident1", formulas[0].Attribute.AttributeOperationToken.As().Name.Value); + var checkResult = GetCheckResult("[MyAttr] X = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); - Assert.Equal("Foo2", formulas[1].Ident.Name.Value); - Assert.Equal("SomeName2", formulas[1].Attribute.AttributeName.Name.Value); - Assert.Equal("Ident2", formulas[1].Attribute.AttributeOperationToken.As().Name.Value); + Assert.True(checkResult.IsSuccess); + } + + [Fact] + public void TestRegisteredAttributeOnUDFSucceeds() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition("MyAttr", 0, 0)); + + var checkResult = GetCheckResult("[MyAttr] MyFunc(): Number = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.True(checkResult.IsSuccess); + } + + [Fact] + public void TestAttributeArgCountTooFew() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition("Foo", 1, 2)); + + var checkResult = GetCheckResult("[Foo] X = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.False(checkResult.IsSuccess); + Assert.Contains(checkResult.Errors, e => e.Message.Contains("Foo")); + } + + [Fact] + public void TestAttributeArgCountTooMany() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition("Foo", 0, 1)); + + var checkResult = GetCheckResult(@"[Foo(""a"", ""b"")] X = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.False(checkResult.IsSuccess); + Assert.Contains(checkResult.Errors, e => e.Message.Contains("Foo")); + } + + [Fact] + public void TestBuiltInPartialNeedsNoRegistration() + { + // Partial is a built-in attribute; no host registration needed + var checkResult = GetCheckResult( + @"[Partial(""And"")] X = 1; [Partial(""And"")] X = 2;"); + checkResult.ApplyCreateUserDefinedFunctions(); + + // Should have no unknown-attribute errors + Assert.DoesNotContain(checkResult.Errors, e => e.Message.Contains("Unknown attribute")); + } + + [Fact] + public void TestComposedSymbolTableAttributeLookup() + { + var table1 = new SymbolTable(); + table1.AddAttribute(new AttributeDefinition("AttrA", 0, 0)); + + var table2 = new SymbolTable(); + table2.AddAttribute(new AttributeDefinition("AttrB", 1, 1)); + + var composed = ReadOnlySymbolTable.Compose(table1, table2); + + Assert.True(composed.TryGetAttributeDefinition("AttrA", out var defA)); + Assert.Equal("AttrA", defA.Name); + + Assert.True(composed.TryGetAttributeDefinition("AttrB", out var defB)); + Assert.Equal("AttrB", defB.Name); + + Assert.False(composed.TryGetAttributeDefinition("Unknown", out _)); + } + + [Fact] + public void TestAllowAttributesFalseSkipsValidation() + { + // With AllowAttributes = false, attributes aren't parsed at all, so no validation + var options = new ParserOptions() { AllowAttributes = false, AllowEqualOnlyNamedFormulas = true }; + var checkResult = new DefinitionsCheckResult(); + checkResult.SetText("X = 1;", options) + .SetBindingInfo(ReadOnlySymbolTable.Compose(new SymbolTable())); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.True(checkResult.IsSuccess); + } + + [Fact] + public void TestDuplicateAttributeRegistrationThrows() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition("MyAttr", 0, 0)); + + Assert.Throws(() => + symbolTable.AddAttribute(new AttributeDefinition("MyAttr", 1, 1))); + } + + [Fact] + public void TestRegisteredAttributeWithCorrectArgs() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition("Tag", 1, 3)); + + var checkResult = GetCheckResult(@"[Tag(""hello"", ""world"")] MyFunc(): Number = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.True(checkResult.IsSuccess); + } + + [Fact] + public void TestCustomValidatorAcceptsMatchingSignature() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition( + "RequiresNumber", + 0, + 0, + validator: ctx => + { + if (ctx.ReturnType != FormulaType.Number) + { + return new[] { "Function must return Number." }; + } + + return Array.Empty(); + })); + + var checkResult = GetCheckResult("[RequiresNumber] MyFunc(): Number = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.True(checkResult.IsSuccess); + Assert.DoesNotContain(checkResult.Errors, e => e.Message == "Function must return Number."); + } + + [Fact] + public void TestCustomValidatorRejectsWrongReturnType() + { + var symbolTable = new SymbolTable(); + symbolTable.AddType(new DName("Text"), FormulaType.String); + symbolTable.AddAttribute(new AttributeDefinition( + "RequiresNumber", + 0, + 0, + validator: ctx => + { + if (ctx.ReturnType != FormulaType.Number) + { + return new[] { "Function must return Number." }; + } + + return Array.Empty(); + })); + + var checkResult = GetCheckResult(@"[RequiresNumber] MyFunc(): Text = ""hello"";", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + // Custom validation errors are warnings — IsSuccess stays true + Assert.True(checkResult.IsSuccess); + Assert.Contains(checkResult.Errors, e => e.Message == "Function must return Number." && e.IsWarning); + } + + [Fact] + public void TestCustomValidatorChecksParameters() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition( + "NeedsNumberParam", + 0, + 0, + validator: ctx => + { + if (ctx.Parameters.Count != 1 || ctx.Parameters[0].Type != FormulaType.Number) + { + return new[] { "Function must have exactly one Number parameter." }; + } + + return Array.Empty(); + })); + + // Matching signature + var checkResult = GetCheckResult("[NeedsNumberParam] MyFunc(x: Number): Number = x;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + Assert.DoesNotContain(checkResult.Errors, e => e.Message == "Function must have exactly one Number parameter."); + + // Non-matching: no parameters + var checkResult2 = GetCheckResult("[NeedsNumberParam] MyFunc(): Number = 1;", symbolTable); + checkResult2.ApplyCreateUserDefinedFunctions(); + Assert.Contains(checkResult2.Errors, e => e.Message == "Function must have exactly one Number parameter." && e.IsWarning); + } + + [Fact] + public void TestNullValidatorNoOp() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition("NoValidator", 0, 0)); + + var checkResult = GetCheckResult("[NoValidator] MyFunc(): Number = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.True(checkResult.IsSuccess); + } + + [Fact] + public void TestValidatorReturnsEmpty() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition("AlwaysOk", 0, 0, validator: ctx => Array.Empty())); + + var checkResult = GetCheckResult("[AlwaysOk] MyFunc(): Number = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.True(checkResult.IsSuccess); + } + + [Fact] + public void TestMultipleValidatorErrorMessages() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition("Strict", 0, 0, validator: ctx => new[] { "Error one.", "Error two." })); + + var checkResult = GetCheckResult("[Strict] MyFunc(): Number = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.Contains(checkResult.Errors, e => e.Message == "Error one." && e.IsWarning); + Assert.Contains(checkResult.Errors, e => e.Message == "Error two." && e.IsWarning); + } + + [Fact] + public void TestCustomValidatorErrorsAreWarnings() + { + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition("WarnMe", 0, 0, validator: ctx => new[] { "This is a warning." })); + + var checkResult = GetCheckResult("[WarnMe] MyFunc(): Number = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + // IsSuccess should still be true because custom validation errors are warnings + Assert.True(checkResult.IsSuccess); + var warning = Assert.Single(checkResult.Errors, e => e.Message == "This is a warning."); + Assert.True(warning.IsWarning); + } + + [Fact] + public void TestCustomValidatorDoesNotRunOnNF() + { + var validatorCalled = false; + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition( + "UdfOnly", + 0, + 0, + validator: ctx => + { + validatorCalled = true; + return new[] { "Should not appear." }; + })); + + var checkResult = GetCheckResult("[UdfOnly] X = 1;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.False(validatorCalled); + Assert.DoesNotContain(checkResult.Errors, e => e.Message == "Should not appear."); + } + + [Fact] + public void TestCustomValidatorReceivesCorrectContext() + { + AttributeValidationContext capturedContext = null; + var symbolTable = new SymbolTable(); + symbolTable.AddAttribute(new AttributeDefinition( + "Capture", + 1, + 2, + validator: ctx => + { + capturedContext = ctx; + return Array.Empty(); + })); + + var checkResult = GetCheckResult(@"[Capture(""arg1"", ""arg2"")] MyFunc(x: Number): Number = x;", symbolTable); + checkResult.ApplyCreateUserDefinedFunctions(); + + Assert.NotNull(capturedContext); + Assert.Equal("MyFunc", capturedContext.DefinitionName); + Assert.Equal(2, capturedContext.AttributeArguments.Count); + Assert.Equal("arg1", capturedContext.AttributeArguments[0]); + Assert.Equal("arg2", capturedContext.AttributeArguments[1]); + Assert.Equal(FormulaType.Number, capturedContext.ReturnType); + Assert.Single(capturedContext.Parameters); + Assert.Equal("x", capturedContext.Parameters[0].Name.Value); + Assert.Equal(FormulaType.Number, capturedContext.Parameters[0].Type); } } } diff --git a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/PublicSurfaceTests.cs b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/PublicSurfaceTests.cs index c79698f55d..99415efdc3 100644 --- a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/PublicSurfaceTests.cs +++ b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/PublicSurfaceTests.cs @@ -27,7 +27,7 @@ public void PublicSurface_Tests() var allowed = new HashSet() { - // Core namespace. + // Core namespace. "Microsoft.PowerFx.CheckResult", "Microsoft.PowerFx.DefinitionsCheckResult", "Microsoft.PowerFx.CheckContextSummary", diff --git a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/UDFHasSameDefinitionTests.cs b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/UDFHasSameDefinitionTests.cs index 15920f42ab..62c6f6f3ef 100644 --- a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/UDFHasSameDefinitionTests.cs +++ b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/UDFHasSameDefinitionTests.cs @@ -46,11 +46,30 @@ public class UDFHasSameDefinitionTests : PowerFxTest // Imperative UDF vs Declarative UDF [InlineData("Foo(x: Number): Number = Abs(x);", "Foo(x: Number): Number = {Abs(x)};", false)] + + // test with attributes - same attributes + [InlineData("[MyAttr(\"a\")] Foo(x: Number): Number = Abs(x);", "[MyAttr(\"a\")] Foo(x: Number): Number = Abs(x);", true)] + + // test with attributes - different attribute names + [InlineData("[MyAttr(\"a\")] Foo(x: Number): Number = Abs(x);", "[Other(\"a\")] Foo(x: Number): Number = Abs(x);", false)] + + // test with attributes - different attribute arguments + [InlineData("[MyAttr(\"a\")] Foo(x: Number): Number = Abs(x);", "[MyAttr(\"b\")] Foo(x: Number): Number = Abs(x);", false)] + + // test with attributes - one has attribute, other doesn't + [InlineData("[MyAttr(\"a\")] Foo(x: Number): Number = Abs(x);", "Foo(x: Number): Number = Abs(x);", false)] + + // test with attributes - different number of attributes + [InlineData("[MyAttr(\"a\")] [Other(\"b\")] Foo(x: Number): Number = Abs(x);", "[MyAttr(\"a\")] Foo(x: Number): Number = Abs(x);", false)] + + // test with attributes - same multiple attributes + [InlineData("[MyAttr(\"a\")] [Other(\"b\")] Foo(x: Number): Number = Abs(x);", "[MyAttr(\"a\")] [Other(\"b\")] Foo(x: Number): Number = Abs(x);", true)] public void TestSimpleUDFSameness(string udfFormula1, string udfFormula2, bool areSame) { var parserOptions = new ParserOptions() { - AllowsSideEffects = true + AllowsSideEffects = true, + AllowAttributes = true }; var types = UDTTestHelper.TestTypesDictionaryWithNumberTypeIsFloat.Union(new Dictionary()