Repository navigation
Fix #1300 - nullable loop conditions - #1312
Merged
GrahamTheCoder merged 21 commits intoNov 11, 2026
Merged
GrahamTheCoder merged 21 commits into
GrahamTheCoder merged 21 commits into
Conversation
Pure refactor so loop conditions can share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
VB's binder passes the condition context through parentheses, so Nothing means False. Previously a (bool) cast was emitted, which throws on null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Pure refactor: top and bottom While/Until clauses share one conversion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
VB converts these conditions to Boolean (Nothing to False) like If conditions, but they weren't treated as Boolean contexts, so a Boolean? condition gave CS0266. Until conditions are converted before being inverted, matching VB, so an Until loop continues while its condition is Nothing. Fixes icsharpcode#1300 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
With Option Strict Off, a nullable non-Boolean condition (e.g. If x Then, While x with x As Integer?) converted to Conversions.ToBoolean(x.Value), which throws when x is Nothing. VB lifts the conversion and treats Nothing as False, so use GetValueOrDefault: its zero default also converts to False. Also pins Option Strict Off Object conditions in loops, which already worked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Pure refactor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
…nversion A nullable structure with a Widening CType to Boolean used as a condition converted to (bool)s, which throws when s is Nothing. VB lifts the conversion and treats Nothing as False. Since the operator may convert the default value to True, check HasValue (or pattern-match a non-reusable expression) first. Also pins that conversions outside conditions keep .Value and throw like VB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
GrahamTheCoder
marked this pull request as draft
November 10, 2026 19:21
A conditional or coalesce expression as the operand of a generated
'is { } argN' / 'is var argN' pattern was emitted without parentheses, so the
C# text parsed differently (e.g. If(a, b, c) as a nullable structure condition
gave CS0173). The syntax tree was right, so only reparsing showed it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
VB doesn't lift a CType returning Boolean? the same way: it throws for a Nothing operand and treats a Nothing result as False. Leave such conditions with the previous cast output rather than emitting HasValue checks that hide the throw and still throw on a Nothing result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Pure refactor of the NullableBool condition, plus a comment explaining why a test pins master's output for CType returning Boolean?. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Pure refactor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
A condition converted by a user-defined CType returning Boolean? gave (bool)s, which throws when the operator returns Nothing; VB treats that as False. Emit (bool?)s.Value == true: .Value keeps VB's throw for a Nothing operand, which C# would otherwise lift to null. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
…tions A lifted <, <=, > or >= is false when an operand is null, so its negated operator would be false too rather than true. The output is unchanged: the inversion previously stopped at a redundant (bool) cast by coincidence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
The (bool) cast was redundant (the simplifier removed it), but it stopped an Until loop's inversion from simplifying, e.g. Loop Until Not b gave !(b == false). Now it gives b != false, and Do Until b = True gives b != true. Other Until conditions are now inverted through && and ||. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
… conditions Where Not (x < y) is only tested for True, x >= y is equivalent: both are false when an operand is Nothing. So While Not (x < y) gives while (x >= y), and Do Until Not (x < y) gives while (!(x >= y)). Floating point (NaN is unordered), Boolean, structures, and = and <> keep the existing form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
A ternary Boolean? condition such as If If(f, x < y, x > y) or Do Until If(f, b, Not b) gave f ? A : B == true, which reparses as f ? A : (B == true) (CS0173/CS0266). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Inverting only the condition of a ternary swaps its arms rather than negating it: Do Until If(f, a, b) gave while (!f ? a : b). Now it gives while (!(f ? a : b)). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
…ions InvertCondition now refuses to flip a <, <=, > or >= the nullable converter marked as a lifted comparison whose operand may be null, rather than Until loops rescanning the VB condition and refusing every ordering comparison in it. So Do Until x > y AndAlso i < 3 (x, y nullable) gives !(x > y) || i >= 3 instead of !(x > y) || !(i < 3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
Pure refactor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
…NegatedForParentNotAnnotation Pure refactor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg
GrahamTheCoder
marked this pull request as ready for review
November 11, 2026 14:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1300
Root cause
VbSyntaxNodeExtensions.AlwaysHasBooleanTypeInCSharpis the shared, syntax-based list of places where VB binds a condition withBindBooleanExpression. There, a nullable value that is Nothing becomes False. The nullable converter andTypeConversionAnalyzer(TypeConversionKind.NullableBool) use it, and statement visitors callAddExplicitConversion(..., forceTargetType: Boolean). There were four problems:If(),WhereandTake/Skip While. It was missingWhile,Do While/Until,Loop While/UntilandCatch ... When, and those visitors didn't add the Boolean conversion. That gave CS0266 (the issue).isOperandOfConditionalBranchis passed throughParenthesizedExpressionin Binder_Expressions.vb). SoIf (a < b) Thenwith nullables gave(bool)(...), which throws InvalidOperationException at runtime when a value is null. This also affected If statements on master, and the issue reports the parenthesizedWhileform.If x Then/While xwithx As Integer?) converted toConversions.ToBoolean(x.Value), which throws when x is Nothing. VB lifts the conversion and treats Nothing as False. This also affected If on master.Widening CTypeto Boolean, used as a condition, converted to(bool)s, which throws when s is Nothing. VB lifts the user-defined conversion too. This also affected If on master. Once loops are treated as conditions, it would otherwise have turned a loop's compile error into a runtime throw.Changes (commits)
ConvertConditionAsync(If/ElseIf/single-line If).AlwaysHasBooleanTypeInCSharpnow walks out of the parentheses around a condition.TypeConversionAnalyzerlooks inside parentheses when it excludes binary/unary nodes fromNullableBool.ConvertLoopContinueConditionAsync, so the top and bottomWhile/Untilclauses share one conversion.AlwaysHasBooleanTypeInCSharpnow coversWhileStatementSyntax,WhileOrUntilClauseSyntaxandCatchFilterClauseSyntax.VisitWhileBlock,ConvertLoopContinueConditionAsyncand the Catch filter useConvertConditionAsync. AnUntilcondition is converted to bool before it's inverted.Conversionsmethod usesConversions.ToBoolean(x.GetValueOrDefault())instead of.Value. The zero default of every type thatConversions.ToBooleanaccepts also converts to False, so this matches VB and evaluates x once. This commit also pins Option Strict OffObjectconditions in loops, which already worked.SkipIntoParens()once in theNullableBoolanalysis.s.HasValue && (bool)s.Value, orGetS() is { } arg1 && (bool)arg1when the expression isn't safely reusable. This is the newVisualBasicNullableExpressionsConverter.ConvertWhenNotNullElseFalse. It can't useGetValueOrDefault, because the operator may convert the default to True. The commit also pins that conversions outside conditions keep.Valueand throw on Nothing, as VB does (Dim b As Boolean = x,If IsTrue(x),If CBool(x)).is { } argN/is not { } argN/is var argNpatterns now parenthesize a non-primary operand (AddParensUnlessPrimary). Before,If(a, b, c)orIf(a, b)as a nullable structure condition gave text that reparsed wrongly (CS0173/CS0165), even though the syntax tree was right. This was a latent bug in the shared helpers, whichInvokeConversionWhenNotNulland the relational/logical operator conversions also use. No existing test output changed.CTypethat returnsBoolean?is excluded from commit 8's lifting, because VB doesn't lift it the same way. Commit 13 replaces the fallback to master's output.IsMadeBoolByNullableOperatorConversionpredicate from theNullableBoolcondition.CreateCastForContext(renamedCreateCastOrConditionCastin commit 21).CTypereturningBoolean?gives(bool?)s.Value == true(or(bool?)ns == truefor a non-nullable operand) instead of(bool)s, which threw when the operator returned Nothing..Valuekeeps VB's throw for a Nothing operand, which C# would otherwise lift to null.InvertConditiontakesnegateOrderingComparisons. An Until condition containing a nullable<,<=,>or>=explicitly isn't flipped (a doc comment gives the reason). Before, a redundant(bool)cast stopped the flip by coincidence. No output changed. Commit 19 replaces the mechanism.(bool)cast where the nullable operator logic already gives bool. Now Until conditions simplify through&&/||:Loop Until Not bgivesb != falseandDo Until b = Truegivesb != true.Not (x < y)(and<=,>,>=) with nullable operands of a totally ordered type (integral, Decimal, Date, Char, enum) flips to the lifted opposite operator. Both are false when an operand is Nothing. The fix doesn't apply to floating point (NaN), Boolean (C# has no ordering operators for bool), structures (user-defined operators), or=/<>(both operands Nothing), and a test pins that.TypeConversionKind.NullableBoolparenthesizes the condition before== true(EqualsTrue()). ABoolean?ternary condition (If If(f, x < y, x > y),Do Until If(f, b, Not b)) gavef ? A : B == true, which reparses asf ? A : (B == true)(CS0173/CS0266). The If form was also broken on master.InvertConditioninverted only a ternary's condition, which swaps its arms rather than negating it.Do Until If(f, a, b)gavewhile (!f ? a : b), and now giveswhile (!(f ? a : b)).CSharpUtil.MayCompareNullAnnotation), andInvertConditionrefuses to flip only those</<=/>/>=nodes. This replaces commit 14'snegateOrderingComparisonsparameter and its rescan of the VB condition. It's precise per node:Do Until x > y AndAlso i < 3gives!(x > y) || i >= 3instead of!(x > y) || !(i < 3).IsOrderingComparisonextension.CreateCastForContexttoCreateCastOrConditionCast, and say why in theNegatedForParentNotAnnotationdoc comment.Decisions
Principle: use the simplest output form we can cheaply create that keeps VB's behaviour, even where the issue proposed a more complex one.
<=stays:While currentDate <= endDategiveswhile (currentDate <= endDate), not the issue'scurrentDate.HasValue && endDate.HasValue && currentDate.Value <= endDate.Value. C#'s lifted<=is false when an operand is null, which matches VB's Nothing→False. This matches the existing If output.!(x < y), neverx >= y(wrong when an operand is null). This is now explicit:InvertConditiondoesn't flip a comparison the nullable converter marked as lifted over a possibly-null operand, and no longer depends on cast/simplifier ordering. Other comparisons in the same condition still flip.DoUntilLessThan/LoopUntilGreaterThaninNullableLoopConditionTests.vbguard it.CTypereturningBoolean?in a condition compares with True:(bool?)s.Value == true, with Until giving!= truethrough the normal inversion. Self-verifying tests check that VB and C# both throw whensis Nothing and both give False when the operator returns Nothing, for bothS?and non-nullableS. The proposed form was(bool?)s == true..Valuewas added because without it C# lifts a nullsto false instead of throwing as VB does.Notin conditions:Notof a nullable ordering comparison on a totally ordered type flips the lifted operator.!(b == false)/!(b == true)onbool?becomeb != false/b != true.=/<>keep the existing form, and a test pins that.Review
Five blind review rounds on commits 1-11; round 5 found only nitpicks. Commits 12-16 implement the decisions above. Round 6 reviewed commits 12-16 and found no bugs in them. It found two bugs in the area (commits 17, 18) and two quality items (commits 19-21); all are fixed. Commits 17-21 have not had a further blind review round. Declined nitpicks:
(c?.S) is { } arg1.AddParensUnlessPrimaryis shared with member-access operands, where wrapping?.is needed ((c?.S).Xisn't the same asc?.S.X).InvokeConversionWhenNotNullin terms ofConvertWhenNotNullElseFalse. The only shared part is the singleHasValue(vbExpr, ref csExpr)call; the two then combine differently (? (T?)conv : nullvs&& conv).TypeConversionAnalyzer.csline 1. It matches the other files in the directory.NullableConditionTests.vbandNullableLoopConditionTests.vb, and a repeatedDo Until spin.Semantics (verified by running VB in the self-verifying tests)
Roslyn
Binder_Statements.vb:BindWhileBlock/BindDoLoopbind the condition withBindBooleanExpression, andUntilis a flag applied after the conversion. SoDo Until <Nothing>is not done and the loop continues. The VB side ofNullableLoopConditionTests.vbpasses with these expectations. In a condition, VB lifts intrinsic conversions and user-defined conversions that returnBoolean, so Nothing is False (NullableConditionOptionStrictOffTests.vb,NullableConditionTests.NullableStructureWithWideningToBooleanConditions). Outside conditions, the conversion throws. A user-defined conversion returningBoolean?isn't lifted that way: a Nothing operand throws, and a Nothing result is False.Output
While currentDate <= endDate(issue repro, also parenthesized)while (currentDate <= endDate)While x = ywhile (x.HasValue && y.HasValue && x.Value == y.Value)While b/While Not bwhile (b == true)/while (b == false)Do Until x < ywhile (!(x < y))Do Until x = ywhile (!x.HasValue || !y.HasValue || x.Value != y.Value)Do Until b/Do Until b = Truewhile (b != true)Loop Until Not bwhile (b != false);Do Until x > y AndAlso i < 3(x, y nullable; i Integer)while (!(x > y) || i >= 3)If If(f, x < y, x > y)/Do Until If(f, b, Not b)(Boolean? ternary)if ((f ? A : B) == true)(A, B: the expanded nullable comparisons) /while ((f ? b : !b) != true)Do Until If(f, a, b)(Boolean)while (!(f ? a : b))(waswhile (!f ? a : b))While Not (x < y)/Do Until Not (x < y)/If Not (x <= y)(Integer?, Decimal?, enum?…)while (x >= y)/while (!(x >= y))/if (x > y)While Not (a < 1.0)(Double?/Single?) /If Not (x = y)while ((a.HasValue ? a.Value < 1.0d : null) == false)/if ((x.HasValue && y.HasValue ? x.Value == y.Value : null) == false)Catch e When x = y/When (b)when (x.HasValue && y.HasValue && x.Value == y.Value)/when (b == true)x As Integer?:If x/Do Until xif (Conversions.ToBoolean(x.GetValueOrDefault()))/while (!Conversions.ToBoolean(x.GetValueOrDefault()))o As Object:While o/Do Until o > 1while (Conversions.ToBoolean(o))/while (!Conversions.ToBoolean(Operators.ConditionalCompareObjectGreater(o, 1, false)))s As S?withWidening CTypeto Boolean:If s/Do Until s/Loop While GetS()if (s.HasValue && (bool)s.Value)/while (!s.HasValue || !(bool)s.Value)/while (GetS() is { } arg1 && (bool)arg1);If If(s, GetS())/Do Until If(s.HasValue, s, GetS())if ((s ?? GetS()) is { } arg2 && (bool)arg2)/while (!((s.HasValue ? s : GetS()) is { } arg3) || !(bool)arg3)Widening CTypeto Boolean?:If s/Do Until s(s As S?) /While ns(ns As S)if ((bool?)s.Value == true)/while ((bool?)s.Value != true)/while ((bool?)ns == true)Non-nullable output is unchanged, e.g.
Do Until i < 1still giveswhile (i >= 1). Changed existing expectations: only the Until outputs above, which are now simplified through&&/||/!=, and theBoolean?operator pin (decision 3).For reviewer attention
IsNonBooleanNullableexcludesBoolean?conditions, because the nullable Boolean logic already gives thembool..Valueon a nullable operand, which differs from the proposed(bool?)s == true(see Decisions).Objectoperands; those keep the existing form.Out of scope (found while investigating; possible separate issues)
Select Case False/Select Case someBoolwithCase <expr>converts tocase object _ when <expr>, which ignores the selector value. WithSelect Case True, aBoolean?case value gives abool?when-clause (CS0266).Boolean?converted toFunc(Of T, Boolean)(e.g.xs.Where(Function(x) x > 2)) is a narrowing conversion that throws on Nothing in VB. It isn't a condition context.If f() = g()wheref()returnsInteger?andg()has side effects converts tog() is var a && f() is {} b ..., which callsg()first. VB callsf()first. Found with a self-verifying test, inForRelationalOperators(binds the RHS first when the LHS is nullable).Do Until d < 1.0(Double) becomeswhile (d >= 1.0d), which is wrong for NaN (VB keeps looping, C# stops).While x AndAlso ywithInteger?operands gives a correct but ~300-character expression.If Not xwithx As Integer?gives CS0023 (!onint?; it should be~x). Master does the same.argNtemporary names come from a counter that doesn't check scope, so a localDim arg1 = 1in the same method plus a nullable-struct condition that needs a temporary gives CS0128.If(If(f, a, b), 0, 3)givesf ? a : b ? 0 : 3, which C# parses asf ? a : (b ? 0 : 3)(CS1503 in an argument). Found while writing commit 18's test.Operator IsTrue/IsFalsearen't supported at all. The operator declarations fail to convert, andIf tgives CS0029.Tests
LoopStatementTests.NullableComparisonInWhileConditionIssue1300Async: the exact repro plus the parenthesized form.NullableLoopConditionsTreatNothingAsFalseAsync: every loop form.OptionStrictOffNonBooleanConditionsTreatNothingAsFalseAsync: If/While/Do Until onInteger?andObject.NullableStructureWithWideningToBooleanConditionsTreatNothingAsFalseAsync: If/While/Do Until/Loop While on a struct with a user-defined operator, including a non-reusable call and coalesce/ternary conditions.StructureWithWideningToNullableBooleanConditionsCompareResultWithTrueAsync: decision 3.NotOfNullableOrderingComparisonFlipsLiftedOperatorAsync/NotOfFloatingPointOrEqualityNullableComparisonIsNotFlippedAsync: decision 4.OptionStrictOffNullableToBooleanOutsideConditionsKeepsValueAsync: the boundary, where.Valueis kept outside conditions.NonNullableUntilConditionsAreInvertedAsync: pins the unchanged simple output.MethodStatementTests.TryCatchWithNullableFilterTreatsNothingAsFalseAsync.BinaryExpressionTests.ParenthesizedNullableConditionsTreatNothingAsFalseAsync.NullableConditionTests.vb:Boolean?ternary conditions in If, Do Until and Loop Until (commit 17).Boolean?CType, for a Nothing result and a Nothing operand (throws).NullableLoopConditionTests.vb:Notof</<=/>/>=with Nothing on each side.=cases.BooleanTests.vb:Do Until If(f, a, b)andDo Until If(f, True, b)(commit 18).NullableConditionOptionStrictOffTests.vb:Integer?/Double?conditions in If/While/Do Until/Loop While.Objectconditions with Nothing, "True", "0" and 2.Dim b As Boolean = x,If IsTrue(x),If CBool(x).== falseform, and its self-verifying test passed before and after, because that output was already correct. Commit 17's tests failed with CS0173/CS0266, commit 18's at runtime (the loop ran when VB's didn't), and commit 19's conversion test on!(i < 3).🤖 Generated with Claude Code
https://claude.ai/code/session_019FEr8FVAmHV9bscZ82CChg