Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make .Is* discriminated union properties visible #16341

Merged
merged 19 commits into from
Dec 7, 2023
Merged

Conversation

kerams
Copy link
Contributor

@kerams kerams commented Nov 25, 2023

Implements fsharp/fslang-suggestions#222, attempts to finish #11394.

@kerams kerams requested a review from a team as a code owner November 25, 2023 11:39
@vzarytovskii
Copy link
Member

@kerams you're the MVP, thanks for reviving it! I remember I've hit some verification issues (ilverify tests iirc), and didn't have much time to investigate. Let me know if you'll hit them as well and will want some help looking into them.

@edgarfgp
Copy link
Contributor

It would be good to add some tests when using lowercase DU ie:

[<RequireQualifiedAccess>]
type X =
 | A
 | a

where we would have IsA and Isa  

@kerams
Copy link
Contributor Author

kerams commented Nov 25, 2023

@psfinaki, how would I go about constraining which symbols can be renamed? Entities that are either compiler generated or implied should not offer the rename context menu option. Maybe I can return an empty result from GetRenameInfoAsync?

On second thought, maybe rename should be allowed here?

@vzarytovskii
Copy link
Member

@kerams, a shot in the dark but:

yield FSharpInlineRenameLocation(document, textSpan)

https://github.com/dotnet/fsharp/blob/b4dc66c3d3cd4b45614d2b1b903890c9c846895a/vsintegration/src/FSharp.Editor/InlineRename/InlineRenameService.fs#L102C16-L102C25

That would work, probably, from the VS language service perspective, not sure if @kerams asked about universal service solution.

@vzarytovskii
Copy link
Member

@psfinaki, how would I go about constraining which symbols can be renamed? Entities that are either compiler generated or implied should not offer the rename context menu option. Maybe I can return an empty result from GetRenameInfoAsync?

On second thought, maybe rename should be allowed here?

Thinking about it now. I'm a bit confused. You mean if we should allow renames on Is* members?

@kerams
Copy link
Contributor Author

kerams commented Nov 25, 2023

Yes. On the one hand, if you decide to rename a union case, it should be possible to rename Is properties too in order to get them in sync. On the other hand, renaming something that the compiler generated generated is fishy. Ideally a rename of either should rename both, but I am not sure how hard that would be.

I might just ignore this issue for now.

@vzarytovskii
Copy link
Member

vzarytovskii commented Nov 25, 2023

Yes. On the one hand, if you decide to rename a union case, it should be possible to rename Is properties too in order to get them in sync. On the other hand, renaming something that the compiler generated generated is fishy. Ideally a rename of either should rename both, but I am not sure how hard that would be.

I might just ignore this issue for now.

Yeah, it's a tough issue. I agree that we can ignore it for now.

@smoothdeveloper
Copy link
Contributor

on the rename front, there is also the subtlety of renaming a property to the compiler generated name for an existing case.

I think the most important is just to prevent a rename on the call to .Is*, all considering the rest as pandora boxes.

@kerams
Copy link
Contributor Author

kerams commented Nov 26, 2023

One more thing I am unsure about. If assembly A is compiled with lang version preview and exposes a union, assembly B with a reference to A has access to that union's case testers even if it uses a lower language version.

Finally, these members are currently not surfaced without using the right language version, meaning we don't an error guiding you to update. You also don't get to use the members in 3rd party libraries, even if they have been recompiled, but without the approriate language version.

