From 941d90a1193efc231e1e51f1b8e42bffc32bdc22 Mon Sep 17 00:00:00 2001 From: kerams Date: Sun, 21 Jun 2026 23:36:40 +0200 Subject: [PATCH 01/10] Support NotNullIfNotNullAttribute --- src/Compiler/AbstractIL/il.fs | 1 + src/Compiler/AbstractIL/il.fsi | 1 + .../Checking/Expressions/CheckExpressions.fs | 68 +++++- src/Compiler/Checking/MethodCalls.fs | 8 + src/Compiler/FSComp.txt | 1 + src/Compiler/Facilities/LanguageFeatures.fs | 3 + src/Compiler/Facilities/LanguageFeatures.fsi | 1 + .../TypedTree/TypedTreeOps.Attributes.fs | 6 + src/Compiler/TypedTree/WellKnownAttribs.fs | 1 + src/Compiler/TypedTree/WellKnownAttribs.fsi | 1 + src/Compiler/xlf/FSComp.txt.cs.xlf | 5 + src/Compiler/xlf/FSComp.txt.de.xlf | 5 + src/Compiler/xlf/FSComp.txt.es.xlf | 5 + src/Compiler/xlf/FSComp.txt.fr.xlf | 5 + src/Compiler/xlf/FSComp.txt.it.xlf | 5 + src/Compiler/xlf/FSComp.txt.ja.xlf | 5 + src/Compiler/xlf/FSComp.txt.ko.xlf | 5 + src/Compiler/xlf/FSComp.txt.pl.xlf | 5 + src/Compiler/xlf/FSComp.txt.pt-BR.xlf | 5 + src/Compiler/xlf/FSComp.txt.ru.xlf | 5 + src/Compiler/xlf/FSComp.txt.tr.xlf | 5 + src/Compiler/xlf/FSComp.txt.zh-Hans.xlf | 5 + src/Compiler/xlf/FSComp.txt.zh-Hant.xlf | 5 + .../FSharp.Compiler.ComponentTests.fsproj | 1 + .../Nullness/NotNullIfNotNullTests.fs | 221 ++++++++++++++++++ 25 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs diff --git a/src/Compiler/AbstractIL/il.fs b/src/Compiler/AbstractIL/il.fs index 629a3d260bb..b0a900b4550 100644 --- a/src/Compiler/AbstractIL/il.fs +++ b/src/Compiler/AbstractIL/il.fs @@ -1257,6 +1257,7 @@ type WellKnownILAttributes = | RequiredMemberAttribute = (1u <<< 22) | NullableContextAttribute = (1u <<< 23) | AttributeUsageAttribute = (1u <<< 24) + | NotNullIfNotNullAttribute = (1u <<< 25) | NotComputed = (1u <<< 31) type internal ILAttributesStoredRepr = diff --git a/src/Compiler/AbstractIL/il.fsi b/src/Compiler/AbstractIL/il.fsi index dc22d5bc803..1f6296bf1be 100644 --- a/src/Compiler/AbstractIL/il.fsi +++ b/src/Compiler/AbstractIL/il.fsi @@ -907,6 +907,7 @@ type WellKnownILAttributes = | RequiredMemberAttribute = (1u <<< 22) | NullableContextAttribute = (1u <<< 23) | AttributeUsageAttribute = (1u <<< 24) + | NotNullIfNotNullAttribute = (1u <<< 25) | NotComputed = (1u <<< 31) /// Represents the efficiency-oriented storage of ILAttributes in another item. diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 070ce6b3e8e..ed9ebc3152f 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -3330,6 +3330,41 @@ let GetMethodArgs arg = unnamedCallerArgs, namedCallerArgs +let NotNullIfNotNullParamNames g (minfo: MethInfo) = + match minfo with + | ILMeth(ilMethInfo = ilminfo) when ilminfo.RawMetadata.Return.CustomAttrsStored.HasWellKnownAttribute (g, WellKnownILAttributes.NotNullIfNotNullAttribute) -> + ilminfo.RawMetadata.Return.CustomAttrs.AsArray() + |> Array.toList + |> List.choose (fun attr -> + if classifyILAttrib attr &&& WellKnownILAttributes.NotNullIfNotNullAttribute <> WellKnownILAttributes.None then + match decodeILAttribData attr with + | [ ILAttribElem.String (Some paramName) ], _ -> Some paramName + | _ -> None + else + None) + | FSMeth(valRef = vref) -> + match vref.ValReprInfo with + | Some (ValReprInfo(result = retInfo)) when ArgReprInfoHasWellKnownAttribute g WellKnownValAttributes.NotNullIfNotNullAttribute retInfo -> + retInfo.Attribs.AsList() + |> List.choose (fun attrib -> + if classifyValAttrib g attrib &&& WellKnownValAttributes.NotNullIfNotNullAttribute <> WellKnownValAttributes.None then + match attrib with + | Attrib(unnamedArgs = [ AttribStringArg paramName ]) -> Some paramName + | _ -> None + else + None) + | _ -> [] + | _ -> [] + +// Resolve the caller argument bound to 'paramName' (named arguments first, then by position) and return the type of its type-checked expression. +let TryGetCallerArgType g (minfo: MethInfo) (callerArgs: CallerArgs<_>) paramName = + match callerArgs.Named |> List.tryPick (List.tryPick (fun (CallerNamedArg(id, arg)) -> if id.idText = paramName then Some (tyOfExpr g arg.Expr) else None)) with + | None -> + match minfo.GetParamNames() |> List.tryPick (List.tryFindIndex (fun nm -> match nm with Some nm -> nm = paramName | _ -> false)) with + | Some idx -> callerArgs.Unnamed |> List.tryPick (List.tryItem idx) |> Option.map (fun arg -> tyOfExpr g arg.Expr) + | None -> None + | x -> x + //------------------------------------------------------------------------- // Helpers dealing with sequence expressions //------------------------------------------------------------------------- @@ -10270,12 +10305,26 @@ and TcMethodApplication_UniqueOverloadInference let arityFilteredCandidates = candidateMethsAndProps - let makeOneCalledMeth (minfo, pinfoOpt, usesParamArrayConversion) = + let makeOneCalledMeth (minfo: MethInfo, pinfoOpt, usesParamArrayConversion) = let minst = FreshenMethInfo mItem minfo let callerTyArgs = match tyArgsOpt with | Some tyargs -> minfo.AdjustUserTypeInstForFSharpStyleIndexedExtensionMembers tyargs | None -> minst + + // If the return value is [], give the return a fresh nullness inference variable here so that + // unique-overload inference does not prematurely commit the result to the declared (nullable) nullness. The real + // nullness is resolved post argument type-checking (see below), once the argument types are known. + let minfo = + if not minfo.IsConstructor && g.checkNullness && g.langVersion.SupportsFeature LanguageFeature.NotNullIfNotNull then + match NotNullIfNotNullParamNames g minfo with + | [ _ ] -> + let retTy = minfo.GetFSharpReturnType(cenv.amap, mMethExpr, callerTyArgs) + MethInfoWithModifiedReturnType(minfo, replaceNullnessOfTy (NewNullnessVar()) retTy) + | _ -> minfo + else + minfo + CalledMeth(cenv.infoReader, Some(env.NameEnv), isCheckingAttributeCall, FreshenMethInfo, mMethExpr, ad, minfo, minst, callerTyArgs, pinfoOpt, callerObjArgTys, callerArgs, usesParamArrayConversion, true, objTyOpt, staticTyOpt) let preArgumentTypeCheckingCalledMethGroup = @@ -10505,6 +10554,23 @@ and TcMethodApplication match tyArgsOpt with | Some tyargs -> minfo.AdjustUserTypeInstForFSharpStyleIndexedExtensionMembers tyargs | None -> minst + + let minfo = + if not minfo.IsConstructor && g.checkNullness && g.langVersion.SupportsFeature LanguageFeature.NotNullIfNotNull then + // 'minfo' may already carry a placeholder return nullness from unique-overload inference (phase 1); + // strip it back to the base method before applying the real (argument-derived) nullness. + let baseMinfo = match minfo with MethInfoWithModifiedReturnType(inner, _) -> inner | _ -> minfo + match NotNullIfNotNullParamNames g baseMinfo with + | [ paramName ] -> + match TryGetCallerArgType g baseMinfo callerArgs paramName with + | Some callerArgTy -> + let retTy = baseMinfo.GetFSharpReturnType(cenv.amap, mMethExpr, callerTyArgs) + MethInfoWithModifiedReturnType(baseMinfo, replaceNullnessOfTy (nullnessOfTy g callerArgTy) retTy) + | None -> baseMinfo + | _ -> baseMinfo + else + minfo + CalledMeth(cenv.infoReader, Some(env.NameEnv), isCheckingAttributeCall, FreshenMethInfo, mMethExpr, ad, minfo, minst, callerTyArgs, pinfoOpt, callerObjArgTys, callerArgs, usesParamArrayConversion, true, objTyOpt, staticTyOpt)) // Commit unassociated constraints prior to member overload resolution where there is ambiguity diff --git a/src/Compiler/Checking/MethodCalls.fs b/src/Compiler/Checking/MethodCalls.fs index a9c394ee4e9..9fdf22a3a1a 100644 --- a/src/Compiler/Checking/MethodCalls.fs +++ b/src/Compiler/Checking/MethodCalls.fs @@ -1250,6 +1250,14 @@ let rec BuildMethodCall tcVal g amap isMutable m isProp minfo valUseFlags minst let expr = mkCoerceExpr (expr, retTy, m, exprTy) expr, retTy + | MethInfoWithModifiedReturnType((FSMeth(_, _, vref, _) as innerMeth), retTy) -> + // Build the inner call directly, without re-invoking TakeObjAddrForMethodCall. + let vExpr, vExprTy = tcVal vref valUseFlags (innerMeth.DeclaringTypeInst @ minst) m + let expr, exprTy = BuildFSharpMethodApp g m vref vExpr vExprTy allArgs + + let expr = mkCoerceExpr (expr, retTy, m, exprTy) + expr, retTy + | MethInfoWithModifiedReturnType _ -> failwith "MethInfoWithModifiedReturnType: unexpected inner method kind" diff --git a/src/Compiler/FSComp.txt b/src/Compiler/FSComp.txt index dc27f32bae9..c35c5ca3dd6 100644 --- a/src/Compiler/FSComp.txt +++ b/src/Compiler/FSComp.txt @@ -1821,3 +1821,4 @@ featurePreprocessorElif,"#elif preprocessor directive" 3888,implAttributeMissingFromSignature,"The attribute '%s' is present on '%s' in the implementation but not in the signature, which takes precedence for tooling and consumers. Add the attribute to the signature, to ensure the attribute is not ignored by the compiler." featureExceptionFieldSerializationSupport,"emit GetObjectData and field-restoring deserialization constructor for exception types" featureErrorOnMissingSignatureAttribute,"error (rather than warning) when an enforced compiler-semantic attribute is present in the .fs but missing from the .fsi" +featureNotNullIfNotNull,"honor the 'NotNullIfNotNull' attribute on a method's return value" diff --git a/src/Compiler/Facilities/LanguageFeatures.fs b/src/Compiler/Facilities/LanguageFeatures.fs index da4ef690311..3312cb9a146 100644 --- a/src/Compiler/Facilities/LanguageFeatures.fs +++ b/src/Compiler/Facilities/LanguageFeatures.fs @@ -110,6 +110,7 @@ type LanguageFeature = | PreprocessorElif | ExceptionFieldSerializationSupport | ErrorOnMissingSignatureAttribute + | NotNullIfNotNull /// LanguageVersion management type LanguageVersion(versionText, ?disabledFeaturesArray: LanguageFeature array) = @@ -254,6 +255,7 @@ type LanguageVersion(versionText, ?disabledFeaturesArray: LanguageFeature array) LanguageFeature.WarnWhenFunctionValueUsedAsInterpolatedStringArg, languageVersion110 LanguageFeature.PreprocessorElif, languageVersion110 LanguageFeature.ExceptionFieldSerializationSupport, languageVersion110 + LanguageFeature.NotNullIfNotNull, languageVersion110 // Difference between languageVersion110 and preview - 11.0 gets turned on automatically by picking a preview .NET 11 SDK // previewVersion is only when "preview" is specified explicitly in project files and users also need a preview SDK @@ -459,6 +461,7 @@ type LanguageVersion(versionText, ?disabledFeaturesArray: LanguageFeature array) | LanguageFeature.PreprocessorElif -> FSComp.SR.featurePreprocessorElif () | LanguageFeature.ExceptionFieldSerializationSupport -> FSComp.SR.featureExceptionFieldSerializationSupport () | LanguageFeature.ErrorOnMissingSignatureAttribute -> FSComp.SR.featureErrorOnMissingSignatureAttribute () + | LanguageFeature.NotNullIfNotNull -> FSComp.SR.featureNotNullIfNotNull () /// Get a version string associated with the given feature. static member GetFeatureVersionString feature = diff --git a/src/Compiler/Facilities/LanguageFeatures.fsi b/src/Compiler/Facilities/LanguageFeatures.fsi index 5ba352191af..1be8275de35 100644 --- a/src/Compiler/Facilities/LanguageFeatures.fsi +++ b/src/Compiler/Facilities/LanguageFeatures.fsi @@ -101,6 +101,7 @@ type LanguageFeature = | PreprocessorElif | ExceptionFieldSerializationSupport | ErrorOnMissingSignatureAttribute + | NotNullIfNotNull /// LanguageVersion management type LanguageVersion = diff --git a/src/Compiler/TypedTree/TypedTreeOps.Attributes.fs b/src/Compiler/TypedTree/TypedTreeOps.Attributes.fs index d011c2d231f..21e3bda323c 100644 --- a/src/Compiler/TypedTree/TypedTreeOps.Attributes.fs +++ b/src/Compiler/TypedTree/TypedTreeOps.Attributes.fs @@ -183,6 +183,7 @@ module internal ILExtensions = WellKnownILAttributes.SetsRequiredMembersAttribute | "System.ObsoleteAttribute" -> WellKnownILAttributes.ObsoleteAttribute | "System.Diagnostics.CodeAnalysis.ExperimentalAttribute" -> WellKnownILAttributes.ExperimentalAttribute + | "System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute" -> WellKnownILAttributes.NotNullIfNotNullAttribute | "System.AttributeUsageAttribute" -> WellKnownILAttributes.AttributeUsageAttribute | _ -> WellKnownILAttributes.None @@ -592,6 +593,11 @@ module internal AttributeHelpers = | "ConditionalAttribute" -> WellKnownValAttributes.ConditionalAttribute | _ -> WellKnownValAttributes.None + | [| "System"; "Diagnostics"; "CodeAnalysis"; name |] -> + match name with + | "NotNullIfNotNullAttribute" -> WellKnownValAttributes.NotNullIfNotNullAttribute + | _ -> WellKnownValAttributes.None + | [| "System"; name |] -> match name with | "ThreadStaticAttribute" -> WellKnownValAttributes.ThreadStaticAttribute diff --git a/src/Compiler/TypedTree/WellKnownAttribs.fs b/src/Compiler/TypedTree/WellKnownAttribs.fs index fac3508a56e..748f525b89c 100644 --- a/src/Compiler/TypedTree/WellKnownAttribs.fs +++ b/src/Compiler/TypedTree/WellKnownAttribs.fs @@ -116,6 +116,7 @@ type internal WellKnownValAttributes = | NoEagerConstraintApplicationAttribute = (1uL <<< 38) | ValueAsStaticPropertyAttribute = (1uL <<< 39) | TailCallAttribute = (1uL <<< 40) + | NotNullIfNotNullAttribute = (1uL <<< 41) | NotComputed = (1uL <<< 63) module internal Flags = diff --git a/src/Compiler/TypedTree/WellKnownAttribs.fsi b/src/Compiler/TypedTree/WellKnownAttribs.fsi index da7a7b67f33..4939f94aaa8 100644 --- a/src/Compiler/TypedTree/WellKnownAttribs.fsi +++ b/src/Compiler/TypedTree/WellKnownAttribs.fsi @@ -114,6 +114,7 @@ type internal WellKnownValAttributes = | NoEagerConstraintApplicationAttribute = (1uL <<< 38) | ValueAsStaticPropertyAttribute = (1uL <<< 39) | TailCallAttribute = (1uL <<< 40) + | NotNullIfNotNullAttribute = (1uL <<< 41) | NotComputed = (1uL <<< 63) module internal Flags = diff --git a/src/Compiler/xlf/FSComp.txt.cs.xlf b/src/Compiler/xlf/FSComp.txt.cs.xlf index 84e5e3cc50c..32eb6598139 100644 --- a/src/Compiler/xlf/FSComp.txt.cs.xlf +++ b/src/Compiler/xlf/FSComp.txt.cs.xlf @@ -527,6 +527,11 @@ neproměnné vzory napravo od vzorů typu „jako“ + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop nepovinný zprostředkovatel komunikace s možnou hodnotou null diff --git a/src/Compiler/xlf/FSComp.txt.de.xlf b/src/Compiler/xlf/FSComp.txt.de.xlf index d6edecb4def..35dac025ce1 100644 --- a/src/Compiler/xlf/FSComp.txt.de.xlf +++ b/src/Compiler/xlf/FSComp.txt.de.xlf @@ -527,6 +527,11 @@ Nicht-Variablenmuster rechts neben as-Mustern + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop Interop, NULL-Werte zulassend, optional diff --git a/src/Compiler/xlf/FSComp.txt.es.xlf b/src/Compiler/xlf/FSComp.txt.es.xlf index 46573e801f1..281f8dd69bc 100644 --- a/src/Compiler/xlf/FSComp.txt.es.xlf +++ b/src/Compiler/xlf/FSComp.txt.es.xlf @@ -527,6 +527,11 @@ patrones no variables a la derecha de los patrones "as" + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop interoperabilidad opcional que admite valores NULL diff --git a/src/Compiler/xlf/FSComp.txt.fr.xlf b/src/Compiler/xlf/FSComp.txt.fr.xlf index 1acfc469b5b..0cd33a35441 100644 --- a/src/Compiler/xlf/FSComp.txt.fr.xlf +++ b/src/Compiler/xlf/FSComp.txt.fr.xlf @@ -527,6 +527,11 @@ modèles non variables à droite de modèles « as » + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop interopérabilité facultative pouvant accepter une valeur null diff --git a/src/Compiler/xlf/FSComp.txt.it.xlf b/src/Compiler/xlf/FSComp.txt.it.xlf index deaca46fad0..f27b686b2e0 100644 --- a/src/Compiler/xlf/FSComp.txt.it.xlf +++ b/src/Compiler/xlf/FSComp.txt.it.xlf @@ -527,6 +527,11 @@ modelli non variabili a destra dei modelli 'as' + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop Interop facoltativo nullable diff --git a/src/Compiler/xlf/FSComp.txt.ja.xlf b/src/Compiler/xlf/FSComp.txt.ja.xlf index 10029fab134..7977bede1f8 100644 --- a/src/Compiler/xlf/FSComp.txt.ja.xlf +++ b/src/Compiler/xlf/FSComp.txt.ja.xlf @@ -527,6 +527,11 @@ 'as' パターンの右側の非変数パターン + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop Null 許容のオプションの相互運用 diff --git a/src/Compiler/xlf/FSComp.txt.ko.xlf b/src/Compiler/xlf/FSComp.txt.ko.xlf index 1d7a19af957..cfe29218dae 100644 --- a/src/Compiler/xlf/FSComp.txt.ko.xlf +++ b/src/Compiler/xlf/FSComp.txt.ko.xlf @@ -527,6 +527,11 @@ 'as' 패턴의 오른쪽에 있는 변수가 아닌 패턴 + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop nullable 선택적 interop diff --git a/src/Compiler/xlf/FSComp.txt.pl.xlf b/src/Compiler/xlf/FSComp.txt.pl.xlf index 170e6e37718..e28c23a1c59 100644 --- a/src/Compiler/xlf/FSComp.txt.pl.xlf +++ b/src/Compiler/xlf/FSComp.txt.pl.xlf @@ -527,6 +527,11 @@ stałe wzorce po prawej stronie wzorców typu „as” + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop opcjonalna międzyoperacyjność dopuszczająca wartość null diff --git a/src/Compiler/xlf/FSComp.txt.pt-BR.xlf b/src/Compiler/xlf/FSComp.txt.pt-BR.xlf index 87ef255a7e8..67cf82ad478 100644 --- a/src/Compiler/xlf/FSComp.txt.pt-BR.xlf +++ b/src/Compiler/xlf/FSComp.txt.pt-BR.xlf @@ -527,6 +527,11 @@ padrões não-variáveis à direita dos padrões 'as'. + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop interoperabilidade opcional anulável diff --git a/src/Compiler/xlf/FSComp.txt.ru.xlf b/src/Compiler/xlf/FSComp.txt.ru.xlf index 57f29e98517..3bd062a0e57 100644 --- a/src/Compiler/xlf/FSComp.txt.ru.xlf +++ b/src/Compiler/xlf/FSComp.txt.ru.xlf @@ -527,6 +527,11 @@ шаблоны без переменных справа от шаблонов "as" + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop необязательное взаимодействие, допускающее значение NULL diff --git a/src/Compiler/xlf/FSComp.txt.tr.xlf b/src/Compiler/xlf/FSComp.txt.tr.xlf index 08753e47a27..cee8392e86f 100644 --- a/src/Compiler/xlf/FSComp.txt.tr.xlf +++ b/src/Compiler/xlf/FSComp.txt.tr.xlf @@ -527,6 +527,11 @@ 'as' desenlerinin sağındaki değişken olmayan desenler + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop null atanabilir isteğe bağlı birlikte çalışma diff --git a/src/Compiler/xlf/FSComp.txt.zh-Hans.xlf b/src/Compiler/xlf/FSComp.txt.zh-Hans.xlf index e117ab0ec5d..f14491cfce7 100644 --- a/src/Compiler/xlf/FSComp.txt.zh-Hans.xlf +++ b/src/Compiler/xlf/FSComp.txt.zh-Hans.xlf @@ -527,6 +527,11 @@ "as" 模式右侧的非变量模式 + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop 可以为 null 的可选互操作 diff --git a/src/Compiler/xlf/FSComp.txt.zh-Hant.xlf b/src/Compiler/xlf/FSComp.txt.zh-Hant.xlf index 31c5596a098..dcc74bd974c 100644 --- a/src/Compiler/xlf/FSComp.txt.zh-Hant.xlf +++ b/src/Compiler/xlf/FSComp.txt.zh-Hant.xlf @@ -527,6 +527,11 @@ 'as' 模式右邊的非變數模式 + + honor the 'NotNullIfNotNull' attribute on a method's return value + honor the 'NotNullIfNotNull' attribute on a method's return value + + nullable optional interop 可為 Null 的選擇性 Interop diff --git a/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj b/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj index 3fc031a525f..f2c74fefd3d 100644 --- a/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj +++ b/tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj @@ -372,6 +372,7 @@ + diff --git a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs new file mode 100644 index 00000000000..d1bfbd437e2 --- /dev/null +++ b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs @@ -0,0 +1,221 @@ +module Language.NotNullIfNotNull + +open FSharp.Test +open FSharp.Test.Compiler + + +let typeCheckWithStrictNullness cu = + cu + |> withCheckNulls + |> withLangVersionPreview + |> withWarnOn 3261 + |> withOptions ["--warnaserror+"] + |> typecheck + +let csNotNullLib = + CSharp """ +#nullable enable +using System.Diagnostics.CodeAnalysis; +namespace NotNullLib { + public class C { + [return: NotNullIfNotNull(nameof(input))] + public static string? Echo(string? input) => input; + + // The result is non-null when the SECOND parameter is non-null. + [return: NotNullIfNotNull(nameof(second))] + public static string? DependsOnSecond(string? first, string? second) => second; + } +}""" |> withName "csNotNullLib" + +let private nullableExpected = "was expected but this expression is nullable" + +[] +let ``BCL Path.GetExtension - non-null input yields non-null result`` () = + FSharp """module MyLibrary +open System.IO + +let nonNull : string = "file.txt" +let ext : string = Path.GetExtension(nonNull) +""" + |> asLibrary + |> typeCheckWithStrictNullness + |> shouldSucceed + +[] +let ``BCL Path.GetExtension - nullable input yields nullable result`` () = + FSharp """module MyLibrary +open System.IO + +let maybeNull : string | null = "file.txt" +let ext : string = Path.GetExtension(maybeNull) +""" + |> asLibrary + |> typeCheckWithStrictNullness + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +[] +let ``Multiple NotNullIfNotNull attributes are not supported - Delegate.Combine stays nullable`` () = + // Delegate.Combine carries two [return: NotNullIfNotNull] attributes. Making the result depend on more than one + // parameter (logical OR) is not supported, so the declared nullable return type is kept even though an argument + // is non-null. + FSharp """module MyLibrary +open System + +let d1 : Delegate = Action(fun () -> ()) :> Delegate +let dMaybe : Delegate | null = null + +let combined : Delegate = Delegate.Combine(d1, dMaybe) +""" + |> asLibrary + |> typeCheckWithStrictNullness + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +[] +let ``Csharp NotNullIfNotNull - non-null propagation works positionally`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "x" +let maybeNull : string | null = "y" + +// Single referenced parameter, passed positionally +let r1 : string = C.Echo(notNull) + +// Referenced parameter is the second one; nullable first, non-null second -> non-null. +// Arguments are positional (no named arguments), so this proves the parameter is identified by name. +let r2 : string = C.DependsOnSecond(maybeNull, notNull) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> typeCheckWithStrictNullness + |> shouldSucceed + +[] +let ``Csharp NotNullIfNotNull - non-null propagation works with named arguments`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "x" +let maybeNull : string | null = "y" + +let r : string = C.DependsOnSecond(second = notNull, first = maybeNull) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> typeCheckWithStrictNullness + |> shouldSucceed + +[] +let ``Csharp NotNullIfNotNull - Echo stays nullable for nullable input`` () = + FSharp """module MyLibrary +open NotNullLib + +let maybeNull : string | null = "y" +let r : string = C.Echo(maybeNull) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> typeCheckWithStrictNullness + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +[] +let ``Csharp NotNullIfNotNull - depends on second parameter, not the first`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "x" +let maybeNull : string | null = "y" + +// Non-null first but nullable referenced (second) parameter -> result stays nullable +let r : string = C.DependsOnSecond(notNull, maybeNull) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> typeCheckWithStrictNullness + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +[] +let ``Local F# method with NotNullIfNotNull - non-null propagation`` () = + FSharp """module MyLibrary +open System.Diagnostics.CodeAnalysis + +type C = + [] + static member Echo(x: string | null) : string | null = x + +let notNull : string = "a" +let ok : string = C.Echo(notNull) +""" + |> asLibrary + |> typeCheckWithStrictNullness + |> shouldSucceed + +[] +let ``Local F# method with NotNullIfNotNull - stays nullable for nullable input`` () = + FSharp """module MyLibrary +open System.Diagnostics.CodeAnalysis + +type C = + [] + static member Echo(x: string | null) : string | null = x + +let maybeNull : string | null = "a" +let bad : string = C.Echo(maybeNull) +""" + |> asLibrary + |> typeCheckWithStrictNullness + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +[] +let ``Referenced F# method with NotNullIfNotNull - non-null propagation`` () = + let fsharpLib = + FSharp """module NotNullFSharpLib +open System.Diagnostics.CodeAnalysis + +type C = + [] + static member Echo(x: string | null) : string | null = x +""" + |> withCheckNulls + |> withName "NotNullFSharpLib" + + FSharp """module MyLibrary +open NotNullFSharpLib + +let notNull : string = "a" +let ok : string = C.Echo(notNull) +""" + |> asLibrary + |> withReferences [fsharpLib] + |> typeCheckWithStrictNullness + |> shouldSucceed + +[] +let ``Referenced F# method with NotNullIfNotNull - stays nullable for nullable input`` () = + let fsharpLib = + FSharp """module NotNullFSharpLib +open System.Diagnostics.CodeAnalysis + +type C = + [] + static member Echo(x: string | null) : string | null = x +""" + |> withCheckNulls + |> withName "NotNullFSharpLib" + + FSharp """module MyLibrary +open NotNullFSharpLib + +let maybeNull : string | null = "a" +let bad : string = C.Echo(maybeNull) +""" + |> asLibrary + |> withReferences [fsharpLib] + |> typeCheckWithStrictNullness + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected From 8061f9620ca229c4f6afe6f2ac567059b2800998 Mon Sep 17 00:00:00 2001 From: kerams Date: Mon, 22 Jun 2026 00:12:11 +0200 Subject: [PATCH 02/10] Stuff --- docs/release-notes/.FSharp.Compiler.Service/11.0.100.md | 1 + docs/release-notes/.Language/preview.md | 1 + src/Compiler/DependencyManager/AssemblyResolveHandler.fs | 4 ++-- src/Compiler/Utilities/range.fs | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index dd63aa988bd..8a54e13855f 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -96,6 +96,7 @@ * Debug: rework for expressions stepping ([PR #19894](https://github.com/dotnet/fsharp/pull/19894)) * Debug: rework conditional erasure, fix stepping over literals ([PR #19897](https://github.com/dotnet/fsharp/pull/19897)) * Debug: fix if and match condition sequence points ([PR #19932](https://github.com/dotnet/fsharp/pull/19932)) +* Support common types of `NotNullIfNotNullAttribute` usage. If a method parameter is marked with `NotNullIfNotNullAttribute`, the compiler will now honor this attribute and mark the return type as non-null. ([PR #19977](https://github.com/dotnet/fsharp/pull/19977)) ### Changed diff --git a/docs/release-notes/.Language/preview.md b/docs/release-notes/.Language/preview.md index 90c1aa2faa6..cb9d639c163 100644 --- a/docs/release-notes/.Language/preview.md +++ b/docs/release-notes/.Language/preview.md @@ -3,6 +3,7 @@ * Warn (FS3884) when a function or delegate value is used as an interpolated string argument, since it will be formatted via `ToString` rather than being applied. ([PR #19289](https://github.com/dotnet/fsharp/pull/19289)) * Added `MethodOverloadsCache` language feature (preview) that caches overload resolution results for repeated method calls, significantly improving compilation performance. ([PR #19072](https://github.com/dotnet/fsharp/pull/19072)) * Added `ErrorOnMissingSignatureAttribute` preview language feature: makes FS3888 (compiler-semantic attribute on the `.fs` but not on the `.fsi`) an error instead of a warning. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880)) +* Support common types of `NotNullIfNotNullAttribute` usage. If a method parameter is marked with `NotNullIfNotNullAttribute`, the compiler will now honor this attribute and mark the return type as non-null. ([PR #19977](https://github.com/dotnet/fsharp/pull/19977)) ### Fixed diff --git a/src/Compiler/DependencyManager/AssemblyResolveHandler.fs b/src/Compiler/DependencyManager/AssemblyResolveHandler.fs index 6daf749f87f..d59b65d835e 100644 --- a/src/Compiler/DependencyManager/AssemblyResolveHandler.fs +++ b/src/Compiler/DependencyManager/AssemblyResolveHandler.fs @@ -54,7 +54,7 @@ type AssemblyResolveHandlerCoreclr(assemblyProbingPaths: AssemblyResolutionProbe let assemblyPathOpt = assemblyPaths - |> Seq.tryFind (fun path -> Path.GetFileNameWithoutExtension(path) = simpleName) + |> Seq.tryFind (fun path -> String.Equals(Path.GetFileNameWithoutExtension(path), simpleName)) match assemblyPathOpt with | Some path -> loadAssembly path @@ -84,7 +84,7 @@ type AssemblyResolveHandlerDeskTop(assemblyProbingPaths: AssemblyResolutionProbe let assemblyPathOpt = assemblyPaths - |> Seq.tryFind (fun path -> Path.GetFileNameWithoutExtension(path) = simpleName) + |> Seq.tryFind (fun path -> String.Equals(Path.GetFileNameWithoutExtension(path), simpleName)) match assemblyPathOpt with | Some path -> Assembly.LoadFrom path diff --git a/src/Compiler/Utilities/range.fs b/src/Compiler/Utilities/range.fs index 3a22199c32f..2a05fa74c75 100755 --- a/src/Compiler/Utilities/range.fs +++ b/src/Compiler/Utilities/range.fs @@ -334,7 +334,7 @@ type Range(code1: int64, code2: int64) = member m.FileName = fileOfFileIndex m.FileIndex member internal m.ShortFileName = - Path.GetFileName(fileOfFileIndex m.FileIndex) |> nonNull + Path.GetFileName(fileOfFileIndex m.FileIndex) |> Unchecked.nonNull member m.ApplyLineDirectives() = match LineDirectives.store.TryFind m.FileIndex with From 3acded4fd04252b0b883fabf59d8f22c5af8a0c5 Mon Sep 17 00:00:00 2001 From: kerams Date: Mon, 22 Jun 2026 01:00:45 +0200 Subject: [PATCH 03/10] Stuff --- .../Language/Nullness/NotNullIfNotNullTests.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs index d1bfbd437e2..343f4e20363 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs @@ -18,11 +18,11 @@ let csNotNullLib = using System.Diagnostics.CodeAnalysis; namespace NotNullLib { public class C { - [return: NotNullIfNotNull(nameof(input))] + [return: NotNullIfNotNull("input")] public static string? Echo(string? input) => input; // The result is non-null when the SECOND parameter is non-null. - [return: NotNullIfNotNull(nameof(second))] + [return: NotNullIfNotNull("second")] public static string? DependsOnSecond(string? first, string? second) => second; } }""" |> withName "csNotNullLib" From 8c7aceab7ba171b6b0fcf55742346a8d35731c47 Mon Sep 17 00:00:00 2001 From: kerams Date: Mon, 22 Jun 2026 07:55:07 +0200 Subject: [PATCH 04/10] Stuff --- src/Compiler/Checking/NicePrint.fs | 2 +- .../Nullness/NotNullIfNotNullTests.fs | 27 ++++++++++++------- ...iler.Service.SurfaceArea.netstandard20.bsl | 1 + 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/Compiler/Checking/NicePrint.fs b/src/Compiler/Checking/NicePrint.fs index de8daffa7c5..6bbf4e09644 100644 --- a/src/Compiler/Checking/NicePrint.fs +++ b/src/Compiler/Checking/NicePrint.fs @@ -1723,7 +1723,7 @@ module InfoMemberPrinting = let layout,paramLayouts = match denv.showCsharpCodeAnalysisAttributes, minfo with - | true, ILMeth(_g,mi,_e) -> + | true, (ILMeth(_, mi, _) | MethInfoWithModifiedReturnType(ILMeth(_, mi, _), _)) -> let methodLayout = // Render Method attributes and [return:..] attributes on separate lines above (@@) the method definition PrintTypes.layoutCsharpCodeAnalysisIlAttributes denv (minfo.GetCustomAttrs()) (squareAngleL >> (@@)) layout diff --git a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs index 343f4e20363..e2ec34a7b5a 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs @@ -3,13 +3,16 @@ module Language.NotNullIfNotNull open FSharp.Test open FSharp.Test.Compiler - -let typeCheckWithStrictNullness cu = +let withStrictNullness cu = cu - |> withCheckNulls |> withLangVersionPreview + |> withCheckNulls |> withWarnOn 3261 |> withOptions ["--warnaserror+"] + +let typeCheckWithStrictNullness cu = + cu + |> withStrictNullness |> typecheck let csNotNullLib = @@ -89,7 +92,8 @@ let r2 : string = C.DependsOnSecond(maybeNull, notNull) """ |> asLibrary |> withReferences [csNotNullLib] - |> typeCheckWithStrictNullness + |> withStrictNullness + |> compile |> shouldSucceed [] @@ -104,7 +108,8 @@ let r : string = C.DependsOnSecond(second = notNull, first = maybeNull) """ |> asLibrary |> withReferences [csNotNullLib] - |> typeCheckWithStrictNullness + |> withStrictNullness + |> compile |> shouldSucceed [] @@ -117,7 +122,8 @@ let r : string = C.Echo(maybeNull) """ |> asLibrary |> withReferences [csNotNullLib] - |> typeCheckWithStrictNullness + |> withStrictNullness + |> compile |> shouldFail |> withDiagnosticMessageMatches nullableExpected @@ -134,7 +140,8 @@ let r : string = C.DependsOnSecond(notNull, maybeNull) """ |> asLibrary |> withReferences [csNotNullLib] - |> typeCheckWithStrictNullness + |> withStrictNullness + |> compile |> shouldFail |> withDiagnosticMessageMatches nullableExpected @@ -192,7 +199,8 @@ let ok : string = C.Echo(notNull) """ |> asLibrary |> withReferences [fsharpLib] - |> typeCheckWithStrictNullness + |> withStrictNullness + |> compile |> shouldSucceed [] @@ -216,6 +224,7 @@ let bad : string = C.Echo(maybeNull) """ |> asLibrary |> withReferences [fsharpLib] - |> typeCheckWithStrictNullness + |> withStrictNullness + |> compile |> shouldFail |> withDiagnosticMessageMatches nullableExpected diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl index 4034ba8064d..8067be778fc 100644 --- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl +++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl @@ -1833,6 +1833,7 @@ FSharp.Compiler.AbstractIL.IL+WellKnownILAttributes: WellKnownILAttributes IsUnm FSharp.Compiler.AbstractIL.IL+WellKnownILAttributes: WellKnownILAttributes NoEagerConstraintApplicationAttribute FSharp.Compiler.AbstractIL.IL+WellKnownILAttributes: WellKnownILAttributes None FSharp.Compiler.AbstractIL.IL+WellKnownILAttributes: WellKnownILAttributes NotComputed +FSharp.Compiler.AbstractIL.IL+WellKnownILAttributes: WellKnownILAttributes NotNullIfNotNullAttribute FSharp.Compiler.AbstractIL.IL+WellKnownILAttributes: WellKnownILAttributes NullableAttribute FSharp.Compiler.AbstractIL.IL+WellKnownILAttributes: WellKnownILAttributes NullableContextAttribute FSharp.Compiler.AbstractIL.IL+WellKnownILAttributes: WellKnownILAttributes ObsoleteAttribute From 6daa50ae57688ab771d572bcda7b967323a372e3 Mon Sep 17 00:00:00 2001 From: kerams Date: Mon, 22 Jun 2026 09:15:59 +0200 Subject: [PATCH 05/10] Correctness, refactor --- .../Checking/Expressions/CheckExpressions.fs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index ed9ebc3152f..675d29a1e27 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -3356,14 +3356,19 @@ let NotNullIfNotNullParamNames g (minfo: MethInfo) = | _ -> [] | _ -> [] -// Resolve the caller argument bound to 'paramName' (named arguments first, then by position) and return the type of its type-checked expression. +// Resolve the caller argument bound to 'paramName' and return the type of its type-checked expression. let TryGetCallerArgType g (minfo: MethInfo) (callerArgs: CallerArgs<_>) paramName = - match callerArgs.Named |> List.tryPick (List.tryPick (fun (CallerNamedArg(id, arg)) -> if id.idText = paramName then Some (tyOfExpr g arg.Expr) else None)) with - | None -> - match minfo.GetParamNames() |> List.tryPick (List.tryFindIndex (fun nm -> match nm with Some nm -> nm = paramName | _ -> false)) with - | Some idx -> callerArgs.Unnamed |> List.tryPick (List.tryItem idx) |> Option.map (fun arg -> tyOfExpr g arg.Expr) - | None -> None - | x -> x + // First try to find a named argument with the given name + callerArgs.Named + |> List.tryPick (List.tryPick (fun (CallerNamedArg(id, arg)) -> if id.idText = paramName then Some arg else None)) + |> Option.orElseWith (fun () -> + // If there is no matching named argument, find the argument in the same position as the parameter with the given name + minfo.GetParamNames() + |> Seq.concat + |> Seq.tryFindIndex (fun nm -> match nm with Some nm -> nm = paramName | _ -> false) + |> Option.bind (fun idx -> Seq.concat callerArgs.Unnamed |> Seq.tryItem idx) + ) + |> Option.map (fun arg -> tyOfExpr g arg.Expr) //------------------------------------------------------------------------- // Helpers dealing with sequence expressions From b65a49bcf7dc84859d6a30889519eaca6e2c3894 Mon Sep 17 00:00:00 2001 From: kerams Date: Mon, 22 Jun 2026 14:38:20 +0200 Subject: [PATCH 06/10] Fix, add tests --- .../Checking/Expressions/CheckExpressions.fs | 6 ++- .../Nullness/NotNullIfNotNullTests.fs | 51 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 675d29a1e27..a0c84439716 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -10570,7 +10570,11 @@ and TcMethodApplication match TryGetCallerArgType g baseMinfo callerArgs paramName with | Some callerArgTy -> let retTy = baseMinfo.GetFSharpReturnType(cenv.amap, mMethExpr, callerTyArgs) - MethInfoWithModifiedReturnType(baseMinfo, replaceNullnessOfTy (nullnessOfTy g callerArgTy) retTy) + let argNullness = + match GetTyparTyIfSupportsNull g callerArgTy with + | ValueSome _ -> g.knownWithNull + | ValueNone -> nullnessOfTy g callerArgTy + MethInfoWithModifiedReturnType(baseMinfo, replaceNullnessOfTy argNullness retTy) | None -> baseMinfo | _ -> baseMinfo else diff --git a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs index e2ec34a7b5a..36764471f1a 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs @@ -145,6 +145,21 @@ let r : string = C.DependsOnSecond(notNull, maybeNull) |> shouldFail |> withDiagnosticMessageMatches nullableExpected +[] +let ``Csharp NotNullIfNotNull - unannotated parameter with non-null return annotation fails`` () = + FSharp """module MyLibrary +open NotNullLib + +let f x : string = C.Echo(x) +let _ : string = f null +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldFail + |> withDiagnosticMessageMatches "Nullness warning: The type 'string' does not support 'null'." + [] let ``Local F# method with NotNullIfNotNull - non-null propagation`` () = FSharp """module MyLibrary @@ -228,3 +243,39 @@ let bad : string = C.Echo(maybeNull) |> compile |> shouldFail |> withDiagnosticMessageMatches nullableExpected + +[] +let ``BCL Path.GetExtension - null literal input yields nullable result`` () = + FSharp """module MyLibrary +open System.IO + +let ext : string = Path.GetExtension(null) +""" + |> asLibrary + |> typeCheckWithStrictNullness + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +[] +let ``BCL Path.GetExtension - null-bound variable input yields nullable result`` () = + FSharp """module MyLibrary +open System.IO + +let maybeNull = null +let ext : string = Path.GetExtension(maybeNull) +""" + |> asLibrary + |> typeCheckWithStrictNullness + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +[] +let ``BCL Path.GetExtension - explicit non-null parameter annotation yields non-null result`` () = + FSharp """module MyLibrary +open System.IO + +let f (x: string) : string = Path.GetExtension x +""" + |> asLibrary + |> typeCheckWithStrictNullness + |> shouldSucceed \ No newline at end of file From 5e1d0ad38f547b454dc79dff48ae16ac499448d6 Mon Sep 17 00:00:00 2001 From: kerams Date: Tue, 23 Jun 2026 12:40:18 +0200 Subject: [PATCH 07/10] More tests --- .../Nullness/NotNullIfNotNullTests.fs | 125 +++++++++++++++++- 1 file changed, 122 insertions(+), 3 deletions(-) diff --git a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs index 36764471f1a..b0ed9b6308d 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs @@ -28,6 +28,23 @@ namespace NotNullLib { [return: NotNullIfNotNull("second")] public static string? DependsOnSecond(string? first, string? second) => second; } + + public static class Extensions { + // Degenerate case: the return depends on the 'this' parameter of a C#-style extension method. + // When called instance-style the receiver is an object argument, not an unnamed caller argument. + [return: NotNullIfNotNull("self")] + public static string? PreferSelf(this string? self, string? other) => self ?? other; + } + + public static class Variadic { + // The result depends on an optional parameter ('b') that is not in the first position. + [return: NotNullIfNotNull("b")] + public static string? PickB(string? a = null, string? b = null) => b ?? a; + + // The result depends on the first parameter, which precedes a params array. + [return: NotNullIfNotNull("first")] + public static string? JoinRest(string? first, params string?[] rest) => first; + } }""" |> withName "csNotNullLib" let private nullableExpected = "was expected but this expression is nullable" @@ -59,9 +76,8 @@ let ext : string = Path.GetExtension(maybeNull) [] let ``Multiple NotNullIfNotNull attributes are not supported - Delegate.Combine stays nullable`` () = - // Delegate.Combine carries two [return: NotNullIfNotNull] attributes. Making the result depend on more than one - // parameter (logical OR) is not supported, so the declared nullable return type is kept even though an argument - // is non-null. + // Delegate.Combine carries two [return: NotNullIfNotNull] attributes. We cannot currently represent nullness linking + // to multiple types (logical OR), so the declared nullable return type is kept even though an argument is non-null. FSharp """module MyLibrary open System @@ -145,6 +161,109 @@ let r : string = C.DependsOnSecond(notNull, maybeNull) |> shouldFail |> withDiagnosticMessageMatches nullableExpected +[] +let ``Csharp NotNullIfNotNull - extension this-parameter must be identified, not the explicit argument`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "x" +let maybeNull : string | null = "y" + +// Result depends on 'self' (the receiver), which is nullable -> result must stay nullable and warn. +let r : string = maybeNull.PreferSelf(notNull) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +[] +let ``Csharp NotNullIfNotNull - optional parameter referenced positionally`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "x" +let maybeNull : string | null = "y" + +// 'b' is the referenced (second, optional) parameter, passed positionally and non-null -> result non-null. +let r : string = Variadic.PickB(maybeNull, notNull) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldSucceed + +[] +let ``Csharp NotNullIfNotNull - optional parameter referenced by name`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "x" + +// Only the referenced optional parameter is supplied, by name and non-null -> result non-null. +let r : string = Variadic.PickB(b = notNull) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldSucceed + +[] +let ``Csharp NotNullIfNotNull - optional parameter omitted stays nullable`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "x" + +// The referenced optional parameter 'b' is omitted (defaults to null) -> result stays nullable. +let r : string = Variadic.PickB(notNull) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +[] +let ``Csharp NotNullIfNotNull - parameter before params array, non-null propagation`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "x" +let maybeNull : string | null = "y" + +// Referenced parameter 'first' precedes the params array; non-null first -> result non-null. +let r : string = Variadic.JoinRest(notNull, maybeNull, maybeNull) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldSucceed + +[] +let ``Csharp NotNullIfNotNull - parameter before params array, stays nullable`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "x" +let maybeNull : string | null = "y" + +// Referenced parameter 'first' is nullable -> result stays nullable regardless of params args. +let r : string = Variadic.JoinRest(maybeNull, notNull, notNull) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + [] let ``Csharp NotNullIfNotNull - unannotated parameter with non-null return annotation fails`` () = FSharp """module MyLibrary From 6f84303c1fd23345cfe81be9c53bad813f089b7e Mon Sep 17 00:00:00 2001 From: kerams Date: Wed, 24 Jun 2026 21:07:49 +0200 Subject: [PATCH 08/10] Stuff --- .../Checking/Expressions/CheckExpressions.fs | 9 +- .../Nullness/NotNullIfNotNullTests.fs | 87 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 1d161a4e137..44d0fdbbab7 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -10599,9 +10599,12 @@ and TcMethodApplication | Some callerArgTy -> let retTy = baseMinfo.GetFSharpReturnType(cenv.amap, mMethExpr, callerTyArgs) let argNullness = - match GetTyparTyIfSupportsNull g callerArgTy with - | ValueSome _ -> g.knownWithNull - | ValueNone -> nullnessOfTy g callerArgTy + if TypeNullIsTrueValue g callerArgTy then + g.knownWithNull + else + match GetTyparTyIfSupportsNull g callerArgTy with + | ValueSome _ -> g.knownWithNull + | ValueNone -> nullnessOfTy g callerArgTy MethInfoWithModifiedReturnType(baseMinfo, replaceNullnessOfTy argNullness retTy) | None -> baseMinfo | _ -> baseMinfo diff --git a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs index b0ed9b6308d..56a15baf3dc 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs @@ -27,6 +27,15 @@ namespace NotNullLib { // The result is non-null when the SECOND parameter is non-null. [return: NotNullIfNotNull("second")] public static string? DependsOnSecond(string? first, string? second) => second; + + // Generic echo: 'T' is inferred to the F# argument type with no coercion, so the + // argument's own nullness (including runtime representations like option/unit) is preserved. + [return: NotNullIfNotNull("input")] + public static T EchoGeneric(T input) => input; + + // Object echo: the argument is coerced to 'object', but a 'with null' nullness rides along. + [return: NotNullIfNotNull("input")] + public static object? EchoObj(object? input) => input; } public static class Extensions { @@ -264,6 +273,84 @@ let r : string = Variadic.JoinRest(maybeNull, notNull, notNull) |> shouldFail |> withDiagnosticMessageMatches nullableExpected +// F# <-> runtime interop: a value can be 'null' at runtime even when its F# type is statically non-null. +// 'option' (None) is represented as null via UseNullAsTrueValue, so EchoGeneric of a None must keep the +// result nullable. This case is the one that exercises the TypeNullIsTrueValue branch of the derivation. +[] +let ``Csharp NotNullIfNotNull - generic echo of None stays nullable`` () = + FSharp """module MyLibrary +open NotNullLib + +let none : int option = None +let r : int option = C.EchoGeneric none +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +// Control: a genuinely non-null reference value yields a non-null result through the generic echo. +[] +let ``Csharp NotNullIfNotNull - generic echo of non-null reference is non-null`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "x" +let r : string = C.EchoGeneric notNull +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldSucceed + +// A 'T | null typar argument coming from a generic function keeps the result nullable. +[] +let ``Csharp NotNullIfNotNull - generic echo of nullable typar stays nullable`` () = + FSharp """module MyLibrary +open NotNullLib + +let wrap (x: 'T | null) : 'T = C.EchoGeneric x +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +// The object-accepting echo coerces the argument to 'object', but a 'with null' nullness rides along. +[] +let ``Csharp NotNullIfNotNull - object echo of nullable reference stays nullable`` () = + FSharp """module MyLibrary +open NotNullLib + +let maybeNull : string | null = "y" +let r : obj = C.EchoObj maybeNull +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + +[] +let ``Csharp NotNullIfNotNull - object echo of non-null reference is non-null`` () = + FSharp """module MyLibrary +open NotNullLib + +let notNull : string = "y" +let r : obj = C.EchoObj notNull +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldSucceed + [] let ``Csharp NotNullIfNotNull - unannotated parameter with non-null return annotation fails`` () = FSharp """module MyLibrary From ee6b9fa8fca675c2428626654b70a2309e1c531e Mon Sep 17 00:00:00 2001 From: kerams Date: Wed, 24 Jun 2026 21:34:24 +0200 Subject: [PATCH 09/10] Add unit test --- .../Language/Nullness/NotNullIfNotNullTests.fs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs index 56a15baf3dc..553673a90fd 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/Nullness/NotNullIfNotNullTests.fs @@ -291,6 +291,22 @@ let r : int option = C.EchoGeneric none |> shouldFail |> withDiagnosticMessageMatches nullableExpected +// unit is also represented as null at runtime, so EchoGeneric of '()' must keep the result nullable. +// Like None, this travels the same-tycon nullness subsumption path (unit-with-null vs unit-without-null). +[] +let ``Csharp NotNullIfNotNull - generic echo of unit stays nullable`` () = + FSharp """module MyLibrary +open NotNullLib + +let r : unit = C.EchoGeneric (()) +""" + |> asLibrary + |> withReferences [csNotNullLib] + |> withStrictNullness + |> compile + |> shouldFail + |> withDiagnosticMessageMatches nullableExpected + // Control: a genuinely non-null reference value yields a non-null result through the generic echo. [] let ``Csharp NotNullIfNotNull - generic echo of non-null reference is non-null`` () = From 061c5f8e91908030f1ad7ddabe54e27868ccd93b Mon Sep 17 00:00:00 2001 From: kerams Date: Thu, 25 Jun 2026 11:07:34 +0200 Subject: [PATCH 10/10] Refactor --- src/Compiler/Checking/Expressions/CheckExpressions.fs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 44d0fdbbab7..7bc5a57b576 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -10599,12 +10599,10 @@ and TcMethodApplication | Some callerArgTy -> let retTy = baseMinfo.GetFSharpReturnType(cenv.amap, mMethExpr, callerTyArgs) let argNullness = - if TypeNullIsTrueValue g callerArgTy then + if TypeNullIsTrueValue g callerArgTy || TypeNullIsExtraValueNew g mMethExpr callerArgTy then g.knownWithNull else - match GetTyparTyIfSupportsNull g callerArgTy with - | ValueSome _ -> g.knownWithNull - | ValueNone -> nullnessOfTy g callerArgTy + nullnessOfTy g callerArgTy MethInfoWithModifiedReturnType(baseMinfo, replaceNullnessOfTy argNullness retTy) | None -> baseMinfo | _ -> baseMinfo