From 588560a4567931ef9cfd5877f267ac32f4a269d4 Mon Sep 17 00:00:00 2001 From: Rene Modery Date: Wed, 11 Mar 2026 21:11:15 +0800 Subject: [PATCH] Publicly accessible PrettyPrint in CheckResult #2582 --- .../Public/CheckResult.cs | 28 ++++++ .../FormatterTests.cs | 88 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/libraries/Microsoft.PowerFx.Core/Public/CheckResult.cs b/src/libraries/Microsoft.PowerFx.Core/Public/CheckResult.cs index 6b67f0f716..559b4bc213 100644 --- a/src/libraries/Microsoft.PowerFx.Core/Public/CheckResult.cs +++ b/src/libraries/Microsoft.PowerFx.Core/Public/CheckResult.cs @@ -11,6 +11,7 @@ using Microsoft.PowerFx.Core.IR; using Microsoft.PowerFx.Core.Localization; using Microsoft.PowerFx.Core.Logging; +using Microsoft.PowerFx.Core.Parser; using Microsoft.PowerFx.Core.Public; using Microsoft.PowerFx.Core.Public.Types.TypeCheckers; using Microsoft.PowerFx.Core.Texl.Intellisense; @@ -673,6 +674,33 @@ public string ApplyGetInvariant() return _expressionInvariant; } + /// + /// Get the pretty-printed form of the expression. + /// If there are parse errors, returns the original expression text unchanged. + /// Always enables expression chaining and uses lenient disambiguation syntax + /// to avoid false negatives from deprecation errors during formatting. + /// + /// The formatted expression string. + public string ApplyFormat() + { + if (_expression == null) + { + throw new InvalidOperationException($"Must call {nameof(SetText)} before calling {nameof(ApplyFormat)}()."); + } + + var features = _engine?.Config?.Features; + + // Use lenient features: keep structural features (e.g. IsUserDefinedTypesEnabled + // for Type literals) but allow [@...] disambiguation syntax which is still + // parseable even when deprecated. Without this, deprecation errors prevent formatting. + if (features != null && features.DisableRowScopeDisambiguationSyntax) + { + features = new Features(features) { DisableRowScopeDisambiguationSyntax = false }; + } + + return TexlParser.Format(_expression, features: features); + } + /// /// Get anonymous form of expression with all PII removed. Suitable for logging to /// capture the structure of the expression. diff --git a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/FormatterTests.cs b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/FormatterTests.cs index ba7bed37e5..cb1d5a4d08 100644 --- a/src/tests/Microsoft.PowerFx.Core.Tests.Shared/FormatterTests.cs +++ b/src/tests/Microsoft.PowerFx.Core.Tests.Shared/FormatterTests.cs @@ -299,6 +299,94 @@ public void TestPrettyPrint(string script, string expected) Assert.Equal(expected, result); } + [Fact] + public void TestApplyFormatWithParseError() + { + // Expression with a parse error should return the original text unchanged. + var badExpression = "If(1,"; + var check = new CheckResult(new Engine(new PowerFxConfig())); + check.SetText(badExpression, new ParserOptions { AllowsSideEffects = true }); + var formatted = check.ApplyFormat(); + Assert.Equal(badExpression, formatted); + } + + [Theory] + [InlineData("foo[@bar]", "foo[@bar]")] + [InlineData("Back()", "Back()")] + [InlineData("false", "false")] + [InlineData("\"Are you sure you want to delete this \r\nreceipt?\"", "\"Are you sure you want to delete this \r\nreceipt?\"")] + [InlineData("RGBA(255,255,255,1)", "RGBA(\n 255,\n 255,\n 255,\n 1\n)")] + [InlineData("RGBA(255, /*r*/255, 255, 1)//com", "RGBA(\n 255,/*r*/\n 255,\n 255,\n 1\n)//com")] + [InlineData("ColorFade(Button4.BorderColor, 20%)", "ColorFade(\n Button4.BorderColor,\n 20%\n)")] + [InlineData("If(!IsBlank(NewAddressText.Text)&&!IsBlank(NewCityText.Text)&&!IsBlank(NewZipText.Text)&&!IsBlank(NewStateText.Text)&&!IsBlank(NewTitleText.Text)&&!IsBlank(NewSubTitleText.Text)||!IsBlank(NewTitleText.Text)&&!IsBlank(NewSubTitleText.Text)&&Radio1_4.Selected.Value=\"Use GPS for current Location\", true)", "If(\n !IsBlank(NewAddressText.Text) && !IsBlank(NewCityText.Text) && !IsBlank(NewZipText.Text) && !IsBlank(NewStateText.Text) && !IsBlank(NewTitleText.Text) && !IsBlank(NewSubTitleText.Text) || !IsBlank(NewTitleText.Text) && !IsBlank(NewSubTitleText.Text) && Radio1_4.Selected.Value = \"Use GPS for current Location\",\n true\n)")] + [InlineData("Set(ErrorC,0);ForAll(myData,Patch('[dbo].[BookingData]',First(Filter('[dbo].[BookingData]',ID=myData[@ID])),{FiscalYearValue:myData[@FiscalYearValue],ModifiedBy:TextInput1.Text}));If(CountRows(Errors('[dbo].[BookingData]'))>ErrorC,Set(Err,CountRows(Errors('[dbo].[BookingData]'))&\" \"&First(Errors('[dbo].[BookingData]')).Message),Set(Err,\"Success\"))", "Set(\n ErrorC,\n 0\n);\nForAll(\n myData,\n Patch(\n '[dbo].[BookingData]',\n First(\n Filter(\n '[dbo].[BookingData]',\n ID = myData[@ID]\n )\n ),\n {\n FiscalYearValue: myData[@FiscalYearValue],\n ModifiedBy: TextInput1.Text\n }\n )\n);\nIf(\n CountRows(Errors('[dbo].[BookingData]')) > ErrorC,\n Set(\n Err,\n CountRows(Errors('[dbo].[BookingData]')) & \" \" & First(Errors('[dbo].[BookingData]')).Message\n ),\n Set(\n Err,\n \"Success\"\n )\n)")] + [InlineData("\"(\"&RoundUp(Value(UsedHrsText.Text/8),1) &\" DAYS)\"", "\"(\" & RoundUp(\n Value(UsedHrsText.Text / 8),\n 1\n) & \" DAYS)\"")] + [InlineData("If(true = !true,false,true)", "If(\n true = !true,\n false,\n true\n)")] + [InlineData("If(true > -1,false,true)", "If(\n true > -1,\n false,\n true\n)")] + [InlineData("If(1 <= -1,-11 >= -1,-11 < 1)", "If(\n 1 <= -1,\n -11 >= -1,\n -11 < 1\n)")] + [InlineData("If(true <> -1,false,true)", "If(\n true <> -1,\n false,\n true\n)")] + [InlineData("If(true = Not true,false,true)", "If(\n true = Not true,\n false,\n true\n)")] + [InlineData("If(7% = !true,false,true)", "If(\n 7% = !true,\n false,\n true\n)")] + [InlineData("-Label1.X & -Label1.Y", "-Label1.X & -Label1.Y")] + [InlineData("(-Label1.X) & (-Label1.Y)", "(-Label1.X) & (-Label1.Y)")] + [InlineData("UsedHrsText.Text-TextBox7_1.Text", "UsedHrsText.Text - TextBox7_1.Text")] + [InlineData("If(!IsBlank(Address.Text) || !IsBlank(City.Text) || !IsBlank(States.Text) || !IsBlank(ZipCode.Text),SubmitForm(Form1),UpdateContext({error1:true}))", "If(\n !IsBlank(Address.Text) || !IsBlank(City.Text) || !IsBlank(States.Text) || !IsBlank(ZipCode.Text),\n SubmitForm(Form1),\n UpdateContext({error1: true})\n)")] + [InlineData("If(!IsBlank(Address.Text) && !IsBlank(City.Text) && !IsBlank(States.Text) && !IsBlank(ZipCode.Text),SubmitForm(Form1),UpdateContext({error1:true}))", "If(\n !IsBlank(Address.Text) && !IsBlank(City.Text) && !IsBlank(States.Text) && !IsBlank(ZipCode.Text),\n SubmitForm(Form1),\n UpdateContext({error1: true})\n)")] + [InlineData("If(CountRows(Filter('Time Off Requests','Created On'>=Today()&&Owner=LookUp(Users_1,'User Name'=User().Email,User)))>0,Navigate([@'Attendance Already Submitted'],ScreenTransition.Cover),NewForm(AttendanceForm));Navigate('Save Attendance',Fade)", "If(\n CountRows(\n Filter(\n 'Time Off Requests',\n 'Created On' >= Today() && Owner = LookUp(\n Users_1,\n 'User Name' = User().Email,\n User\n )\n )\n ) > 0,\n Navigate(\n [@'Attendance Already Submitted'],\n ScreenTransition.Cover\n ),\n NewForm(AttendanceForm)\n);\nNavigate(\n 'Save Attendance',\n Fade\n)")] + [InlineData("[1]", "[1]")] + [InlineData("[1, 2, 3]", "[\n 1,\n 2,\n 3\n]")] + [InlineData("If(true, [1, 2, 3], [3])", "If(\n true,\n [\n 1,\n 2,\n 3\n ],\n [3]\n)")] + [InlineData("((((1 + 2))))", "((((1 + 2))))")] + [InlineData("((1 + 2) + 3)", "((1 + 2) + 3)")] + [InlineData("(1 + (2 + 3))", "(1 + (2 + 3))")] + [InlineData("(1 * 2) + 3)", "(1 * 2) + 3)")] + [InlineData("Namespace.Call(1, 2, 3)", "Namespace.Call(\n 1,\n 2,\n 3\n)")] + [InlineData("ColorFade(RGBA(56,96,178,1),-(1+3)%%)", "ColorFade(\n RGBA(\n 56,\n 96,\n 178,\n 1\n ),\n -(1 + 3)%%\n)")] + [InlineData("/*jj*/\r\nRGBA(255, 255, 255, 1)\n//yes", "/*jj*/\r\nRGBA(\n 255,\n 255,\n 255,\n 1\n)\n//yes")] + [InlineData("/*jj*/\nRGBA(\n /*j2*/\n 255,\n 255,\n 255,\n 1\n)\n//yes", "/*jj*/\nRGBA(\n /*j2*/\n 255,\n 255,\n 255,\n 1\n)\n//yes")] + [InlineData("/*x*/Call(/*a*/1/*b*/;/*c*/2/*d*/;/*e*/3/*f*/, /*g*/4/*h*/)/*y*/", "/*x*/Call(/*a*/\n 1/*b*/;\n /*c*/2/*d*/;\n /*e*/3/*f*/,/*g*/\n 4/*h*/\n)/*y*/")] + [InlineData("/*a*/[/*b*/1/*c*/,/*d*/2/*e*/]/*f*/", "/*a*/[\n /*b*/1/*c*/,\n /*d*/2/*e*/\n]/*f*/")] + [InlineData("/*a*/{/*b*/name/*c*/:/*d*/1/*e*/,\n/*f*/name2/*g*/:/*h*/2/*i*/}/*j*/", "/*a*/{\n /*b*/\n name/*c*/: /*d*/1/*e*/,\n /*f*/\n name2/*g*/: /*h*/2/*i*/\n}/*j*/")] + //// Make sure there's no lost trivia + [InlineData("/*a*/foo/*b*/[/*c*/@/*d*/bar/*e*/]/*f*/", "/*a*/foo/*b*/[/*c*/@/*d*/bar/*e*/]/*f*/")] + [InlineData("1; /*a*/2/*b*/; 3", "1;\n/*a*/2/*b*/;\n3")] + [InlineData("/*a*/1/*b*/+/*c*/2/*d*/-/*e*/3/*f*/", "/*a*/1 /*b*/+/*c*/ 2 /*d*/-/*e*/ 3/*f*/")] + [InlineData("$\"Hello {\"World\"}\"", "$\"Hello {\"World\"}\"")] + [InlineData("$\"Hello { \"World\" }\"", "$\"Hello {\"World\"}\"")] + [InlineData("$\"Hello {/*a*/\"World\"}\"", "$\"Hello {/*a*/\"World\"}\"")] + [InlineData("/*a*/$\"Hello {\"World\"}\"", "/*a*/$\"Hello {\"World\"}\"")] + [InlineData("$\"Hello {\"World\"/*b*/}\"", "$\"Hello {\"World\"/*b*/}\"")] + [InlineData("$\"Hello {\"World\"}\"/*b*/", "$\"Hello {\"World\"}\"/*b*/")] + [InlineData("$\"{{}}\"", "$\"{{}}\"")] + [InlineData("This is not an interpolated {} {{{}}} string", "This is not an interpolated {} {{{}}} string")] + [InlineData("$\"{{{{1+1}}}}\"", "$\"{{{{1+1}}}}\"")] + [InlineData("Set(str, $\"{{}}\")", "Set(\n str,\n $\"{{}}\"\n)")] + [InlineData("Set(additionText, $\"The sum of 1 and 3 is {{{1 + 3}}})\")", "Set(\n additionText,\n $\"The sum of 1 and 3 is {{{1 + 3}}})\"\n)")] + [InlineData("$\"This is {{\"Another\"}} interpolated {{string}}\"", "$\"This is {{\"Another\"}} interpolated {{string}}\"")] + [InlineData("ParseJSON(\"[]\", Type([{Age: Number}]))", "ParseJSON(\n \"[]\",\n Type([{Age: Number}])\n)")] + [InlineData("Type([{Age: Number, Name: Text}])", "Type([\n {\n Age: Number,\n Name: Text\n }\n])")] + [InlineData("Type(RecordOf(Accounts))", "Type(RecordOf(Accounts))")] + [InlineData( + "If(\n true,\n 1;\n // Comment in the middle of a chain\n 2\n)", + "If(\n true,\n 1;\n // Comment in the middle of a chain\n 2\n)")] + [InlineData( + "If(\n true,\n 1;\n /* Block comment in the middle of a chain */\n 2\n)", + "If(\n true,\n 1;\n /* Block comment in the middle of a chain */2\n)")] + public void TestApplyFormat(string script, string expected) + { + var check = new CheckResult(new Engine(new PowerFxConfig())); + check.SetText(script); + var formatted = check.ApplyFormat(); + Assert.NotNull(formatted); + Assert.Equal(expected, formatted); + + // Ensure idempotence + check = new CheckResult(new Engine(new PowerFxConfig())); + check.SetText(formatted); + formatted = check.ApplyFormat(); + Assert.Equal(expected, formatted); + } + [Theory] // without colon