Skip to content

Safe list handling for ActionInputs - #38

Open
shayanhabibi wants to merge 16 commits into
JordanMarr:mainfrom
shayanhabibi:empty-lists
Open

Safe list handling for ActionInputs#38
shayanhabibi wants to merge 16 commits into
JordanMarr:mainfrom
shayanhabibi:empty-lists

Conversation

@shayanhabibi

@shayanhabibi shayanhabibi commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

No more errors on empty lists or requirements for custom binders by default.

type private SafeInputLists =
    static member private dynamicParser<'T>(): Parsing.ArgumentResult -> Array =
        fun result ->
            let typ = typeof<'T>
            let count = result.Tokens.Count
            let elementType =
                // if list is empty, then element type will return null
                if typ.GetElementType() = null
                // use the generic arg passed to the generic type definition
                then typ.GetGenericArguments()[0]
                // otherwise, we use the element type
                else typ.GetElementType()
                
            let dynamicArray = Array.CreateInstance(elementType, count)
            for i, token in result.Tokens |> Seq.indexed do
                dynamicArray.SetValue(Convert.ChangeType(token.Value, elementType), i)
            dynamicArray
        
    static member protect<'T>(o: Argument<'T>) =
        match typeof<'T> with
        | typ when typ.IsGenericType && typ.GetGenericTypeDefinition() = typedefof<list<_>> ->
            o.Arity <- ArgumentArity (0, 100_000)
            o.CustomParser <- (SafeInputLists.dynamicParser<'T>() >> fun dynamicArray ->
                let modl = typeof<list<obj>>.Assembly.GetType("Microsoft.FSharp.Collections.ListModule")
                let meth = modl.GetMethod("OfArray", System.Reflection.BindingFlags.Static ||| System.Reflection.BindingFlags.Public)
                meth.MakeGenericMethod(typeof<'T>.GetGenericArguments()[0]).Invoke(null, [| dynamicArray |] ) |> unbox
                )
            o.DefaultValueFactory <- (fun _ ->
                let emptyProperty = typ.GetProperty("Empty", System.Reflection.BindingFlags.Static ||| System.Reflection.BindingFlags.Public)
                if emptyProperty <> null then emptyProperty.GetValue(null) |> unbox
                else failwithf $"Could not find Empty property on type %s{typ.FullName}."
                )
            o
        | _ -> o
    static member protect<'T>(o: Option<'T>) = // ...

@JordanMarr JordanMarr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for this! I dug into it and it's more valuable than the title suggests: on main, F# list inputs don't work at all (omitted → NullReferenceException; supplied → S.CL's "FSharpList cannot be created without a custom binder"). With this branch, omitted gives [], order is preserved, and int list parses. So I'd like to get it in. A few changes first:

1. Route conversion failures through AddError.
A bad token currently escapes as an exception instead of a parse error, e.g. option<int list> "-n" with -n abc throws FormatException. Catch the conversion failure and call result.AddError(...) so the user gets S.CL's normal error output and exit code.

2. Reuse MaybeParser.parseTokenValue for element conversion.
Convert.ChangeType alone can't handle FileInfo / DirectoryInfo / Uri, so option<FileInfo list> throws InvalidCastException. Going through MaybeParser (the same path tryParse/customParser use) keeps behavior consistent across the library. Building the closed List.ofArray method once when the option is created, rather than on every parse, would be nice too.

3. Dedupe the two protect overloads.
The Option<'T> and Argument<'T> bodies are identical. Build the parser and default-value factory once in a shared helper and have each overload just assign them. The typ.GetElementType() branch is also unreachable since the caller already guards on list<_>.

4. Leave Input.option where it was.
It moved from the top of the module to below recursive, which makes the diff noisy without changing anything. SafeInputLists is defined above the module, so the original position works.

5. A few more tests.
The current test sets arity Arity.ZeroOrMore explicitly, so the new default arity isn't exercised. Cases I'd add: option omitted (no explicit arity) → []; int list with values; bad token → non-zero exit and no exception; argument<string list> with values and omitted.

Also, #37 just merged, so you'll need to rebase — Tests.fsproj will conflict on the <Compile Include> line.

Once these are in I'll merge. Thanks again!

shayanhabibi and others added 10 commits September 10, 2026 13:01
…typed value (acceptOnlyFromChoices, acceptOnlyFromChoicesWith, acceptManyFromChoices, acceptManyFromChoicesWith, acceptOnlyFromChoicesIgnoreCase, acceptManyFromChoicesIgnoreCase)
…g to typed value

Added tests; and bullet points in readme.md

Tests demonstrate failing, correct, and undefined behaviours.
- README example was missing the option name argument
- Drop the "tryParse overrides mapFromAmong" note, remarks, and the test
  asserting that undefined behaviour
- Reword doc comments with inline examples

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmkZPCobsju7AauvVH8du
1. Conversion errors route through AddError
2. List protection routes through SRTP overloads
3. Tests cover option(int list), option(string list), argument(int list), argument(string list)
@shayanhabibi

Copy link
Copy Markdown
Contributor Author

2. Reuse MaybeParser.parseTokenValue for element conversion. Convert.ChangeType alone can't handle FileInfo / DirectoryInfo / Uri, so option<FileInfo list> throws InvalidCastException. Going through MaybeParser (the same path tryParse/customParser use) keeps behavior consistent across the library. Building the closed List.ofArray method once when the option is created, rather than on every parse, would be nice too.

We don't have the element type as typar to dispatch through MaybeParser.parseTokenValue unfortunately; but I've composed the original implementation to dispatch through MaybeParser.parseTokenValueOfType so that the logic is shared between the two pathways

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants