Repository: comonicon/Comonicon.jl (julia)

Issue:
Feature request: Support options without default values
Nice repo! I am trying to create an application, in which it is unsafe to make either the argument `name` a positional argument, or a keyword argument having a default value.

```julia
@cast function generate_smallgraph(problem::String;
    name::String,
    weights::Union{Nothing,Vector{Float64}}=nothing,
    datafolder::String="data", prefix::String="")
    graph = SmallGraphConfig(name)
    config = GraphProblemConfig(; problem, graph, weights, openvertices=Int[])
    foldername(datafolder, config; create=true, prefix)
end
```

The error message is
```julia
shell> julia --project project/run.jl generate-smallgraph IndependentSet --name=petersen
ERROR: LoadError: options should have default values or make it a positional argument
```

I wish it can be supported!

Pull request description:
fix #195

Patch:
diff --git a/src/ast/types.jl b/src/ast/types.jl
index 49ee8f8c..ee31500b 100644
--- a/src/ast/types.jl
+++ b/src/ast/types.jl
@@ -34,6 +34,7 @@ Base.@kwdef struct Option <: ComoniconExpr
     sym::Symbol
     name::String = replace(string(sym), '_' => '-')
     hint::Maybe{String} = nothing
+    require::Bool = false # unless option doesn't have a default keyword
     type = Any
     short::Bool = false
     description::Description = Description()
diff --git a/src/codegen/julia.jl b/src/codegen/julia.jl
index 808867ab..bbaa9a8d 100644
--- a/src/codegen/julia.jl
+++ b/src/codegen/julia.jl
@@ -239,6 +239,30 @@ function emit_leaf_call(cmd::LeafCommand, ctx::EmitContext, args::Symbol, kwargs
         )
     end
 
+    # check if all required options are filled
+    if !isempty(cmd.options)
+        for each in values(cmd.options)
+            each.require || continue
+            err_hint = "--" * each.name
+            
+            if each.short
+                err_hint *= ",-" * each.name[1:1]
+            end
+
+            err_hint = "expect option $err_hint"
+
+            @gensym idx
+            push!(ret.args,
+                quote
+                    $idx = findfirst(x->x.first===$(QuoteNode(each.sym)), $kwargs)
+                    if $idx === nothing
+                        $(emit_error(cmd, ctx, err_hint))
+                    end
+                end
+            )
+        end
+    end
+
     call = Expr(:call, cmd.fn)
     if !isempty(cmd.flags) || !isempty(cmd.options)
         push!(call.args, Expr(:parameters, :($kwargs...)))
