diff --git a/fpm.toml b/fpm.toml index 5dd6a53..bbe2cff 100644 --- a/fpm.toml +++ b/fpm.toml @@ -22,7 +22,6 @@ test-drive.git = "https://github.com/fortran-lang/test-drive.git" [install] library = false -test = false [fortran] implicit-typing = false diff --git a/src/fclap.f90 b/src/fclap.f90 index fd2ec4b..f7d2ad2 100644 --- a/src/fclap.f90 +++ b/src/fclap.f90 @@ -54,6 +54,7 @@ module fclap use fclap_actions, only: Action, not_less_than, not_bigger_than use fclap_parser, only: ArgumentParser, ArgumentGroup, MutuallyExclusiveGroup, & get_prog_name + use fclap_utils_accuracy, only: wp use fclap_version, only: get_fclap_version, fclap_version_compact, & fclap_version_string @@ -117,5 +118,6 @@ module fclap public :: get_fclap_version ! Get version components public :: fclap_version_compact ! Compact version string public :: fclap_version_string ! Full version string + public :: wp ! Working precision kind for reals end module fclap diff --git a/src/fclap/fclap_actions.f90 b/src/fclap/fclap_actions.f90 index 6111de7..245ed33 100644 --- a/src/fclap/fclap_actions.f90 +++ b/src/fclap/fclap_actions.f90 @@ -86,12 +86,16 @@ module fclap_actions type(ValueContainer) :: default_value !> Flag indicating whether a default value has been set logical :: has_default = .false. + !> Whether default value should be shown in help text when set + logical :: print_default = .true. !> Metavar for display in usage/help (e.g., "FILE" instead of "filename") character(len=:), allocatable :: metavar !> Array of valid choices that constrain acceptable values character(len=MAX_ARG_LEN) :: choices(MAX_CHOICES) !> Number of choices defined for this argument integer :: num_choices = 0 + !> Whether choices should be shown in help text + logical :: print_choices = .false. !> Expected value type (TYPE_STRING, TYPE_INTEGER, TYPE_REAL, TYPE_LOGICAL) integer :: value_type = TYPE_STRING !> Whether this is a positional argument (vs optional/flag) @@ -260,6 +264,19 @@ function action_check_status(self, error) result(can_proceed) end select end function action_check_status + !> @brief Add argument context to an error message. + !> + !> @param message Base error message + !> @param display_name Argument display name + !> @return Message including argument context + function with_argument_context(message, display_name) result(context_message) + character(len=*), intent(in) :: message + character(len=*), intent(in) :: display_name + character(len=:), allocatable :: context_message + + context_message = trim(message) // " for argument '" // trim(display_name) // "'" + end function with_argument_context + !> @brief Execute action based on action type. !> !> @details Performs the action's behavior based on its action_type: @@ -283,7 +300,9 @@ subroutine action_execute(self, args, values, num_values, error) type(fclap_error), intent(inout) :: error integer :: int_val, ios, i, int_bound, str_len real(wp) :: real_val, real_bound - real :: store_real_val + character(len=:), allocatable :: display_name + + display_name = self%get_display_name() select case(self%action_type) case(ACT_STORE) @@ -294,7 +313,8 @@ subroutine action_execute(self, args, values, num_values, error) ! Store all values as a list do i = 1, num_values if (.not. self%is_valid_choice(values(i))) then - call error%init("invalid choice", values(i)) + call error%init( & + with_argument_context("invalid choice", display_name), values(i)) return end if @@ -302,7 +322,9 @@ subroutine action_execute(self, args, values, num_values, error) case(TYPE_INTEGER) read(values(i), *, iostat=ios) int_val if (ios /= 0) then - call error%init("invalid integer value", values(i)) + call error%init( & + with_argument_context("invalid integer value", display_name), & + values(i)) return end if call args%append_integer(self%dest, int_val) @@ -314,12 +336,13 @@ subroutine action_execute(self, args, values, num_values, error) else ! Single-value storage if (num_values < 1) then - call error%init("expected one argument", self%dest) + call error%init(with_argument_context("expected one argument", display_name)) return end if if (.not. self%is_valid_choice(values(1))) then - call error%init("invalid choice", values(1)) + call error%init( & + with_argument_context("invalid choice", display_name), values(1)) return end if @@ -327,7 +350,9 @@ subroutine action_execute(self, args, values, num_values, error) case(TYPE_INTEGER) read(values(1), *, iostat=ios) int_val if (ios /= 0) then - call error%init("invalid integer value", values(1)) + call error%init( & + with_argument_context("invalid integer value", display_name), & + values(1)) return end if call args%set_integer(self%dest, int_val) @@ -335,11 +360,11 @@ subroutine action_execute(self, args, values, num_values, error) case(TYPE_REAL) read(values(1), *, iostat=ios) real_val if (ios /= 0) then - call error%init("invalid real value", values(1)) + call error%init( & + with_argument_context("invalid real value", display_name), values(1)) return end if - store_real_val = real(real_val) - call args%set_real(self%dest, store_real_val) + call args%set_real(self%dest, real_val) case default call args%set_string(self%dest, values(1)) @@ -357,13 +382,15 @@ subroutine action_execute(self, args, values, num_values, error) case(ACT_APPEND) if (num_values < 1) then - call error%init("expected at least one argument", self%dest) + call error%init( & + with_argument_context("expected at least one argument", display_name)) return end if do i = 1, num_values if (.not. self%is_valid_choice(values(i))) then - call error%init("invalid choice", values(i)) + call error%init( & + with_argument_context("invalid choice", display_name), values(i)) return end if @@ -371,7 +398,9 @@ subroutine action_execute(self, args, values, num_values, error) case(TYPE_INTEGER) read(values(i), *, iostat=ios) int_val if (ios /= 0) then - call error%init("invalid integer value", values(i)) + call error%init( & + with_argument_context("invalid integer value", display_name), & + values(i)) return end if call args%append_integer(self%dest, int_val) @@ -393,7 +422,7 @@ subroutine action_execute(self, args, values, num_values, error) case(ACT_NOT_LESS_THAN) ! Store value and validate it is >= bound if (num_values < 1) then - call error%init("expected one argument", self%dest) + call error%init(with_argument_context("expected one argument", display_name)) return end if @@ -401,16 +430,22 @@ subroutine action_execute(self, args, values, num_values, error) case(TYPE_INTEGER) read(values(1), *, iostat=ios) int_val if (ios /= 0) then - call error%init("invalid integer value", values(1)) + call error%init( & + with_argument_context("invalid integer value", display_name), values(1)) return end if read(self%bound_str, *, iostat=ios) int_bound if (ios /= 0) then - call error%init("invalid bound value", self%bound_str) + call error%init( & + with_argument_context("invalid bound value", display_name), & + self%bound_str) return end if if (int_val < int_bound) then - call error%init("value must not be less than " // trim(self%bound_str), values(1)) + call error%init( & + with_argument_context( & + "value must not be less than " // trim(self%bound_str), display_name), & + values(1)) return end if call args%set_integer(self%dest, int_val) @@ -418,31 +453,41 @@ subroutine action_execute(self, args, values, num_values, error) case(TYPE_REAL) read(values(1), *, iostat=ios) real_val if (ios /= 0) then - call error%init("invalid real value", values(1)) + call error%init( & + with_argument_context("invalid real value", display_name), values(1)) return end if read(self%bound_str, *, iostat=ios) real_bound if (ios /= 0) then - call error%init("invalid bound value", self%bound_str) + call error%init( & + with_argument_context("invalid bound value", display_name), & + self%bound_str) return end if if (real_val < real_bound) then - call error%init("value must not be less than " // trim(self%bound_str), values(1)) + call error%init( & + with_argument_context( & + "value must not be less than " // trim(self%bound_str), display_name), & + values(1)) return end if - store_real_val = real(real_val) - call args%set_real(self%dest, store_real_val) + call args%set_real(self%dest, real_val) case default ! String type: compare len(trim(value)) against integer bound str_len = len(trim(values(1))) read(self%bound_str, *, iostat=ios) int_bound if (ios /= 0) then - call error%init("invalid bound value", self%bound_str) + call error%init( & + with_argument_context("invalid bound value", display_name), & + self%bound_str) return end if if (str_len < int_bound) then - call error%init("string length must not be less than " // trim(self%bound_str), values(1)) + call error%init( & + with_argument_context( & + "string length must not be less than " // trim(self%bound_str), & + display_name), values(1)) return end if call args%set_string(self%dest, values(1)) @@ -451,7 +496,7 @@ subroutine action_execute(self, args, values, num_values, error) case(ACT_NOT_BIGGER_THAN) ! Store value and validate it is <= bound if (num_values < 1) then - call error%init("expected one argument", self%dest) + call error%init(with_argument_context("expected one argument", display_name)) return end if @@ -459,16 +504,23 @@ subroutine action_execute(self, args, values, num_values, error) case(TYPE_INTEGER) read(values(1), *, iostat=ios) int_val if (ios /= 0) then - call error%init("invalid integer value", values(1)) + call error%init( & + with_argument_context("invalid integer value", display_name), values(1)) return end if read(self%bound_str, *, iostat=ios) int_bound if (ios /= 0) then - call error%init("invalid bound value", self%bound_str) + call error%init( & + with_argument_context("invalid bound value", display_name), & + self%bound_str) return end if if (int_val > int_bound) then - call error%init("value must not be bigger than " // trim(self%bound_str), values(1)) + call error%init( & + with_argument_context( & + "value must not be bigger than " // & + trim(self%bound_str), display_name), & + values(1)) return end if call args%set_integer(self%dest, int_val) @@ -476,31 +528,42 @@ subroutine action_execute(self, args, values, num_values, error) case(TYPE_REAL) read(values(1), *, iostat=ios) real_val if (ios /= 0) then - call error%init("invalid real value", values(1)) + call error%init( & + with_argument_context("invalid real value", display_name), values(1)) return end if read(self%bound_str, *, iostat=ios) real_bound if (ios /= 0) then - call error%init("invalid bound value", self%bound_str) + call error%init( & + with_argument_context("invalid bound value", display_name), & + self%bound_str) return end if if (real_val > real_bound) then - call error%init("value must not be bigger than " // trim(self%bound_str), values(1)) + call error%init( & + with_argument_context( & + "value must not be bigger than " // & + trim(self%bound_str), display_name), & + values(1)) return end if - store_real_val = real(real_val) - call args%set_real(self%dest, store_real_val) + call args%set_real(self%dest, real_val) case default ! String type: compare len(trim(value)) against integer bound str_len = len(trim(values(1))) read(self%bound_str, *, iostat=ios) int_bound if (ios /= 0) then - call error%init("invalid bound value", self%bound_str) + call error%init( & + with_argument_context("invalid bound value", display_name), & + self%bound_str) return end if if (str_len > int_bound) then - call error%init("string length must not be bigger than " // trim(self%bound_str), values(1)) + call error%init( & + with_argument_context( & + "string length must not be bigger than " // trim(self%bound_str), & + display_name), values(1)) return end if call args%set_string(self%dest, values(1)) diff --git a/src/fclap/fclap_formatter.f90 b/src/fclap/fclap_formatter.f90 index 69b0963..37dbf92 100644 --- a/src/fclap/fclap_formatter.f90 +++ b/src/fclap/fclap_formatter.f90 @@ -8,7 +8,7 @@ module fclap_formatter use fclap_constants, only: MAX_ARG_LEN, MAX_ACTIONS, MAX_GROUPS, & ACT_STORE, ACT_APPEND, ACT_HELP, ACT_VERSION, & - STATUS_DEPRECATED + STATUS_DEPRECATED, TYPE_STRING use fclap_actions, only: Action implicit none private @@ -181,8 +181,8 @@ function format_help_text(prog, description, epilog, actions, num_actions, & integer, intent(in) :: max_help_position character(len=:), allocatable :: help_text - character(len=:), allocatable :: line, opt_str - integer :: i, j, k, padding + character(len=:), allocatable :: line, opt_str, help_desc, choices_desc + integer :: i, j, k, padding, help_start logical :: has_positional, has_optional, in_group logical :: action_shown(MAX_ACTIONS) @@ -215,14 +215,20 @@ function format_help_text(prog, description, epilog, actions, num_actions, & do i = 1, num_actions if (actions(i)%is_positional .and. actions(i)%visible) then line = " " // actions(i)%dest - if (allocated(actions(i)%help_text)) then + help_start = len(line) + max(2, max_help_position - len(line)) + help_desc = format_action_help(actions(i)) + if (len_trim(help_desc) > 0) then padding = max(2, max_help_position - len(line)) - line = line // repeat(" ", padding) // actions(i)%help_text + line = line // repeat(" ", padding) // help_desc end if if (actions(i)%status == STATUS_DEPRECATED) then line = line // " (DEPRECATED)" end if help_text = help_text // line // new_line('A') + choices_desc = format_action_choices(actions(i)) + if (len_trim(choices_desc) > 0) then + help_text = help_text // repeat(" ", help_start) // choices_desc // new_line('A') + end if action_shown(i) = .true. end if end do @@ -255,14 +261,20 @@ function format_help_text(prog, description, epilog, actions, num_actions, & end if line = opt_str - if (allocated(actions(i)%help_text)) then + help_start = len(line) + max(2, max_help_position - len(line)) + help_desc = format_action_help(actions(i)) + if (len_trim(help_desc) > 0) then padding = max(2, max_help_position - len(line)) - line = line // repeat(" ", padding) // actions(i)%help_text + line = line // repeat(" ", padding) // help_desc end if if (actions(i)%status == STATUS_DEPRECATED) then line = line // " (DEPRECATED)" end if help_text = help_text // line // new_line('A') + choices_desc = format_action_choices(actions(i)) + if (len_trim(choices_desc) > 0) then + help_text = help_text // repeat(" ", help_start) // choices_desc // new_line('A') + end if action_shown(i) = .true. end if end do @@ -301,14 +313,20 @@ function format_help_text(prog, description, epilog, actions, num_actions, & end if line = opt_str - if (allocated(actions(i)%help_text)) then + help_start = len(line) + max(2, max_help_position - len(line)) + help_desc = format_action_help(actions(i)) + if (len_trim(help_desc) > 0) then padding = max(2, max_help_position - len(line)) - line = line // repeat(" ", padding) // actions(i)%help_text + line = line // repeat(" ", padding) // help_desc end if if (actions(i)%status == STATUS_DEPRECATED) then line = line // " (DEPRECATED)" end if help_text = help_text // line // new_line('A') + choices_desc = format_action_choices(actions(i)) + if (len_trim(choices_desc) > 0) then + help_text = help_text // repeat(" ", help_start) // choices_desc // new_line('A') + end if end if end do end if @@ -337,4 +355,60 @@ function format_help_text(prog, description, epilog, actions, num_actions, & end if end function format_help_text + !> @brief Build help text for a single action, including optional default. + !> + !> @details Returns the action help description and appends a + !> `(default: ...)` suffix when a default is set and `print_default` + !> is enabled for the action. + !> + !> @param act Action metadata used to construct help text + !> @return Final help description for this action + function format_action_help(act) result(help_desc) + type(Action), intent(in) :: act + character(len=:), allocatable :: help_desc + character(len=:), allocatable :: default_str + + help_desc = "" + if (allocated(act%help_text)) then + help_desc = act%help_text + end if + + if (act%has_default .and. act%print_default) then + default_str = act%default_value%to_string() + if (len_trim(help_desc) > 0) then + help_desc = trim(help_desc) // " " + end if + help_desc = trim(help_desc) // " (default: " // trim(default_str) // ")" + end if + end function format_action_help + + !> @brief Build formatted choices annotation for a single action. + !> + !> @details Returns an empty string unless `print_choices` is enabled and + !> the action has one or more choices. String choices are quoted while + !> non-string choices are emitted without quotes. + !> + !> @param act Action metadata containing choice values and display flags + !> @return Formatted choices text, e.g. `[choices: 'a', 'b']`, or empty + function format_action_choices(act) result(choices_desc) + type(Action), intent(in) :: act + character(len=:), allocatable :: choices_desc + integer :: i + + choices_desc = "" + if (.not. act%print_choices) return + if (act%num_choices <= 0) return + + choices_desc = "[choices: " + do i = 1, act%num_choices + if (i > 1) choices_desc = choices_desc // ", " + if (act%value_type == TYPE_STRING) then + choices_desc = choices_desc // "'" // trim(act%choices(i)) // "'" + else + choices_desc = choices_desc // trim(act%choices(i)) + end if + end do + choices_desc = choices_desc // "]" + end function format_action_choices + end module fclap_formatter diff --git a/src/fclap/fclap_namespace.f90 b/src/fclap/fclap_namespace.f90 index 32cd7da..041b9df 100644 --- a/src/fclap/fclap_namespace.f90 +++ b/src/fclap/fclap_namespace.f90 @@ -22,6 +22,7 @@ module fclap_namespace use fclap_constants, only: MAX_ACTIONS, MAX_ARG_LEN, MAX_LIST_VALUES, & TYPE_STRING, TYPE_INTEGER, TYPE_REAL, TYPE_LOGICAL + use fclap_utils_accuracy, only: wp implicit none private @@ -42,7 +43,7 @@ module fclap_namespace !> Storage for integer values integer :: integer_value = 0 !> Storage for real/float values - real :: real_value = 0.0 + real(wp) :: real_value = 0.0_wp !> Storage for logical/boolean values logical :: logical_value = .false. !> Array storage for string list values (used with append action) @@ -50,7 +51,7 @@ module fclap_namespace !> Array storage for integer list values (used with append action) integer :: integer_list(MAX_LIST_VALUES) !> Array storage for real list values (used with append action) - real :: real_list(MAX_LIST_VALUES) + real(wp) :: real_list(MAX_LIST_VALUES) !> Number of items currently stored in list arrays integer :: list_count = 0 !> Flag indicating whether a value has been explicitly set @@ -127,7 +128,7 @@ module fclap_namespace ! Private specific getter subroutines (backing the generic interface) procedure, private :: get_sub_string => namespace_get_sub_string procedure, private :: get_sub_integer => namespace_get_sub_integer - procedure, private :: get_sub_real => namespace_get_sub_real + procedure, private :: get_sub_real_wp => namespace_get_sub_real_wp procedure, private :: get_sub_logical => namespace_get_sub_logical procedure, private :: get_sub_string_list => namespace_get_sub_string_list procedure, private :: get_sub_integer_list => namespace_get_sub_integer_list @@ -136,8 +137,8 @@ module fclap_namespace !> Resolves based on the type of the output value argument. !> Supported types: character, integer, real, logical. !> Also supports list forms: call args%get(key, values_array, count) - generic :: get => get_sub_string, get_sub_integer, get_sub_real, get_sub_logical, & - get_sub_string_list, get_sub_integer_list + generic :: get => get_sub_string, get_sub_integer, get_sub_real_wp, & + get_sub_logical, get_sub_string_list, get_sub_integer_list !> @brief Retrieve a string list by key. !> @param key The argument destination name !> @param values Output array for the values @@ -187,7 +188,7 @@ end subroutine value_set_integer subroutine value_set_real(self, val) class(ValueContainer), intent(inout) :: self - real, intent(in) :: val + real(wp), intent(in) :: val self%real_value = val self%value_type = TYPE_REAL @@ -229,7 +230,7 @@ end subroutine value_append_integer subroutine value_append_real(self, val) class(ValueContainer), intent(inout) :: self - real, intent(in) :: val + real(wp), intent(in) :: val if (self%list_count < MAX_LIST_VALUES) then self%list_count = self%list_count + 1 @@ -239,6 +240,80 @@ subroutine value_append_real(self, val) end if end subroutine value_append_real + !> @brief Trim trailing zeros from a decimal string while preserving float notation. + !> + !> @details Removes trailing zeros after the decimal point. If all fractional + !> digits are removed, keeps one digit (`.0`) so the value remains clearly + !> represented as floating-point text. + !> + !> @param text Input numeric string to normalize + !> @return Normalized numeric string with trimmed fractional zeros + function trim_real_fraction(text) result(trimmed) + character(len=*), intent(in) :: text + character(len=:), allocatable :: trimmed + integer :: dot_pos, i + + trimmed = trim(text) + dot_pos = index(trimmed, ".") + if (dot_pos == 0) then + trimmed = trim(trimmed) // ".0" + return + end if + + i = len_trim(trimmed) + do while (i > dot_pos .and. trimmed(i:i) == "0") + i = i - 1 + end do + + if (i == dot_pos) then + i = dot_pos + 1 + end if + + trimmed = trimmed(:i) + end function trim_real_fraction + + !> @brief Format a real(wp) value for user-facing help/default display. + !> + !> @details Uses exponential formatting for magnitudes smaller than 1.0 + !> (except zero) and for very large magnitudes (>= 1.0e9), while using + !> fixed formatting for intermediate values. Trailing zeros are trimmed + !> while keeping at least one decimal place. + !> + !> @param value Real value to format + !> @return Formatted real string suitable for help/default text + function format_real_for_display(value) result(str) + real(wp), intent(in) :: value + character(len=:), allocatable :: str + character(len=64) :: tmp + character(len=:), allocatable :: compact + integer :: exp_pos + + if (value == 0.0_wp) then + str = "0.0" + else if (abs(value) < 1.0_wp) then + write(tmp, '(ES16.6)') value + compact = trim(adjustl(tmp)) + exp_pos = index(compact, "E") + if (exp_pos > 0) then + str = trim_real_fraction(compact(:exp_pos - 1)) // compact(exp_pos:) + else + str = trim_real_fraction(compact) + end if + else if (abs(value) < 1.0e9_wp) then + write(tmp, '(F16.6)') value + str = trim_real_fraction(trim(adjustl(tmp))) + else + write(tmp, '(ES16.6)') value + compact = trim(adjustl(tmp)) + exp_pos = index(compact, "E") + if (exp_pos > 0) then + str = trim_real_fraction(compact(:exp_pos - 1)) // compact(exp_pos:) + else + str = trim_real_fraction(compact) + end if + end if + end function format_real_for_display + function value_to_string(self) result(str) class(ValueContainer), intent(in) :: self character(len=:), allocatable :: str @@ -261,8 +336,7 @@ function value_to_string(self) result(str) write(tmp, '(I0)') self%integer_list(i) str = str // trim(tmp) case(TYPE_REAL) - write(tmp, '(G0)') self%real_list(i) - str = str // trim(tmp) + str = str // format_real_for_display(self%real_list(i)) end select end do str = str // "]" @@ -278,8 +352,7 @@ function value_to_string(self) result(str) write(tmp, '(I0)') self%integer_value str = trim(tmp) case(TYPE_REAL) - write(tmp, '(G0)') self%real_value - str = trim(tmp) + str = format_real_for_display(self%real_value) case(TYPE_LOGICAL) if (self%logical_value) then str = "True" @@ -363,7 +436,7 @@ end subroutine namespace_set_integer subroutine namespace_set_real(self, key, value) class(Namespace), intent(inout) :: self character(len=*), intent(in) :: key - real, intent(in) :: value + real(wp), intent(in) :: value integer :: idx idx = self%find_or_create(key) @@ -472,8 +545,8 @@ end function namespace_get_integer function namespace_get_real(self, key, default) result(value) class(Namespace), intent(in) :: self character(len=*), intent(in) :: key - real, intent(in), optional :: default - real :: value + real(wp), intent(in), optional :: default + real(wp) :: value integer :: idx logical :: found @@ -489,7 +562,7 @@ function namespace_get_real(self, key, default) result(value) if (present(default)) then value = default else - value = 0.0 + value = 0.0_wp end if end if end function namespace_get_real @@ -643,20 +716,20 @@ subroutine namespace_get_sub_integer(self, key, value, default) value = self%get_integer(key, default) end subroutine namespace_get_sub_integer - !> @brief Retrieve a real value by key (subroutine form for generic interface). + !> @brief Retrieve a real(wp) value by key (subroutine form for generic interface). !> !> @param self The Namespace instance !> @param key The argument destination name - !> @param value Output variable receiving the real value + !> @param value Output variable receiving the real(wp) value !> @param default Optional default if key not found - subroutine namespace_get_sub_real(self, key, value, default) + subroutine namespace_get_sub_real_wp(self, key, value, default) class(Namespace), intent(in) :: self character(len=*), intent(in) :: key - real, intent(out) :: value - real, intent(in), optional :: default + real(wp), intent(out) :: value + real(wp), intent(in), optional :: default value = self%get_real(key, default) - end subroutine namespace_get_sub_real + end subroutine namespace_get_sub_real_wp !> @brief Retrieve a logical value by key (subroutine form for generic interface). !> diff --git a/src/fclap/fclap_parser.f90 b/src/fclap/fclap_parser.f90 index 2df00fc..4adea61 100644 --- a/src/fclap/fclap_parser.f90 +++ b/src/fclap/fclap_parser.f90 @@ -22,6 +22,7 @@ module fclap_parser use fclap_constants + use fclap_utils_accuracy, only: wp use fclap_errors, only: fclap_error use fclap_namespace, only: Namespace, ValueContainer use fclap_actions, only: Action, not_less_than, not_bigger_than @@ -170,6 +171,8 @@ module fclap_parser !> @param nargs Number of arguments to consume !> @param data_type Value type (string, int, real, logical) !> @param default_val Default value if not provided + !> @param print_default Whether to show default in help text when set + !> @param print_choices Whether to show choices list in help text !> @param choices Array of valid choices !> @param required Whether argument is required !> @param help Help text for the argument @@ -388,6 +391,7 @@ subroutine parser_init(self, prog, description, epilog, add_help, version) self%num_groups = 0 self%num_mutex_groups = 0 self%num_seen_dests = 0 + call self%last_error%clear() if (self%add_help) then call self%add_argument("-h", "--help", action="help", & @@ -490,14 +494,14 @@ subroutine parser_add_argument(self, name1, name2, name3, name4, & action, nargs, data_type, default_val, & choices, required, help, metavar, dest, & status, visible, deprecated_msg, removed_msg, & - group_idx, mutex_group_idx) + group_idx, mutex_group_idx, print_default, print_choices) class(ArgumentParser), intent(inout) :: self character(len=*), intent(in) :: name1 character(len=*), intent(in), optional :: name2, name3, name4 character(len=*), intent(in), optional :: action integer, intent(in), optional :: nargs character(len=*), intent(in), optional :: data_type - character(len=*), intent(in), optional :: default_val + class(*), intent(in), optional :: default_val character(len=*), intent(in), optional :: choices(:) logical, intent(in), optional :: required character(len=*), intent(in), optional :: help @@ -509,12 +513,20 @@ subroutine parser_add_argument(self, name1, name2, name3, name4, & character(len=*), intent(in), optional :: removed_msg integer, intent(in), optional :: group_idx integer, intent(in), optional :: mutex_group_idx + logical, intent(in), optional :: print_default + logical, intent(in), optional :: print_choices character(len=MAX_ARG_LEN) :: option_strings(MAX_OPTION_STRINGS) integer :: num_options, num_choices, i, act_type, actual_nargs, actual_type + integer :: ios, default_int + real(wp) :: default_real + logical :: default_logical character(len=:), allocatable :: actual_dest + character(len=:), allocatable :: expected_type logical :: is_positional, is_required + call self%last_error%clear() + num_options = 1 option_strings = "" option_strings(1) = trim(name1) @@ -628,8 +640,97 @@ subroutine parser_add_argument(self, name1, name2, name3, name4, & if (present(metavar)) self%actions(self%num_actions)%metavar = trim(metavar) if (present(default_val)) then + expected_type = "string" + select case(actual_type) + case(TYPE_INTEGER) + expected_type = "integer" + case(TYPE_REAL) + expected_type = "real" + case(TYPE_LOGICAL) + expected_type = "logical" + end select + + select type(default_val) + type is (character(len=*)) + select case(actual_type) + case(TYPE_INTEGER) + read(default_val, *, iostat=ios) default_int + if (ios == 0) then + call self%actions(self%num_actions)%default_value%set_integer(default_int) + else + call self%last_error%init("invalid default value for argument '" // & + trim(actual_dest) // "': expected " // & + expected_type) + end if + case(TYPE_REAL) + read(default_val, *, iostat=ios) default_real + if (ios == 0) then + call self%actions(self%num_actions)%default_value%set_real(default_real) + else + call self%last_error%init("invalid default value for argument '" // & + trim(actual_dest) // "': expected " // & + expected_type) + end if + case(TYPE_LOGICAL) + read(default_val, *, iostat=ios) default_logical + if (ios == 0) then + call self%actions(self%num_actions)%default_value%set_logical(default_logical) + else + ! TODO: Add permissive logical default parsing (1/0, yes/no, on/off). + call self%last_error%init("invalid default value for argument '" // & + trim(actual_dest) // "': expected " // & + expected_type) + end if + case default + call self%actions(self%num_actions)%default_value%set_string(default_val) + end select + type is (integer) + if (actual_type == TYPE_INTEGER) then + call self%actions(self%num_actions)%default_value%set_integer(default_val) + else + call self%last_error%init("invalid default type for argument '" // & + trim(actual_dest) // "': expected " // expected_type) + end if + type is (real) + if (actual_type == TYPE_REAL) then + call self%actions(self%num_actions)%default_value%set_real(real(default_val, kind=wp)) + else + call self%last_error%init("invalid default type for argument '" // & + trim(actual_dest) // "': expected " // expected_type) + end if + type is (real(kind=wp)) + if (actual_type == TYPE_REAL) then + call self%actions(self%num_actions)%default_value%set_real(default_val) + else + call self%last_error%init("invalid default type for argument '" // & + trim(actual_dest) // "': expected " // expected_type) + end if + type is (logical) + if (actual_type == TYPE_LOGICAL) then + call self%actions(self%num_actions)%default_value%set_logical(default_val) + else + call self%last_error%init("invalid default type for argument '" // & + trim(actual_dest) // "': expected " // expected_type) + end if + class default + call self%last_error%init("unsupported default type for argument '" // & + trim(actual_dest) // "'") + end select + + if (self%last_error%has_error) then + self%num_actions = self%num_actions - 1 + return + end if + self%actions(self%num_actions)%has_default = .true. - call self%actions(self%num_actions)%default_value%set_string(default_val) + end if + + if (present(print_default)) then + self%actions(self%num_actions)%print_default = print_default + end if + + if (present(print_choices)) then + self%actions(self%num_actions)%print_choices = print_choices end if if (present(choices)) then @@ -837,10 +938,22 @@ subroutine parser_set_defaults(self, args) call args%set_integer(self%actions(i)%dest, 0) case default if (self%actions(i)%has_default) then - if (allocated(self%actions(i)%default_value%string_value)) then - call args%set_string(self%actions(i)%dest, & - self%actions(i)%default_value%string_value) - end if + select case(self%actions(i)%default_value%value_type) + case(TYPE_INTEGER) + call args%set_integer(self%actions(i)%dest, & + self%actions(i)%default_value%integer_value) + case(TYPE_REAL) + call args%set_real(self%actions(i)%dest, & + self%actions(i)%default_value%real_value) + case(TYPE_LOGICAL) + call args%set_logical(self%actions(i)%dest, & + self%actions(i)%default_value%logical_value) + case default + if (allocated(self%actions(i)%default_value%string_value)) then + call args%set_string(self%actions(i)%dest, & + self%actions(i)%default_value%string_value) + end if + end select end if end select end do diff --git a/test/unit/main.f90 b/test/unit/main.f90 index a72a580..0bcf6a6 100644 --- a/test/unit/main.f90 +++ b/test/unit/main.f90 @@ -4,6 +4,7 @@ program tester implicit none logical :: all_passed + logical, parameter :: RUN_DEFERRED_FAILURE_TESTS = .false. all_passed = .true. @@ -11,7 +12,17 @@ program tester call test_store_true_false(all_passed) call test_count_action(all_passed) call test_default_values(all_passed) + call test_default_roundtrip_types(all_passed) call test_help_generation(all_passed) + call test_help_default_display(all_passed) + call test_help_choices_display(all_passed) + call test_print_default_matrix(all_passed) + call test_print_choices_matrix(all_passed) + call test_choices_format_by_type(all_passed) + call test_real_default_format_edges(all_passed) + call test_wp_real_precision_display(all_passed) + call test_mismatched_default_rejected(all_passed) + call test_rejected_default_does_not_leak(all_passed) call test_type_conversion(all_passed) call test_append(all_passed) call test_nargs(all_passed) @@ -22,6 +33,29 @@ program tester call test_argument_groups(all_passed) call test_subparsers(all_passed) + !> The following tests have to be activated/revised when the error handling of + !> fclap was revised; right now there are hard exits which do not work with + !> the current test infrastructure + if (RUN_DEFERRED_FAILURE_TESTS) then + call test_fail_missing_required_positional(all_passed) + call test_fail_missing_required_optional(all_passed) + call test_fail_option_missing_value(all_passed) + call test_fail_invalid_int_value(all_passed) + call test_fail_invalid_real_value(all_passed) + call test_fail_invalid_logical_value(all_passed) + call test_fail_invalid_choice_string(all_passed) + call test_fail_invalid_choice_integer(all_passed) + call test_fail_mutex_conflict(all_passed) + call test_fail_required_mutex_missing(all_passed) + call test_fail_not_less_than_violation(all_passed) + call test_fail_not_bigger_than_violation(all_passed) + call test_fail_unknown_option(all_passed) + call test_fail_extra_positional(all_passed) + call test_fail_unknown_subcommand(all_passed) + call test_fail_removed_argument_used(all_passed) + call test_fail_append_missing_value(all_passed) + end if + if (all_passed) then print *, "" print *, "========================================" @@ -135,11 +169,20 @@ subroutine test_default_values(passed) type(Namespace) :: args character(len=256) :: test_args(1) character(len=256) :: tmp_output + integer :: tmp_retries + real(wp) :: tmp_threshold + logical :: tmp_enabled print *, "Test: default values..." call parser%init(prog="test_prog", add_help=.false.) call parser%add_argument("-o", "--output", default_val="default.txt", help="Output file") + call parser%add_argument("-r", "--retries", data_type="int", default_val=3, & + help="Number of retries") + call parser%add_argument("-t", "--threshold", data_type="float", default_val=1.25_wp, & + help="Threshold value") + call parser%add_argument("-e", "--enabled", data_type="bool", default_val=.true., & + help="Enable feature") call parser%add_argument("input", help="Input file") test_args(1) = "input.txt" @@ -147,15 +190,78 @@ subroutine test_default_values(passed) args = parser%parse_args_array(test_args) call args%get("output", tmp_output) + call args%get("retries", tmp_retries) + call args%get("threshold", tmp_threshold) + call args%get("enabled", tmp_enabled) if (tmp_output /= "default.txt") then print *, " FAILED: output should be 'default.txt', got: ", trim(tmp_output) passed = .false. + else if (tmp_retries /= 3) then + print *, " FAILED: retries should be 3, got: ", tmp_retries + passed = .false. + else if (abs(tmp_threshold - 1.25_wp) > 0.01_wp) then + print *, " FAILED: threshold should be ~1.25, got: ", tmp_threshold + passed = .false. + else if (.not. tmp_enabled) then + print *, " FAILED: enabled should be .true." + passed = .false. else print *, " PASSED" end if end subroutine test_default_values + !> Verifies default values roundtrip correctly for all scalar types. + subroutine test_default_roundtrip_types(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=64) :: expected_name, got_name + integer :: expected_count, got_count + real(wp) :: expected_temp, got_temp + logical :: expected_enabled, got_enabled + character(len=1) :: empty_args(1) + + print *, "Test: default roundtrip for all types..." + + expected_name = "cpcm" + expected_count = 7 + expected_temp = 298.15_wp + expected_enabled = .true. + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--name", default_val=expected_name, help="Name") + call parser%add_argument("--count", data_type="int", default_val=expected_count, & + help="Count") + call parser%add_argument("--temp", data_type="real", default_val=expected_temp, & + help="Temperature") + call parser%add_argument("--enabled", data_type="bool", default_val=expected_enabled, & + help="Enabled") + + args = parser%parse_args_array(empty_args(1:0)) + + call args%get("name", got_name) + call args%get("count", got_count) + call args%get("temp", got_temp) + call args%get("enabled", got_enabled) + + if (trim(got_name) /= trim(expected_name)) then + print *, " FAILED: string default mismatch" + passed = .false. + else if (got_count /= expected_count) then + print *, " FAILED: integer default mismatch" + passed = .false. + else if (abs(got_temp - expected_temp) > 1.0e-12_wp) then + print *, " FAILED: real default mismatch" + passed = .false. + else if (got_enabled .neqv. expected_enabled) then + print *, " FAILED: logical default mismatch" + passed = .false. + else + print *, " PASSED" + end if + end subroutine test_default_roundtrip_types + subroutine test_help_generation(passed) logical, intent(inout) :: passed type(ArgumentParser) :: parser @@ -184,13 +290,365 @@ subroutine test_help_generation(passed) end if end subroutine test_help_generation + !> Solvation inspired test for checking that default values are displayed in help + !> with correct formatting and that print_default toggle works + subroutine test_help_default_display(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + character(len=:), allocatable :: help_text + + print *, "Test: help default display toggle..." + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--retries", data_type="int", default_val=3, & + help="Number of retries") + call parser%add_argument("--proj-tol", data_type="real", default_val=1.0d-10, & + action=not_less_than(0.0d0), metavar="REAL", & + help="Projection convergence tolerance") + call parser%add_argument("--ratio", data_type="real", default_val=0.25, & + help="Ratio") + call parser%add_argument("--scale", data_type="real", default_val=3.0, & + help="Scale") + call parser%add_argument("--token", default_val="secret", print_default=.false., & + help="Auth token") + call parser%add_argument("--plain", help="No default here") + + help_text = parser%format_help() + + if (index(help_text, "default: 3") == 0) then + print *, " FAILED: help should contain integer default" + passed = .false. + else if (index(help_text, "Projection convergence tolerance (default: 1.0E-10)") == 0) then + print *, " FAILED: help should contain proj-tol default" + passed = .false. + else if (index(help_text, "Ratio (default: 2.5E-01)") == 0) then + print *, " FAILED: help should format <1 real defaults exponentially" + passed = .false. + else if (index(help_text, "Scale (default: 3.0)") == 0) then + print *, " FAILED: help should format >=1 real defaults in fixed format" + passed = .false. + else if (index(help_text, "default: 'secret'") > 0) then + print *, " FAILED: help should hide default when print_default=.false." + passed = .false. + else + print *, " PASSED" + end if + end subroutine test_help_default_display + + subroutine test_help_choices_display(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + character(len=:), allocatable :: help_text + character(len=:), allocatable :: expected_choices_line + integer :: help_col + + print *, "Test: help choices display toggle..." + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--radii", default_val="cpcm", & + choices=[character(len=5) :: "cpcm", "smd", "d3", "cosmo", "bondi"], & + metavar="RADII", help="Atomic radii set", print_choices=.true.) + call parser%add_argument("--method", default_val="r2scan", & + choices=[character(len=6) :: "r2scan", "pbe"], & + help="Method") + + help_text = parser%format_help() + help_col = parser%max_help_position + expected_choices_line = new_line('A') // repeat(" ", help_col) // & + "[choices: 'cpcm', 'smd', 'd3', 'cosmo', 'bondi']" + + if (index(help_text, "Atomic radii set") == 0) then + print *, " FAILED: help should contain radii help text" + passed = .false. + else if (index(help_text, expected_choices_line) == 0) then + print *, " FAILED: choices should be on aligned new line" + passed = .false. + else if (index(help_text, "[choices: r2scan, pbe]") > 0) then + print *, " FAILED: choices should be hidden by default" + passed = .false. + else + print *, " PASSED" + end if + end subroutine test_help_choices_display + + !> Verifies print_default combinations (shown, hidden, missing default). + subroutine test_print_default_matrix(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + character(len=:), allocatable :: help_text + + print *, "Test: print_default matrix..." + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--shown", data_type="int", default_val=11, print_default=.true., & + help="Shown default") + call parser%add_argument("--hidden", data_type="int", default_val=22, print_default=.false., & + help="Hidden default") + call parser%add_argument("--nodef", print_default=.true., & + help="No default value") + + help_text = parser%format_help() + + if (index(help_text, "Shown default (default: 11)") == 0) then + print *, " FAILED: shown default should be printed" + passed = .false. + else if (index(help_text, "Hidden default (default: 22)") > 0) then + print *, " FAILED: hidden default should not be printed" + passed = .false. + else if (index(help_text, "No default value (default:") > 0) then + print *, " FAILED: default should not be printed when unset" + passed = .false. + else + print *, " PASSED" + end if + end subroutine test_print_default_matrix + + !> Verifies print_choices combinations (shown, hidden, and empty choices). + subroutine test_print_choices_matrix(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + character(len=:), allocatable :: help_text + + print *, "Test: print_choices matrix..." + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--shown", choices=[character(len=2) :: "a", "b"], & + print_choices=.true., help="Shown choices") + call parser%add_argument("--hidden", choices=[character(len=2) :: "c", "d"], & + help="Hidden choices") + call parser%add_argument("--empty", print_choices=.true., help="Empty choices") + + help_text = parser%format_help() + + if (index(help_text, "[choices: 'a', 'b']") == 0) then + print *, " FAILED: shown choices should be printed" + passed = .false. + else if (index(help_text, "[choices: 'c', 'd']") > 0) then + print *, " FAILED: hidden choices should not be printed" + passed = .false. + else if (index(help_text, "[choices: ]") > 0) then + print *, " FAILED: empty choices should not print placeholder" + passed = .false. + else + print *, " PASSED" + end if + end subroutine test_print_choices_matrix + + !> Verifies string choices are quoted while numeric choices are unquoted. + subroutine test_choices_format_by_type(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + character(len=:), allocatable :: help_text + + print *, "Test: choices formatting by type..." + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--color", choices=[character(len=4) :: "red", "blue"], & + print_choices=.true., help="Color") + call parser%add_argument("--level", data_type="int", & + choices=[character(len=1) :: "1", "2"], & + print_choices=.true., help="Level") + + help_text = parser%format_help() + + if (index(help_text, "[choices: 'red', 'blue']") == 0) then + print *, " FAILED: string choices should be quoted" + passed = .false. + else if (index(help_text, "[choices: 1, 2]") == 0) then + print *, " FAILED: integer choices should be unquoted" + passed = .false. + else + print *, " PASSED" + end if + end subroutine test_choices_format_by_type + + !> Verifies edge-case formatting for real defaults. + subroutine test_real_default_format_edges(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + character(len=:), allocatable :: help_text + + print *, "Test: real default formatting edges..." + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--zero", data_type="real", default_val=0.0_wp, help="Zero") + call parser%add_argument("--one", data_type="real", default_val=1.0_wp, help="One") + call parser%add_argument("--small", data_type="real", default_val=0.25_wp, help="Small") + call parser%add_argument("--neg-small", data_type="real", default_val=-0.25_wp, & + help="Neg small") + call parser%add_argument("--trim", data_type="real", default_val=3.140000_wp, help="Trim") + call parser%add_argument("--huge", data_type="real", default_val=1.0e9_wp, help="Huge") + + help_text = parser%format_help() + + if (index(help_text, "Zero (default: 0.0)") == 0) then + print *, " FAILED: zero should render as 0.0" + passed = .false. + else if (index(help_text, "One (default: 1.0)") == 0) then + print *, " FAILED: one should render as 1.0" + passed = .false. + else if (index(help_text, "Small (default: 2.5E-01)") == 0) then + print *, " FAILED: small should render in exponential format" + passed = .false. + else if (index(help_text, "Neg small (default: -2.5E-01)") == 0) then + print *, " FAILED: negative small should render in exponential format" + passed = .false. + else if (index(help_text, "Trim (default: 3.14)") == 0) then + print *, " FAILED: fixed format should trim trailing zeros" + passed = .false. + else if (index(help_text, "Huge (default: 1.0E+09)") == 0) then + print *, " FAILED: very large values should render in exponential format" + passed = .false. + else + print *, " PASSED" + end if + end subroutine test_real_default_format_edges + + !> Verifies wp defaults avoid single-precision display artifacts. + subroutine test_wp_real_precision_display(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: empty_args(1) + character(len=:), allocatable :: help_text + real(wp) :: tmp_temp + + print *, "Test: wp real precision display..." + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--temp", data_type="real", default_val=298.15_wp, & + help="Temperature") + + help_text = parser%format_help() + args = parser%parse_args_array(empty_args(1:0)) + call args%get("temp", tmp_temp) + + if (index(help_text, "Temperature (default: 298.15)") == 0) then + print *, " FAILED: expected clean wp default in help" + passed = .false. + else if (index(help_text, "298.149994") > 0) then + print *, " FAILED: should not show single-precision artifacts" + passed = .false. + else if (abs(tmp_temp - 298.15_wp) > 1.0e-12_wp) then + print *, " FAILED: parsed wp default should preserve precision" + passed = .false. + else + print *, " PASSED" + end if + end subroutine test_wp_real_precision_display + + !> Verifies mismatched defaults are rejected with parser errors. + subroutine test_mismatched_default_rejected(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + character(len=:), allocatable :: err + + print *, "Test: mismatched default rejection..." + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--ival", data_type="int", default_val=1.5_wp, help="Integer") + if (.not. parser%last_error%has_error) then + print *, " FAILED: int argument should reject real default" + passed = .false. + return + end if + err = parser%last_error%message + if (index(err, "invalid default") == 0) then + print *, " FAILED: rejection should set default-related parser error" + passed = .false. + return + end if + if (parser%num_actions /= 0) then + print *, " FAILED: rejected argument should not be registered" + passed = .false. + return + end if + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--rval", data_type="real", default_val=2, help="Real") + if (.not. parser%last_error%has_error) then + print *, " FAILED: real argument should reject integer default" + passed = .false. + return + end if + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--flag", data_type="bool", default_val=1, help="Flag") + if (.not. parser%last_error%has_error) then + print *, " FAILED: logical argument should reject integer default" + passed = .false. + return + end if + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--count", data_type="int", default_val="abc", help="Count") + if (.not. parser%last_error%has_error) then + print *, " FAILED: int argument should reject non-numeric string default" + passed = .false. + return + end if + + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--valid", data_type="real", default_val=2.5_wp, help="Valid") + if (parser%last_error%has_error) then + print *, " FAILED: valid default should not set parser error" + passed = .false. + return + end if + + print *, " PASSED" + end subroutine test_mismatched_default_rejected + + !> Regression: rejecting an invalid default must not leave stale default state: + !> A failed add should not affect the next argument slot + !> In particular, help text for a clean argument must not show a leaked default + subroutine test_rejected_default_does_not_leak(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + character(len=:), allocatable :: help_text + + print *, "Test: rejected default does not leak..." + + call parser%init(prog="test_prog", add_help=.false.) + + call parser%add_argument("--bad", data_type="int", default_val=1.5_wp, & + help="Bad argument") + if (.not. parser%last_error%has_error) then + print *, " FAILED: setup should reject invalid default" + passed = .false. + return + end if + + if (parser%num_actions /= 0) then + print *, " FAILED: rejected argument should not remain registered" + passed = .false. + return + end if + + call parser%add_argument("--clean", help="Should stay clean") + if (parser%last_error%has_error) then + print *, " FAILED: valid follow-up argument should not fail" + passed = .false. + return + end if + + help_text = parser%format_help() + if (index(help_text, "Should stay clean (default:") > 0) then + print *, " FAILED: default state leaked from rejected argument" + passed = .false. + return + end if + + print *, " PASSED" + end subroutine test_rejected_default_does_not_leak + subroutine test_type_conversion(passed) logical, intent(inout) :: passed type(ArgumentParser) :: parser type(Namespace) :: args character(len=256) :: test_args(4) integer :: tmp_number - real :: tmp_factor + real(wp) :: tmp_factor print *, "Test: type conversion (integer, real)..." @@ -211,7 +669,7 @@ subroutine test_type_conversion(passed) if (tmp_number /= 42) then print *, " FAILED: number should be 42, got: ", tmp_number passed = .false. - else if (abs(tmp_factor - 3.14) > 0.01) then + else if (abs(tmp_factor - 3.14_wp) > 0.01_wp) then print *, " FAILED: factor should be ~3.14, got: ", tmp_factor passed = .false. else @@ -528,4 +986,293 @@ subroutine test_subparsers(passed) print *, " PASSED" end subroutine test_subparsers + !* ================================================================================ *! + !* The following tests have to be added when the *! + !* error handling in fclap is revised so there is *! + !* no hard exit anymore *! + !* ================================================================================ *! + + !> Helper check for deferred expected-failure tests. + subroutine assert_expected_failure(parser, passed, context) + type(ArgumentParser), intent(in) :: parser + logical, intent(inout) :: passed + character(len=*), intent(in) :: context + + if (.not. parser%last_error%has_error) then + print *, " FAILED: expected parser error for ", trim(context) + passed = .false. + else + print *, " PASSED" + end if + end subroutine assert_expected_failure + + !> Fails when required positional argument is missing. + subroutine test_fail_missing_required_positional(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=1) :: empty_args(1) + + print *, "Deferred fail test: missing required positional..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("input", help="Input") + args = parser%parse_args_array(empty_args(1:0)) + call assert_expected_failure(parser, passed, "missing required positional") + end subroutine test_fail_missing_required_positional + + !> Fails when required optional argument is missing. + subroutine test_fail_missing_required_optional(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=1) :: empty_args(1) + + print *, "Deferred fail test: missing required optional..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--output", required=.true., help="Output") + args = parser%parse_args_array(empty_args(1:0)) + call assert_expected_failure(parser, passed, "missing required optional") + end subroutine test_fail_missing_required_optional + + !> Fails when option value is missing. + subroutine test_fail_option_missing_value(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(1) + + print *, "Deferred fail test: option missing value..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--output", help="Output") + bad_args(1) = "--output" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "option missing value") + end subroutine test_fail_option_missing_value + + !> Fails when integer argument receives non-integer input. + subroutine test_fail_invalid_int_value(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(2) + + print *, "Deferred fail test: invalid integer value..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--n", data_type="int", help="Count") + bad_args(1) = "--n" + bad_args(2) = "abc" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "invalid integer value") + end subroutine test_fail_invalid_int_value + + !> Fails when real argument receives non-real input. + subroutine test_fail_invalid_real_value(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(2) + + print *, "Deferred fail test: invalid real value..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--x", data_type="real", help="Real value") + bad_args(1) = "--x" + bad_args(2) = "abc" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "invalid real value") + end subroutine test_fail_invalid_real_value + + !> Fails when logical argument receives invalid token. + subroutine test_fail_invalid_logical_value(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(2) + + print *, "Deferred fail test: invalid logical value..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--flag", data_type="bool", help="Flag") + bad_args(1) = "--flag" + bad_args(2) = "maybe" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "invalid logical value") + end subroutine test_fail_invalid_logical_value + + !> Fails when string argument receives disallowed choice. + subroutine test_fail_invalid_choice_string(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(2) + + print *, "Deferred fail test: invalid string choice..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--radii", choices=[character(len=5) :: "cpcm", "bondi"], help="Radii") + bad_args(1) = "--radii" + bad_args(2) = "smd" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "invalid string choice") + end subroutine test_fail_invalid_choice_string + + !> Fails when integer argument receives value outside allowed choices. + subroutine test_fail_invalid_choice_integer(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(2) + + print *, "Deferred fail test: invalid integer choice..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--level", data_type="int", & + choices=[character(len=1) :: "1", "2"], help="Level") + bad_args(1) = "--level" + bad_args(2) = "3" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "invalid integer choice") + end subroutine test_fail_invalid_choice_integer + + !> Fails when both options in mutex group are provided. + subroutine test_fail_mutex_conflict(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(2) + integer :: mutex_idx + + print *, "Deferred fail test: mutex conflict..." + call parser%init(prog="test_prog", add_help=.false.) + mutex_idx = parser%add_mutually_exclusive_group(required=.false.) + call parser%add_argument("--foo", action="store_true", mutex_group_idx=mutex_idx) + call parser%add_argument("--bar", action="store_true", mutex_group_idx=mutex_idx) + bad_args(1) = "--foo" + bad_args(2) = "--bar" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "mutex conflict") + end subroutine test_fail_mutex_conflict + + !> Fails when required mutex group has no selected option. + subroutine test_fail_required_mutex_missing(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=1) :: empty_args(1) + integer :: mutex_idx + + print *, "Deferred fail test: required mutex missing..." + call parser%init(prog="test_prog", add_help=.false.) + mutex_idx = parser%add_mutually_exclusive_group(required=.true.) + call parser%add_argument("--foo", action="store_true", mutex_group_idx=mutex_idx) + call parser%add_argument("--bar", action="store_true", mutex_group_idx=mutex_idx) + args = parser%parse_args_array(empty_args(1:0)) + call assert_expected_failure(parser, passed, "required mutex missing") + end subroutine test_fail_required_mutex_missing + + !> Fails when value violates not_less_than bound. + subroutine test_fail_not_less_than_violation(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(2) + + print *, "Deferred fail test: not_less_than violation..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--x", data_type="real", action=not_less_than(0.0_wp)) + bad_args(1) = "--x" + bad_args(2) = "-0.1" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "not_less_than violation") + end subroutine test_fail_not_less_than_violation + + !> Fails when value violates not_bigger_than bound. + subroutine test_fail_not_bigger_than_violation(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(2) + + print *, "Deferred fail test: not_bigger_than violation..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--x", data_type="real", action=not_bigger_than(1.0_wp)) + bad_args(1) = "--x" + bad_args(2) = "1.1" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "not_bigger_than violation") + end subroutine test_fail_not_bigger_than_violation + + !> Fails when unknown option is provided. + subroutine test_fail_unknown_option(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(1) + + print *, "Deferred fail test: unknown option..." + call parser%init(prog="test_prog", add_help=.false.) + bad_args(1) = "--does-not-exist" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "unknown option") + end subroutine test_fail_unknown_option + + !> Fails when too many positional arguments are provided. + subroutine test_fail_extra_positional(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(2) + + print *, "Deferred fail test: extra positional argument..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("input", help="Input") + bad_args(1) = "a" + bad_args(2) = "b" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "extra positional argument") + end subroutine test_fail_extra_positional + + !> Fails when unknown subcommand is used. + subroutine test_fail_unknown_subcommand(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser, sub_a + type(Namespace) :: args + character(len=256) :: bad_args(1) + + print *, "Deferred fail test: unknown subcommand..." + call parser%init(prog="PROG", add_help=.false.) + call parser%add_subparsers(dest="command") + call sub_a%init(prog="PROG a", add_help=.false.) + call parser%add_parser("a", sub_a, help_text="a help") + bad_args(1) = "z" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "unknown subcommand") + end subroutine test_fail_unknown_subcommand + + !> Fails when removed argument is used. + subroutine test_fail_removed_argument_used(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(1) + + print *, "Deferred fail test: removed argument used..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("--old", action="store_true", status=STATUS_REMOVED) + bad_args(1) = "--old" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "removed argument used") + end subroutine test_fail_removed_argument_used + + !> Fails when append action is missing its value. + subroutine test_fail_append_missing_value(passed) + logical, intent(inout) :: passed + type(ArgumentParser) :: parser + type(Namespace) :: args + character(len=256) :: bad_args(1) + + print *, "Deferred fail test: append missing value..." + call parser%init(prog="test_prog", add_help=.false.) + call parser%add_argument("-f", action="append", help="File") + bad_args(1) = "-f" + args = parser%parse_args_array(bad_args) + call assert_expected_failure(parser, passed, "append missing value") + end subroutine test_fail_append_missing_value + end program tester