watch_cli.f90 Source File


This file depends on

sourcefile~~watch_cli.f90~~EfferentGraph sourcefile~watch_cli.f90 watch_cli.f90 sourcefile~watch_cmdline.f90 watch_cmdline.f90 sourcefile~watch_cli.f90->sourcefile~watch_cmdline.f90 sourcefile~watch_cmdsplice.f90 watch_cmdsplice.f90 sourcefile~watch_cli.f90->sourcefile~watch_cmdsplice.f90 sourcefile~watch_config.f90 watch_config.f90 sourcefile~watch_cli.f90->sourcefile~watch_config.f90 sourcefile~watch_types.f90 watch_types.f90 sourcefile~watch_cli.f90->sourcefile~watch_types.f90 sourcefile~watch_config.f90->sourcefile~watch_types.f90

Source Code

!> Command-line parsing and configuration assembly.
!!
!! This module:
!! - Applies built-in defaults (`set_watch_defaults`),
!! - Applies project-level overrides from `fpm.toml` (`apply_watch_from_manifest`),
!! - Parses `--watch-*` options and `fpm` subcommand arguments,
!! - Produces a `watch_config_t` that drives the watcher engine.
!!
!! Parsing is deliberately simple and "argv-token based": it does not implement
!! a full shell parser. The `fpm_cmdline` string is built for display and for
!! execution via `execute_command_line` / `fpm_filesystem:run`.
module watch_cli
   use, intrinsic :: iso_fortran_env, only: error_unit
   use watch_types,  only: watch_config_t, watch_opts_t
   use watch_config, only: set_watch_defaults, apply_watch_from_manifest, normalize_watch_opts, push_feature
   use watch_cmdline, only: get_arg, join_argv, parse_int, parse_real
   use fpm_strings,  only: string_t
   use fpm_command_line, only: fpm_build_settings, fpm_run_settings, fpm_test_settings
   use face, only: colorize
   implicit none
   private
   public parse_cli_config