@@ -301,9 +325,9 @@ function emit_exception_handle(cmd::LeafCommand, ctx::EmitContext, call, color::
         catch e
             # NOTE:
             # terminate should return 0
-            # other exception will return 1
+            # other exception will return exitcode
             if e isa $CommandExit
-                print("command exit")
+                println("command exit with 0")
                 return 0
             elseif e isa $CommandException
                 showerror(stdout, e)
diff --git a/src/frontend/cast.jl b/src/frontend/cast.jl
index 30bf8f10..8ec9a3a4 100644
--- a/src/frontend/cast.jl
+++ b/src/frontend/cast.jl
@@ -254,8 +254,18 @@ function is_optional_argument_with_type(ex)
 end
 
 function split_leaf_command(fn::JLFunction)
+    args = parse_arguments(fn)
+    flags, options = parse_kwargs(fn)
+
+    args = Expr(:ref, :($Comonicon.JLArgument), args...)
+    options = Expr(:ref, :($Comonicon.JLOption), options...)
+    flags = Expr(:ref, :($Comonicon.JLFlag), flags...)
+    return args, options, flags
+end
+
+function parse_arguments(fn::JLFunction)
     # use ::<type> as hint if there is no docstring
-    args = map(fn.args) do each
+    return map(fn.args) do each
         if each isa Symbol
             xcall(Comonicon, :JLArgument; name = QuoteNode(each))
         elseif is_argument_with_type(each) # :($name::$type)
@@ -302,45 +312,78 @@ function split_leaf_command(fn::JLFunction)
             throw(Meta.ParseError("invalid syntax: $each"))
         end
     end
+end
 
+"""
+    parse_kwargs(fn::JLFunction) -> flags, options
+
+Parse the keyword arguments of function expression `fn` into
+intermediate Julia CLI objects `JLFlag` and `JLOption`.
+"""
+function parse_kwargs(fn::JLFunction)
     flags, options = [], []
-    if !isnothing(fn.kwargs)
-        for each in fn.kwargs
-            Meta.isexpr(each, :kw) ||
-                error("options should have default values or make it a positional argument")
-            expr = each.args[1]
-            value = each.args[2]
-            if expr isa Symbol # Expr(:kw, name::Symbol, value)
-                push!(options, xcall(Comonicon, :JLOption, QuoteNode(expr), Any, string(value)))
-            elseif Meta.isexpr(expr, :(::))
-                name = expr.args[1]
-                type = expr.args[2]
-
-                if type === :Bool || type === Bool
-                    value == false || error(
-                        "Boolean options must use false as " *
-                        "default value, and will be parsed as flags. got $name",
-                    )
-                    push!(flags, xcall(Comonicon, :JLFlag, QuoteNode(name)))
-                else
-                    push!(
-                        options,
-                        xcall(
-                            Comonicon,
-                            :JLOption,
-                            QuoteNode(name),
-                            type,
-                            string(value) * "::" * string(type),
-                        ),
-                    )
-                end
-            end
+    isnothing(fn.kwargs) && return flags, options
+    for each_expr in fn.kwargs
+        if Meta.isexpr(each_expr, :kw)
+            parse_optional_kwargs!(flags, options, each_expr)
+        else
+            parse_required_kwargs!(flags, options, each_expr)
         end
     end
-    args = Expr(:ref, :($Comonicon.JLArgument), args...)
-    options = Expr(:ref, :($Comonicon.JLOption), options...)
-    flags = Expr(:ref, :($Comonicon.JLFlag), flags...)
-    return args, options, flags
+    return flags, options
+end
+
+function parse_required_kwargs!(flags, options, @nospecialize(expr))::Nothing
+    if expr isa Symbol # name::Any
+        name = expr
+        type = Any
+    elseif Meta.isexpr(expr, :(::)) # name::type
+        name = expr.args[1]
+        type = expr.args[2]
+    else
+        throw(ArgumentError("unexpected expression $expr"))
+    end
+
+    push!(options, xcall(
+        Comonicon, :JLOption, QuoteNode(name), true, type, string(type),
+    ))
+    return
+end
+
+function parse_optional_kwargs!(flags, options, kw_expr::Expr)::Nothing
+    # expr == Expr(:kw, expr, value)
+    expr = kw_expr.args[1]
+    value = kw_expr.args[2]
+    if expr isa Symbol # Expr(:kw, name::Symbol, value)
+        push!(options, xcall(Comonicon, :JLOption, QuoteNode(expr), false, Any, string(value)))
+    elseif Meta.isexpr(expr, :(::)) # Expr(:kw, ($name::$type), value)
+        name = expr.args[1]
+        type = expr.args[2]
+
+        if type === :Bool || type === Bool
+            value == false || error(
+                "Boolean options must use false as " *
+                "default value, and will be parsed as flags. got $name" *
+                "for non-standard usage please use the AST interface directly"
+            )
+            push!(flags, xcall(Comonicon, :JLFlag, QuoteNode(name)))
+        else
+            push!(
+                options,
+                xcall(
+                    Comonicon,
+                    :JLOption,
+                    QuoteNode(name),
+                    false,
+                    type,
+                    string(value) * "::" * string(type),
+                ),
+            )
+        end
+    else
+        throw(ArgumentError("unexpected expression: $kw_expr"))
+    end
+    return
 end
 
 function wrap_type(def::JLFunction, type)
@@ -586,6 +629,7 @@ function cast_options(doc::JLMD, options::Vector{JLOption}, line)
                     sym = each.name,
                     name = name,
                     hint = option.hint, # use user defined hint
+                    require = each.require,
                     type = each.type,
                     short = option.short,
                     description = option.desc,
@@ -600,6 +644,7 @@ function cast_options(doc::JLMD, options::Vector{JLOption}, line)
                 sym = each.name,
                 name = name,
                 hint = each.hint,
+                require = each.require,
                 type = each.type,
                 line = line,
             )
diff --git a/src/frontend/types.jl b/src/frontend/types.jl
index 039ab258..8e55ec04 100644
--- a/src/frontend/types.jl
+++ b/src/frontend/types.jl
@@ -8,6 +8,7 @@ end
 
 struct JLOption
     name::Symbol