Perhaps it would be best to always surface the members in metadata, but add a usage check in CheckExpressions? That would address both issues in this comment. (Older compilers will likely see these properties either way (see #11394 (comment)), and existing libraries will still need to be recompiled in order to expose union case testers)

@T-Gro
Copy link
Member

T-Gro commented Nov 27, 2023

One more thing I am unsure about. If assembly A is compiled with lang version preview and exposes a union, assembly B with a reference to A has access to that union's case testers even if it uses a lower language version.

Finally, these members are currently not surfaced without using the right language version, meaning we don't an error guiding you to update. You also don't get to use the members in 3rd party libraries, even if they have been recompiled, but without the approriate language version.

Perhaps it would be best to always surface the members in metadata, but add a usage check in CheckExpressions? That would address both issues in this comment. (Older compilers will likely see these properties either way (see #11394 (comment)), and existing libraries will still need to be recompiled in order to expose union case testers)

I personally believe that the language check should be only done at the producer of the DU.
The consumer should accept it and be able to consume it, even if the consumer is on an older language version.

After all, the 'producer' could write similar tester properties by hand as of now already.

@kerams
Copy link
Contributor Author

kerams commented Nov 27, 2023

After all, the 'producer' could write similar tester properties by hand as of now already.

Similar, yes, but not identical.

Assemblies emit IL for these members already, so there is no run-time difference. We're only talking about metadata. Imagine you are consuming a third party assembly whose author updates the SDK, but doesn't want to bump the language version. If the feature check is on the producer side, you wouldn't be able to use case testers from that assembly in your code, even if you use 'preview'. At the same time, whether or not the case testers are visible outwardly makes no difference to the author, because they exist regardless.

@T-Gro
Copy link
Member

T-Gro commented Nov 27, 2023

After all, the 'producer' could write similar tester properties by hand as of now already.

Similar, yes, but not identical.

Assemblies emit IL for these members already, so there is no run-time difference. We're only talking about metadata. Imagine you are consuming a third party assembly whose author updates the SDK, but doesn't want to bump the language version. If the feature check is on the producer side, you wouldn't be able to use case testers from that assembly in your code, even if you use 'preview'. At the same time, whether or not the case testers are visible outwardly makes no difference to the author, because they exist regardless.

The code owned by the producer might already be providing similar checks (maybe with some additional features/logic) and wouldn't want to expose the automatically created getters to keep the recommended API layer clean (for F# consumers at least). Keeping autocomplete results clean might be one aspect to it.

The producer should be able to control the augmentation via attributes as is possible with existing augmentations.
Deferring the languagefeature check to the consumer would lead to a situation of the producer being out of control.

I do realize this depends on one's opinion on 'adding to the offered F# API with hopefully harmless properties'. From the runtime perspective, it does not matter of course. But considering the tooling aspect with autocomplete and API discovery, I do see a difference.

@psfinaki
Copy link
Member

On the rename front, I'd say there are two premises here:

  1. Renaming DU fields is a fundamental tooling capability, hence should be possible
  2. Any tooling action, if offered, shouldn't break the code / break the code even further

I haven't deeply looked into the implementation yet, however that's what I'd probably consider an ideal tooling behavior.

Given the code:

type Contact =
    | Email of address: string
    | Phone of countryCode: int * number: string

type Person = { name: string; contact: Contact }

let canSendEmailTo person =
    person.contact.IsEmail
  1. Renaming Email to EmailAddress in the DU works and also renames IsEmail to IsEmailAddress
  2. Renaming IsEmail to IsEmailAddress produces some kind of popup like "hey, this is a generated thing, please rename the original field and this will be renamed as well"

Now, since I don't expect 2) to be a common scenario and it's hard to implement, I'd be fine if that just wasn't offered for renaming. If that's offered but doesn't do anything - well, we can live with it. That still feels better to me than having that working in a way that it would automatically rename the original field everywhere.

That's just my perspective. Maybe @0101 you have some ideas.


On that note, if you @kerams save renaming files in this PR for a followup, reviewers will appreciate it :)

And thanks for your work.

@kerams kerams requested a review from T-Gro December 1, 2023 12:49
@vzarytovskii vzarytovskii enabled auto-merge (squash) December 7, 2023 13:14
Copy link
Member

@vzarytovskii vzarytovskii left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's great, thanks!

@auduchinok
Copy link
Member

auduchinok commented Dec 7, 2023

@kerams Could you please add some tests for intersection with interface members? Sorry if I've missed them in the test
data.

type I =
    abstract IsA: bool

type U =
    | A
    | B

    member this.IsA = false

    interface I with
        member this.IsA = this.IsA
type I =
    abstract IsA: bool

type U =
    | A
    | B

    interface I with
        member this.IsA = this.IsA
type I =
    abstract IsA: bool

type U =
    | A
    | B

    member this.IsA = 1

    interface I with
        member this.IsA = this.IsA

@vzarytovskii vzarytovskii merged commit 9123c41 into dotnet:main Dec 7, 2023
26 checks passed
src/Compiler/CodeGen/EraseUnions.fs Show resolved Hide resolved
@@ -1933,6 +1933,9 @@ module TastDefinitionPrinting =
let props =
GetImmediateIntrinsicPropInfosOfType (None, ad) g amap m ty
|> List.filter (fun pinfo -> shouldShow pinfo.ArbitraryValRef)
// Filter out 'IsA' properties which are implied by the union cases since they don't need to be displayed
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for my education - what are some examples of the printed outputs here? As in, are we sure it doesn't ever make sense to show these?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any DU properties you define.

@@ -578,6 +578,10 @@ val PublishTypeDefn: cenv: TcFileState -> env: TcEnv -> mspec: Tycon -> unit
/// Publish a value definition to the module/namespace type accumulator.
val PublishValueDefn: cenv: TcFileState -> env: TcEnv -> declKind: DeclKind -> vspec: Val -> unit

/// Publish a value definition to the module/namespace type accumulator.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably should be decopypasted :)

// Build augmentation declarations
//-------------------------------------------------------------------------

module AddAugmentationDeclarations =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to separate this to a separate file so that CheckDeclarations keeps at least somewhat manageable size.

I mean I know it was there so you don't have to :) Just since you've looked into that, you might know if it is easy to do or not.

|> shouldSucceed

[<FSharp.Test.FactForNETCOREAPP>]
let ``Lowercase Is* discriminated union properties are visible, proper values are returned`` () =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you maybe also add a sanity check test that "isABC" doesn't work?

type Foo = | Foo of string | ``Mars Bar``
let foo = Foo.Foo "hi"
if not foo.IsFoo then failwith "Should be Foo"
if foo.``IsMars Bar`` then failwith "Should not be ``Mars Bar``"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. Thinking about it - this is probably a viable approach, but just curious - how hard would it be to rather have foo.Is``Mars Bar`` or to ban such elements from having Is property altogether?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is``Mars Bar`` is not a valid identifier.

@psfinaki
Copy link
Member

psfinaki commented Dec 7, 2023

Damn my review came too late :)

@kerams kerams deleted the props branch December 7, 2023 14:10
@chkn
Copy link
Contributor

chkn commented Dec 7, 2023

@kerams Oh this is great! Thanks for bringing this puppy home

|> withLangVersionPreview
|> typecheck
|> shouldFail
|> withErrorMessage "The type 'ValueOption<_>' does not define the field, constructor or member 'IsValueNone'. Maybe you want one of the following:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kerams :

I am having a problem with this test in a different PR and I cannot get my head around it - how is it ensured, that the generation is prevented for ValueOption?
Afterall, it does NOT have DefaultAugmentation(false).

What am I missing?
I tried to scan this PR's diff for any kind of logical condition that would skip IsValueNone and IsValueSome for ValueOption, but I do not see it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While the case testers exist, they aren't exposed in prim-types.fsi.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And this PR does not add them automatically to anything in prim-types unless specified, or how does it work?
i.e. what condition is in place to make sure that ValueOption does NOT get them and a user-defined DU gets them?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or is it done based on exposing/not exposing in a signature file?
Meaning that all users of signature files (not many, I admit) will only benefit from this feature if they manually add the testers to .fsi?

(this might be relevant for docs)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if there's a signature file, case testers need to be exposed manually. The RFC mentions it briefly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, got it.

For some reason, I have it keep coming in the nullness PR (e.g. ValueOption does come up with the IsValueNone tester), not sure what is so different over there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Archived in project
Development

Successfully merging this pull request may close these issues.

8 participants