contains

   !> Parse CLI arguments and build the full watcher configuration.
   !!
   !! Produces a `watch_config_t`:
   !! - `cfg%w` contains watcher options (polling, debounce, flags, etc.)
   !! - `cfg%settings` holds the typed `fpm` settings for the selected subcommand
   !! - `cfg%fpm_cmdline` is the effective command string
   !! - `cfg%cmd_prefix` and `cfg%cmd_rest` are helper fields used to inject
   !!   target names into `run`/`test` commands.
   subroutine parse_cli_config(cfg)
      use watch_cmdsplice, only: split_cmd_after_subcmd
      type(watch_config_t), intent(out) :: cfg

      character(len=:), allocatable :: fpm_cmdline
      class(fpm_build_settings), allocatable :: settings
      character(len=:), allocatable :: subcmd

      call parse_cli(cfg%w, settings, fpm_cmdline, subcmd)

      cfg%fpm_cmdline = fpm_cmdline
      cfg%subcmd      = subcmd
      call move_alloc(settings, cfg%settings)

      call split_cmd_after_subcmd(cfg%fpm_cmdline, cfg%cmd_prefix, cfg%cmd_rest)
   end subroutine parse_cli_config

   !> Parse the complete CLI and produce watcher options + typed `fpm` settings.
   !!
   !! Parsing order:
   !! 1. Initialize watcher options from defaults and `fpm.toml`.
   !! 2. Consume leading `--watch-*` options and verbosity flags (`-q`, `-v`).
   !! 3. Identify the `fpm` subcommand (`build|test|run`).
   !! 4. Parse selected `fpm` settings used to build the model (profile, flags,
   !!    compilers, build-dir, features, and run/test names).
   !!
   !! All arguments after the subcommand are also preserved in `fpm_cmdline`.
   subroutine parse_cli(w, settings, fpm_cmdline, subcmd)
      type(watch_opts_t), intent(out) :: w
      class(fpm_build_settings), allocatable, intent(out) :: settings
      character(len=:), allocatable, intent(out) :: fpm_cmdline
      character(len=:), allocatable, intent(out) :: subcmd

      integer :: narg, i, subcmd_pos
      character(len=:), allocatable :: a
      character(len=:), allocatable :: profile, build_dir
      character(len=:), allocatable :: compiler, c_compiler, cxx_compiler, archiver
      character(len=:), allocatable :: flag, cflag, cxxflag, ldflag
      character(len=:), allocatable :: runner
      type(string_t), allocatable :: features(:)
      type(string_t), allocatable :: names(:)
      logical :: prune, example

      call set_watch_defaults(w)
      call apply_watch_from_manifest(w)

      prune   = .true.
      example = .false.

      profile = ""
      build_dir = default_build_dir()
      compiler = ""
      c_compiler = ""
      cxx_compiler = ""
      archiver = ""
      flag = ""
      cflag = ""
      cxxflag = ""
      ldflag = ""
      runner = ""

      allocate(features(0))
      allocate(names(0))

      narg = command_argument_count()
      if (narg < 1) then
         call usage_and_stop("missing fpm subcommand (build/test/run)")
      end if

      a = get_arg(1)
      if (a == "--help" .or. a == "-h") then
         call usage_and_stop_ok()
      end if

      i = 1
      do while (i <= narg)
         a = get_arg(i)

         if (is_watch_flag(a)) then
            call apply_watch_flag(a, i, narg, w)
            i = i + 1
            cycle
         end if

         if (a == "-q") then
            w%verbosity = -1
            i = i + 1
            cycle
         end if
         if (a == "-v") then
            w%verbosity = max(w%verbosity, 1)
            i = i + 1
            cycle
         end if
         if (a == "-vv") then
            w%verbosity = max(w%verbosity, 2)
            i = i + 1
            cycle
         end if

         exit
      end do

      if (i > narg) call usage_and_stop("missing fpm subcommand (build/test/run)")

      a = get_arg(i)
      if (a == "--help" .or. a == "-h") then
         call usage_and_stop_ok()
      end if

      subcmd_pos = i
      subcmd = get_arg(subcmd_pos)

      if (.not. is_supported_subcmd(subcmd)) then
         call usage_and_stop("unsupported subcommand: " // trim(subcmd) // " (supported: build,test,run)")
      end if

      if (subcmd_pos < narg) then
         fpm_cmdline = "fpm " // trim(subcmd) // " " // join_argv(subcmd_pos+1, narg)
      else
         fpm_cmdline = "fpm " // trim(subcmd)
      end if

      i = subcmd_pos + 1
      do while (i <= narg)
         a = get_arg(i)

         if (a == "--") exit

         select case (a)
          case ("--profile")
            if (i+1 > narg) call usage_and_stop("--profile requires a value")
            profile = get_arg(i+1)
            i = i + 2
            cycle
          case ("--features")
            if (i+1 > narg) call usage_and_stop("--features requires a value")
            call add_features_csv(features, get_arg(i+1))
            i = i + 2
            cycle
          case ("--build-dir")
            if (i+1 > narg) call usage_and_stop("--build-dir requires a value")
            build_dir = get_arg(i+1)
            i = i + 2
            cycle
          case ("--compiler")
            if (i+1 > narg) call usage_and_stop("--compiler requires a value")
            compiler = get_arg(i+1)
            i = i + 2
            cycle
          case ("--c-compiler")
            if (i+1 > narg) call usage_and_stop("--c-compiler requires a value")
            c_compiler = get_arg(i+1)
            i = i + 2
            cycle
          case ("--cxx-compiler")
            if (i+1 > narg) call usage_and_stop("--cxx-compiler requires a value")
            cxx_compiler = get_arg(i+1)
            i = i + 2
            cycle
          case ("--archiver")
            if (i+1 > narg) call usage_and_stop("--archiver requires a value")
            archiver = get_arg(i+1)
            i = i + 2
            cycle
          case ("--flag")
            if (i+1 > narg) call usage_and_stop("--flag requires a value")
            flag = get_arg(i+1)
            i = i + 2
            cycle
          case ("--c-flag")
            if (i+1 > narg) call usage_and_stop("--c-flag requires a value")
            cflag = get_arg(i+1)
            i = i + 2
            cycle
          case ("--cxx-flag")
            if (i+1 > narg) call usage_and_stop("--cxx-flag requires a value")
            cxxflag = get_arg(i+1)
            i = i + 2
            cycle
          case ("--link-flag")
            if (i+1 > narg) call usage_and_stop("--link-flag requires a value")
            ldflag = get_arg(i+1)
            i = i + 2
            cycle
          case ("--runner")
            if (i+1 > narg) call usage_and_stop("--runner requires a value")
            runner = get_arg(i+1)
            i = i + 2
            cycle
          case ("--prune")
            prune = .true.
            i = i + 1
            cycle
          case ("--no-prune")
            prune = .false.
            i = i + 1
            cycle
          case ("--example")
            example = .true.
            i = i + 1
            cycle
          case default
            if (trim(subcmd) == "run" .or. trim(subcmd) == "test") then
               if (len_trim(a) > 0) then
                  if (a(1:1) /= "-") call push_feature(names, a)
               end if
            end if
            i = i + 1
            cycle
         end select
      end do

      select case (trim(subcmd))
       case ("build")
         allocate(fpm_build_settings :: settings)
         call init_build_settings(settings, build_dir, prune, profile, features, compiler, c_compiler, cxx_compiler, archiver, flag, cflag, cxxflag, ldflag)
       case ("test")
         allocate(fpm_test_settings :: settings)
         call init_build_settings(settings, build_dir, prune, profile, features, compiler, c_compiler, cxx_compiler, archiver, flag, cflag, cxxflag, ldflag)
         select type (s => settings)
          type is (fpm_test_settings)
            s%build_tests = .true.
            if (len_trim(runner) > 0) s%runner = trim(runner)
            call set_names(s, names)
         end select
       case ("run")
         allocate(fpm_run_settings :: settings)
         call init_build_settings(settings, build_dir, prune, profile, features, compiler, c_compiler, cxx_compiler, archiver, flag, cflag, cxxflag, ldflag)
         select type (s => settings)
          type is (fpm_run_settings)
            s%example = example
            if (len_trim(runner) > 0) s%runner = trim(runner)
            call set_names(s, names)
         end select
       case default
         call usage_and_stop("unsupported subcommand: " // trim(subcmd) // " (supported: build,test,run)")
      end select

      call normalize_watch_opts(w)
   end subroutine parse_cli

   !> Return whether a given subcommand token is supported by `fpm-watch`.
   pure logical function is_supported_subcmd(s) result(ok)
      character(len=*), intent(in) :: s
      ok = (trim(s) == "build") .or. (trim(s) == "test") .or. (trim(s) == "run")
   end function is_supported_subcmd

   !> Detect whether an argv token is a watcher-specific flag.
   !!
   !! Watcher flags are of the form `--watch-*` and are consumed by `fpm-watch`
   !! itself rather than forwarded to `fpm`.
   pure logical function is_watch_flag(a) result(ok)
      character(len=*), intent(in) :: a
      ok = (len_trim(a) >= 8 .and. a(1:8) == "--watch-")
   end function is_watch_flag

   !> Apply a single `--watch-*` flag to the watcher options.
   !!
   !! Supports both `--flag=value` and `--flag value` styles for flags that take
   !! a value.
   !!
   !! This routine is called while scanning the argv prefix before the `fpm`
   !! subcommand token.
   subroutine apply_watch_flag(a, i, narg, w)
      character(len=*), intent(in) :: a
      integer, intent(inout) :: i
      integer, intent(in) :: narg
      type(watch_opts_t), intent(inout) :: w

      character(len=:), allocatable :: key, val
      integer :: eq

      key = trim(a)
      val = ""

      eq = index(key, "=")
      if (eq > 0) then
         val = key(eq+1:)
         key = key(1:eq-1)
      end if

      select case (key)
       case ("--watch-child")
         continue
       case ("--watch-auto-restart")
         continue
       case ("--watch-restart-delay")
         if (len_trim(val) == 0) then
            if (i+1 <= narg) i = i + 1
         end if
       case ("--watch-restart-max")
         if (len_trim(val) == 0) then
            if (i+1 <= narg) i = i + 1
         end if
       case ("--watch-self")
         if (len_trim(val) == 0) then
            if (i+1 <= narg) i = i + 1
         end if

       case ("--watch-deps")
         if (len_trim(val) > 0) then
            w%watch_deps = parse_bool(val, .true.)
         else
            w%watch_deps = .true.
         end if
       case ("--watch-no-deps")
         w%watch_deps = .false.

       case ("--watch-low-cpu")
         if (len_trim(val) > 0) then
            w%low_cpu = parse_bool(val, .true.)
         else
            w%low_cpu = .true.
         end if
       case ("--watch-no-low-cpu")
         w%low_cpu = .false.

       case ("--watch-quiet")
         w%verbosity = -1
       case ("--watch-verbose")
         if (len_trim(val) > 0) then
            w%verbosity = max(w%verbosity, parse_int(val, 1))
         else
            w%verbosity = max(w%verbosity, 1)
         end if
       case ("--watch-very-verbose")
         w%verbosity = max(w%verbosity, 2)
       case ("--watch-debug")
         w%debug = .true.
       case ("--watch-print-files")
         w%print_files_once = .true.
         w%verbosity = max(w%verbosity, 2)
       case ("--watch-silent-fpm")
         w%silent_fpm = .true.
       case ("--watch-run-on-start")
         w%run_on_start = .true.
       case ("--watch-no-run-on-start")
         w%run_on_start = .false.
       case ("--watch-rescan")
         if (len_trim(val) == 0) then
            if (i+1 > narg) call usage_and_stop("--watch-rescan requires a value")
            val = get_arg(i+1)
            i = i + 1
         end if
         w%rescan = parse_real(val, w%rescan)
       case ("--watch-no-rescan")
         w%rescan = 0.0
       case ("--watch-ignore")
         if (len_trim(val) == 0) then
            if (i+1 > narg) call usage_and_stop("--watch-ignore requires a value")
            val = get_arg(i+1)
            i = i + 1
         end if
         call push_feature(w%ignore, trim(val))
       case ("--watch-include")
         if (len_trim(val) == 0) then
            if (i+1 > narg) call usage_and_stop("--watch-include requires a value")
            val = get_arg(i+1)
            i = i + 1
         end if
         call push_feature(w%include, trim(val))
       case ("--watch-feature")
         if (len_trim(val) == 0) then
            if (i+1 > narg) call usage_and_stop("--watch-feature requires a value")
            val = get_arg(i+1)
            i = i + 1
         end if
         call push_feature(w%enabled_features, trim(val))
       case ("--watch-poll")
         if (len_trim(val) == 0) then
            if (i+1 > narg) call usage_and_stop("--watch-poll requires a value")
            val = get_arg(i+1)
            i = i + 1
         end if
         w%poll = parse_real(val, w%poll)
       case ("--watch-debounce")
         if (len_trim(val) == 0) then
            if (i+1 > narg) call usage_and_stop("--watch-debounce requires a value")
            val = get_arg(i+1)
            i = i + 1
         end if
         w%debounce = parse_real(val, w%debounce)
       case ("--watch-once")
         if (len_trim(val) > 0) then
            w%once = parse_bool(val, .true.)
         else
            w%once = .true.
         end if
       case ("--watch-help")
         call usage_and_stop_ok()
       case default
         call usage_and_stop("unknown watch flag: " // trim(a))
      end select

      call normalize_watch_opts(w)

   contains

      pure logical function parse_bool(s, default) result(v)
         character(len=*), intent(in) :: s
         logical, intent(in) :: default
         character(len=:), allocatable :: t
         t = trim(adjustl(lower_ascii(s)))
         select case (t)
          case ("1","true","on","yes","y","t")
            v = .true.
          case ("0","false","off","no","n","f")
            v = .false.
          case default
            v = default
         end select
      end function parse_bool

      pure function lower_ascii(s) result(r)
         character(len=*), intent(in) :: s
         character(len=len(s)) :: r
         integer :: k, c, da
         da = iachar('a') - iachar('A')
         do k = 1, len(s)
            c = iachar(s(k:k))
            if (c >= iachar('A') .and. c <= iachar('Z')) then
               r(k:k) = achar(c + da)
            else
               r(k:k) = s(k:k)
            end if
         end do
      end function lower_ascii

   end subroutine apply_watch_flag

   !> Print a usage message and terminate with a non-zero exit status.
   subroutine usage_and_stop(msg)
      character(len=*), intent(in) :: msg
      write(error_unit,'(a)') &
         colorize("fpm-watch: error:", color_fg='red_intense', style='bold_on') // " " // trim(msg)
      write(error_unit,'(a)') ""
      call usage_print_only()
      stop 2
   end subroutine usage_and_stop

   !> Print a usage message and terminate successfully.
   subroutine usage_and_stop_ok()
      call usage_print_only()
      stop 0
   end subroutine usage_and_stop_ok

   !> Print the `fpm-watch` help text.
   !!
   !! The help includes:
   !! - Watcher options (`--watch-*`),
   !! - Project configuration (`[extra.fpm-watch]`),
   !! - Examples and behavioral notes.
   subroutine usage_print_only()
      character(len=:), allocatable :: title

      title = colorize( &
         colorize("fpm-watch — Smart file watcher plugin for fpm projects", &
         color_fg='cyan_intense', style='bold_on'), &
         style='underline_on')

      write(error_unit,'(a)') title
      write(error_unit,'(a)') ""
      write(error_unit,'(a)') sec("USAGE")
      write(error_unit,'(a)') "  " // cmd("fpm-watch") // " " // arg("[watch options]") // " " // arg("<build|test|run>") // " " // arg("[fpm options]")
      write(error_unit,'(a)') ""
      write(error_unit,'(a)') "  Everything after " // arg("<build|test|run>") // " is passed to " // cmd("fpm") // " unchanged."
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') sec("WATCH OPTIONS")
      write(error_unit,'(a)') "  --watch-help"
      write(error_unit,'(a)') "      Show this help and exit."
      write(error_unit,'(a)') ""
      write(error_unit,'(a)') "  --watch-quiet | -q"
      write(error_unit,'(a)') "      Quiet mode (errors only)."
      write(error_unit,'(a)') "  --watch-verbose[=N] | -v"
      write(error_unit,'(a)') "      Increase verbosity. N: 0..2 (default: 1)."
      write(error_unit,'(a)') "  --watch-very-verbose | -vv"
      write(error_unit,'(a)') "      Same as verbosity=2 (prints watch list, more details)."
      write(error_unit,'(a)') "  --watch-debug"
      write(error_unit,'(a)') "      Extra debug logs (watch list rebuilds, counts, keys)."
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') "  --watch-deps"
      write(error_unit,'(a)') "      Also watch dependency source trees (including deps under build/)."
      write(error_unit,'(a)') "  --watch-no-deps"
      write(error_unit,'(a)') "      Disable dependency watching (default)."
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') "  --watch-low-cpu"
      write(error_unit,'(a)') "      Use OS sleep instead of busy-waiting (near 0% idle CPU)."
      write(error_unit,'(a)') "  --watch-no-low-cpu"
      write(error_unit,'(a)') "      Disable low CPU mode (default)."
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') "  --watch-poll <sec>"
      write(error_unit,'(a)') "      Poll interval for file checks. Default: 0.5"
      write(error_unit,'(a)') "  --watch-debounce <sec>"
      write(error_unit,'(a)') "      Wait after first change to coalesce edits. Default: 0.2"
      write(error_unit,'(a)') "  --watch-rescan <sec>"
      write(error_unit,'(a)') "      Rebuild the fpm model periodically (slow on big projects). Default: 0 (off)."
      write(error_unit,'(a)') "  --watch-no-rescan"
      write(error_unit,'(a)') "      Disable periodic rebuild (recommended)."
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') "  --watch-run-on-start"
      write(error_unit,'(a)') "      Run the command once immediately on startup. Default: on."
      write(error_unit,'(a)') "  --watch-no-run-on-start"
      write(error_unit,'(a)') "      Only run after a change is detected."
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') "  --watch-silent-fpm"
      write(error_unit,'(a)') "      Suppress fpm output; keep only fpm-watch status lines."
      write(error_unit,'(a)') "  --watch-print-files"
      write(error_unit,'(a)') "      Print the watched file list (limited) at startup."
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') "  --watch-ignore <glob>"
      write(error_unit,'(a)') "      Ignore files matching glob (repeatable)."
      write(error_unit,'(a)') "  --watch-include <glob>"
      write(error_unit,'(a)') "      Only watch files matching glob (repeatable). Empty means include all."
      write(error_unit,'(a)') "  --watch-feature <name>"
      write(error_unit,'(a)') "      Enable optional feature plugin (repeatable)."
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') "  --watch-auto-restart"
      write(error_unit,'(a)') "      Restart fpm-watch automatically if it crashes."
      write(error_unit,'(a)') "  --watch-restart-delay <sec>"
      write(error_unit,'(a)') "      Delay before restart. Default: 1.0"
      write(error_unit,'(a)') "  --watch-restart-max <N>"
      write(error_unit,'(a)') "      Maximum restart attempts (0 = unlimited). Default: 0"
      write(error_unit,'(a)') "  --watch-self <path>"
      write(error_unit,'(a)') "      Explicit path to fpm-watch executable (advanced)."
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') "  --watch-once"
      write(error_unit,'(a)') "      Initialize (and optionally run once) then exit. Good for CI."
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') sec("CONFIGURATION (fpm.toml)")
      write(error_unit,'(a)') "  " // arg("[extra.fpm-watch]") // "  (same names as flags, without the --watch- prefix)"
      write(error_unit,'(a)') "  poll         = 0.5"
      write(error_unit,'(a)') "  debounce     = 0.2"
      write(error_unit,'(a)') "  rescan       = 0.0"
      write(error_unit,'(a)') "  run-on-start = true"
      write(error_unit,'(a)') "  silent-fpm   = false"
      write(error_unit,'(a)') "  print-files  = false"
      write(error_unit,'(a)') "  deps         = false"
      write(error_unit,'(a)') "  low-cpu      = false"
      write(error_unit,'(a)') "  auto-restart = false"
      write(error_unit,'(a)') "  restart-delay= 1.0"
      write(error_unit,'(a)') "  restart-max  = 0"
      write(error_unit,'(a)') "  self         = """""
      write(error_unit,'(a)') "  debug        = false"
      write(error_unit,'(a)') "  verbosity    = 0"
      write(error_unit,'(a)') "  ignore       = []"
      write(error_unit,'(a)') "  include      = []"
      write(error_unit,'(a)') "  features     = []"
      write(error_unit,'(a)') "  once         = false"
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') sec("EXAMPLES")
      write(error_unit,'(a)') "  " // cmd("fpm-watch build")
      write(error_unit,'(a)') "  " // cmd("fpm-watch --watch-low-cpu test")
      write(error_unit,'(a)') "  " // cmd("fpm-watch --watch-deps test") // " " // arg("--") // " " // arg("--list")
      write(error_unit,'(a)') "  " // cmd("fpm-watch --watch-auto-restart test")
      write(error_unit,'(a)') ""

      write(error_unit,'(a)') sec("NOTES")
      write(error_unit,'(a)') "  fpm-watch ignores " // arg("build/") // " to avoid infinite rebuild loops."
      write(error_unit,'(a)') "  Enable " // arg("deps") // " only if you want dependency source changes to trigger runs."
      write(error_unit,'(a)') "  Enable " // arg("low-cpu") // " to avoid busy-waiting and reduce idle CPU usage."
      write(error_unit,'(a)') "  To quit, press Ctrl+C (or create an empty file named " // arg(".fpm-watch.stop") // ")."
      write(error_unit,'(a)') ""

   contains

      function sec(s) result(r)
         character(len=*), intent(in) :: s
         character(len=:), allocatable :: r
         r = colorize(s, color_fg='blue_intense', style='bold_on')
      end function sec

      function cmd(s) result(r)
         character(len=*), intent(in) :: s
         character(len=:), allocatable :: r
         r = colorize(s, color_fg='cyan_intense', style='bold_on')
      end function cmd

      function arg(s) result(r)
         character(len=*), intent(in) :: s
         character(len=:), allocatable :: r
         r = colorize(s, color_fg='yellow_intense')
      end function arg

   end subroutine usage_print_only

   !> Populate `settings` with parsed values and environment fallbacks.
   !!
   !! This routine centralizes:
   !! - `build_dir`, `profile`, `features`,
   !! - Fortran/C/C++ toolchain selection (`FC`, `CC`, `CXX`, `AR`),
   !! - Common compile and link flags (`--flag`, `--c-flag`, `--cxx-flag`, `--link-flag`).
   !!
   !! Environment variables are consulted only when the corresponding CLI option
   !! is empty.
   subroutine init_build_settings(settings, build_dir, prune, profile, features, compiler_in, c_compiler_in, cxx_compiler_in, archiver_in, flag_in, cflag_in, cxxflag_in, ldflag_in)
      class(fpm_build_settings), intent(inout) :: settings
      character(len=*), intent(in) :: build_dir
      logical, intent(in) :: prune
      character(len=*), intent(in) :: profile
      type(string_t), allocatable, intent(in) :: features(:)
      character(len=*), intent(in) :: compiler_in, c_compiler_in, cxx_compiler_in, archiver_in
      character(len=*), intent(in) :: flag_in, cflag_in, cxxflag_in, ldflag_in

      character(len=:), allocatable :: v
      character(len=2048) :: buf
      integer :: n, stat

      settings%verbose = .false.
      settings%prune = prune

      if (len_trim(build_dir) > 0) then
         settings%build_dir = trim(build_dir)
      end if

      if (len_trim(profile) > 0) settings%profile = trim(profile)

      if (allocated(settings%features)) deallocate(settings%features)
      if (allocated(features)) then
         if (size(features) > 0) then
            allocate(settings%features(size(features)))
            settings%features = features
         end if
      end if

      v = trim(compiler_in)
      if (len_trim(v) == 0) then
         buf = ""
         n = 0
         stat = 0
         call get_environment_variable("FC", buf, length=n, status=stat)
         if (stat == 0 .and. n > 0) v = trim(buf(1:n))
      end if
      if (len_trim(v) == 0) v = "gfortran"
      settings%compiler = trim(v)

      v = trim(c_compiler_in)
      if (len_trim(v) == 0) then
         buf = ""
         n = 0
         stat = 0
         call get_environment_variable("CC", buf, length=n, status=stat)
         if (stat == 0 .and. n > 0) v = trim(buf(1:n))
      end if
      if (len_trim(v) > 0) settings%c_compiler = trim(v)

      v = trim(cxx_compiler_in)
      if (len_trim(v) == 0) then
         buf = ""
         n = 0
         stat = 0
         call get_environment_variable("CXX", buf, length=n, status=stat)
         if (stat == 0 .and. n > 0) v = trim(buf(1:n))
      end if
      if (len_trim(v) > 0) settings%cxx_compiler = trim(v)

      v = trim(archiver_in)
      if (len_trim(v) == 0) then
         buf = ""
         n = 0
         stat = 0
         call get_environment_variable("AR", buf, length=n, status=stat)
         if (stat == 0 .and. n > 0) v = trim(buf(1:n))
      end if
      if (len_trim(v) > 0) settings%archiver = trim(v)

      v = trim(flag_in)
      if (len_trim(v) > 0) settings%flag = trim(v)

      v = trim(cflag_in)
      if (len_trim(v) > 0) settings%cflag = trim(v)

      v = trim(cxxflag_in)
      if (len_trim(v) > 0) settings%cxxflag = trim(v)

      v = trim(ldflag_in)
      if (len_trim(v) > 0) settings%ldflag = trim(v)
   end subroutine init_build_settings

   !> Return the default build directory used by `fpm-watch`.
   pure function default_build_dir() result(dir)
      character(len=:), allocatable :: dir
      dir = "build"
   end function default_build_dir

   !> Transfer collected name patterns into a run/test settings object.
   !!
   !! The `names` array contains tokens collected from positional arguments in
   !! `fpm-watch run/test` invocations. Only non-empty entries are copied.
   pure subroutine set_names(s, names)
      class(fpm_run_settings), intent(inout) :: s
      type(string_t), allocatable, intent(in) :: names(:)
      integer :: i, k, n

      if (.not. allocated(names)) return

      n = 0
      do i = 1, size(names)
         if (len_trim(names(i)%s) > 0) n = n + 1
      end do
      if (n == 0) return

      allocate(s%name(n))
      k = 0
      do i = 1, size(names)
         if (len_trim(names(i)%s) == 0) cycle
         k = k + 1
         s%name(k) = trim(names(i)%s)
      end do
   end subroutine set_names

   !> Parse a comma-separated `--features` value into the features array.
   !!
   !! Empty tokens are ignored; whitespace around tokens is stripped.
   subroutine add_features_csv(features, csv)
      type(string_t), allocatable, intent(inout) :: features(:)
      character(len=*), intent(in) :: csv

      integer :: n, p, q
      character(len=:), allocatable :: s, tok

      s = trim(csv)
      if (len_trim(s) == 0) return

      p = 1
      n = len_trim(s)

      do
         if (p > n) exit
         q = index(s(p:), ",")
         if (q == 0) then
            tok = adjustl(s(p:n))
            call push_feature(features, trim(tok))
            exit
         else
            tok = adjustl(s(p:p+q-2))
            call push_feature(features, trim(tok))
            p = p + q
         end if
      end do
   end subroutine add_features_csv

end module watch_cli