+    require::Bool
     type::Any
     hint::String
 end


Test patch:
diff --git a/test/codegen/julia/options.jl b/test/codegen/julia/options.jl
index 175d270b..51bd8641 100644
--- a/test/codegen/julia/options.jl
+++ b/test/codegen/julia/options.jl
@@ -79,3 +79,25 @@ end
     @test command_main(["build", "test", "--target=aaaa"]) == 0
 end
 end
+
+module TestRequireOptions
+
+using Test
+using Comonicon
+
+"""
+# Options
+
+- `--name=<string>`: name
+"""
+@main function run(;name::String, shots::Int)
+    @test name == "test"
+    @test shots == 2
+end
+
+@testset "TestRequireOptions" begin
+    @test TestRequireOptions.command_main(["--name=test"]) == 1
+    @test TestRequireOptions.command_main(["--name=test", "--shots=2"]) == 0
+end
+
+end
diff --git a/test/frontend/cast.jl b/test/frontend/cast.jl
index a7110c75..c5a0f80f 100644
--- a/test/frontend/cast.jl
+++ b/test/frontend/cast.jl
@@ -35,9 +35,9 @@ flags = [JLFlag(:flag1), JLFlag(:flag2)]
 
 options = [
     # name, type, hint
-    JLOption(:option1, Any, "nothing"),
-    JLOption(:option2, Int, "1::Int"),
-    JLOption(:option3, String, "abc::String"),
+    JLOption(:option1, false, Any, "nothing"),
+    JLOption(:option2, false, Int, "1::Int"),
+    JLOption(:option3, false, String, "abc::String"),
 ]
 
 """
@@ -99,34 +99,57 @@ function foo(
 end
 
 @testset "split_leaf_command" begin
-    def = @expr JLFunction function foo(
-        arg1,
-        arg2::Int = 1,
-        arg3::String = "abc";
-        option1 = nothing,
-        option2::Int = 1,
-        option3::String = "abc",
-        flag1::Bool = false,
-        flag2::Bool = false,
-    ) end
 
-    args′, options′, flags′ = split_leaf_command(def)
-    args′, options′, flags′ = eval(args′), eval(options′), eval(flags′)
-
-    @test args′ == args
-    @test options == options′
-    @test flags == flags′
-
-    def = @expr JLFunction function foo(; option1::Bool = true) end
-    @test_throws ErrorException split_leaf_command(def)
-
-    def = @expr JLFunction function foo(; option1::Bool) end
-    @test_throws ErrorException split_leaf_command(def)
-
-    def = @expr JLFunction function foo(; option1) end
-    @test_throws ErrorException split_leaf_command(def)
+    @testset "basic" begin
+        def = @expr JLFunction function foo(
+            arg1,
+            arg2::Int = 1,
+            arg3::String = "abc";
+            option1 = nothing,
+            option2::Int = 1,
+            option3::String = "abc",
+            flag1::Bool = false,
+            flag2::Bool = false,
+        ) end
+
+        args′, options′, flags′ = split_leaf_command(def)
+        args′, options′, flags′ = eval(args′), eval(options′), eval(flags′)
+
+        @test args′ == args
+        @test options == options′
+        @test flags == flags′
+
+        def = @expr JLFunction function foo(; option1::Bool = true) end
+        @test_throws ErrorException split_leaf_command(def)
+    end
+
+    @testset "required kwargs" begin
+        def = @expr JLFunction function foo(
+            arg1,
+            arg2::Int = 1,
+            arg3::String = "abc";
+            option1,
+            option2::Int,
+            option3::String,
+            flag1::Bool,
+            flag2::Bool = false,
+        ) end
+
+        args′, options′, flags′ = split_leaf_command(def)
+        args′, options′, flags′ = eval(args′), eval(options′), eval(flags′)
+
+        @test options′ == [
+            JLOption(:option1, true, Any, "Any"),
+            JLOption(:option2, true, Int64, "Int"),
+            JLOption(:option3, true, String, "String"),
+            JLOption(:flag1, true, Bool, "Bool"),
+        ]
+
+        @test flags′ == [JLFlag(:flag2)]
+    end
 end
 
+
 @test_throws ErrorException eval(:(module TestA
 using Comonicon
 @cast module Foo end