lib/utils/config_file.rb



require 'tins'

class Utils::ConfigFile
  class << self
    attr_accessor :config_file_paths
  end
  self.config_file_paths = [
    '/etc/utilsrc',
    '~/.utilsrc',
    './.utilsrc',
  ]

  include DSLKit::Interpreter

  class ConfigFileError < StandardError; end

  # The initialize method sets up a new instance of the class.
  #
  # This method is called when creating a new object and performs any necessary
  # initialization tasks for the instance variables and internal state.
  def initialize
  end

  # The configure_from_paths method initializes the configuration by parsing
  # configuration files from the specified paths.
  #
  # This method iterates through an array of configuration file paths and
  # processes each one to load the configuration settings. It is typically used
  # to set up the application's configuration from multiple sources.
  #
  # @param paths [ Array<String> ] an array of file paths pointing to configuration files
  def configure_from_paths(paths = self.class.config_file_paths)
    for config_file_path in paths
      parse_config_file config_file_path
    end
  end

  # The parse_config_file method reads and processes a configuration file.
  #
  # This method opens the specified configuration file, reads its contents,
  # and parses the configuration data. It handles file path expansion and
  # includes error handling for system call errors during file operations.
  #
  # @param config_file_path [ String ] the path to the configuration file to be parsed
  #
  # @return [ Utils::ConfigFile ] returns self after parsing the configuration file
  #
  # @raise [ SystemCallError ] if there is an issue reading the configuration file
  #
  # @note The method will output a warning message to standard error if it fails
  #       to read the configuration file and return nil.
  def parse_config_file(config_file_path)
    config_file_path = File.expand_path(config_file_path)
    File.open(config_file_path) do |cf|
      parse cf.read
    end
    self
  rescue SystemCallError => e
    $DEBUG and warn "Couldn't read config file "\
      "#{config_file_path.inspect}: #{e.class} #{e}"
    return nil
  end

  # The parse method processes the provided source code by interpreting it
  # within the given binding context.
  #
  # This method takes a source code string and evaluates it in the context of
  # the specified binding, allowing for dynamic execution of code with access
  # to the current variable scope.
  #
  # @param source [ String ] the source code to be interpreted and executed
  #
  # @return [ Object ] returns self after processing the source code
  def parse(source)
    interpret_with_binding source, binding
    self
  end

  class BlockConfig
    class << self
      # The inherited method extends the module with DSL accessor functionality
      # and calls the superclass implementation.
      #
      # @param modul [ Module ] the module that inherited this class
      def inherited(modul)
        modul.extend DSLKit::DSLAccessor
        super
      end

      # The config method sets up a configuration accessor with the specified
      # name and options.
      #
      # This method registers a new configuration setting by adding it to the
      # list of configuration settings and then creates an accessor for it
      # using the dsl_accessor method, allowing for easy retrieval and
      # assignment of configuration values.
      #
      # @param name [ Object ] the name of the configuration setting
      # @param r [ Array ] additional arguments passed to the dsl_accessor method
      #
      # @yield [ block ] optional block to be passed to the dsl_accessor method
      #
      # @return [ Object ] returns self to allow for method chaining
      def config(name, *r, &block)
        self.config_settings ||= []
        config_settings << name.to_sym
        dsl_accessor name, *r, &block
        self
      end

      # The config_settings method provides access to the configuration
      # settings.
      #
      # This method returns the configuration settings stored in the instance
      # variable, allowing for reading and modification of the object's
      # configuration state.
      #
      # @return [ Object ] the current configuration settings stored in the
      # instance variable
      attr_accessor :config_settings
    end

    # The initialize method sets up the instance by evaluating the provided
    # block in the instance's context.
    #
    # This method allows for dynamic configuration of the object by executing
    # the given block within the instance's scope, enabling flexible
    # initialization patterns.
    #
    # @param block [ Proc ] the block to be evaluated for instance setup
    def initialize(&block)
      block and instance_eval(&block)
    end

    # The to_ruby method generates a Ruby configuration block representation by
    # recursively processing the object's configuration settings and their
    # values.
    # It creates a nested structure with proper indentation and formatting
    # suitable for configuration files.
    #
    # @param depth [ Integer ] the current nesting depth for indentation purposes
    #
    # @return [ String ] a formatted Ruby string representing the configuration block
    def to_ruby(depth = 0)
      result = ''
      result << ' ' * 2 * depth <<
        "#{self.class.name[/::([^:]+)\z/, 1].underscore} do\n"
      for name in self.class.config_settings
        value = __send__(name)
        if value.respond_to?(:to_ruby)
          result << ' ' * 2 * (depth + 1) << value.to_ruby(depth + 1)
        else
          result << ' ' * 2 * (depth + 1) <<
            "#{name} #{Array(value).map(&:inspect) * ', '}\n"
        end
      end
      result << ' ' * 2 * depth << "end\n"
    end
  end

  class Probe < BlockConfig
    # The config method sets up a configuration option for the test framework.
    #
    # This method defines a configuration parameter that specifies which test
    # framework should be used, allowing for flexible test execution environments.
    #
    # @param name [ Symbol ] the name of the configuration option
    # @param value [ Object ] the value to set for the configuration option
    config :test_framework, :'test-unit'

    # The include_dirs method configures the directories to be included in the
    # search.
    #
    # @param dirs [ Array<String> ] the array of directory names to include
    config :include_dirs, %w[lib test tests ext spec]

    # The include_dirs_argument method constructs a colon-separated string from
    # include directories.
    #
    # This method takes the include directories configuration and converts it
    # into a single colon-delimited string suitable for use in command-line
    # arguments or environment variables.
    #
    # @return [ String ] a colon-separated string of include directory paths
    def include_dirs_argument
      Array(include_dirs) * ':'
    end

    # The initialize method sets up the configuration by validating the test
    # framework.
    #
    # This method initializes the configuration object and ensures that the
    # specified test framework is one of the allowed values. It raises an error
    # if the test framework is not supported.
    #
    # @param block [ Proc ] a block to be passed to the superclass initializer
    def initialize(&block)
      super
      test_frameworks_allowed = [ :'test-unit', :rspec ]
      test_frameworks_allowed.include?(test_framework) or
        raise ConfigFileError,
          "test_framework has to be in #{test_frameworks_allowed.inspect}"
    end
  end

  # The probe method initializes and returns a Probe object.
  #
  # This method creates a new Probe instance either from the provided block or
  # with default settings, storing it for later use. It ensures that only one
  # Probe instance is created per object, returning the existing instance
  # on subsequent calls.
  #
  # @param block [ Proc ] optional block to configure the Probe instance
  #
  # @return [ Utils::Probe ] a Probe instance configured either by the block
  #         or with default settings
  def probe(&block)
    if block
      @probe = Probe.new(&block)
    end
    @probe ||= Probe.new
  end

  class FileFinder < BlockConfig
    # The prune? method checks if a basename matches any of the configured
    # prune directories.
    #
    # This method determines whether a given filename or directory name should
    # be excluded based on the prune directories configuration. It iterates
    # through the list of prune patterns and returns true if any pattern
    # matches the provided basename.
    #
    # @param basename [ String, Object ] the basename to check against prune patterns
    #
    # @return [ TrueClass, FalseClass ] true if the basename matches any prune pattern,
    #         false otherwise
    def prune?(basename)
      Array(prune_dirs).any? { |pd| pd.match(basename.to_s) }
    end

    # The skip? method determines whether a file should be skipped based on
    # configured patterns.
    #
    # This method checks if the provided basename matches any of the configured
    # skip patterns. It converts the basename to a string and tests it against
    # all defined skip files.
    #
    # @param basename [ Object] the file or directory name to check
    #
    # @return [ TrueClass, FalseClass ] true if the basename matches any skip
    # pattern, false otherwise
    def skip?(basename)
      Array(skip_files).any? { |sf| sf.match(basename.to_s) }
    end
  end

  class Search < FileFinder
    # The prune_dirs method configures the pattern for identifying directories
    # to be pruned during file system operations.
    #
    # This method sets up a regular expression pattern that matches directory
    # names which should be excluded from processing. The default pattern
    # excludes version control directories (.svn, .git, CVS) and temporary
    # directories (tmp).
    #
    # @param first [ Regexp ] the regular expression pattern for matching
    # directories to prune
    config :prune_dirs, /\A(\.svn|\.git|CVS|tmp)\z/

    # The skip_files configuration method sets up a regular expression pattern
    # for filtering out files based on their names.
    #
    # This method configures a pattern that matches filenames which should be
    # skipped during file processing operations.
    # It uses a regular expression to identify files that start with a dot, end
    # with common temporary file extensions, or match other patterns typically
    # associated with backup, swap, log, or temporary files.
    #
    # @param pattern [ Regexp ] the regular expression pattern used to identify
    # files to skip
    config :skip_files, /(\A\.|\.sw[pon]\z|\.log\z|~\z)/
  end

  # The search method initializes and returns a Search object.
  #
  # This method creates a Search instance either from a provided block or with
  # default settings.
  # It maintains a cached instance of the Search object, returning the same
  # instance on subsequent calls.
  #
  # @param block [ Proc ] optional block to configure the Search object
  #
  # @return [ Utils::Search ] a Search object configured either by the provided
  # block or with default settings
  def search(&block)
    if block
      @search = Search.new(&block)
    end
    @search ||= Search.new
  end

  class Discover < FileFinder
    # The prune_dirs method configures the pattern for identifying directories
    # to be pruned during file system operations.
    #
    # This method sets up a regular expression pattern that matches directory
    # names which should be excluded from processing.
    # The default pattern excludes version control directories (.svn, .git,
    # CVS) and temporary directories (tmp).
    #
    # @param first [ Regexp ] the regular expression pattern for matching
    # directories to prune
    config :prune_dirs, /\A(\.svn|\.git|CVS|tmp)\z/

    # The skip_files configuration method sets up a regular expression pattern
    # for filtering out files based on their names.
    #
    # This method configures a pattern that matches filenames which should be
    # skipped during file processing operations. It uses a regular expression
    # to identify files that start with a dot, end with common temporary file
    # extensions, or match other patterns typically associated with backup,
    # swap, log, or temporary files.
    #
    # @param pattern [ Regexp ] the regular expression pattern used to identify
    # files to skip
    config :skip_files, /(\A\.|\.sw[pon]\z|\.log\z|~\z)/

    # The config method sets up a configuration option with a default value.
    #
    # @param name [ Symbol ] the name of the configuration option
    # @param default [ Object ] the default value for the configuration option
    config :max_matches, 10

    # The index_expire_after method configures the expiration time for index
    # files.
    #
    # This method sets up the duration after which index files should be
    # considered expired and potentially refreshed or regenerated by the
    # system.
    #
    # @param value [ Integer, nil ] the number of seconds after which indexes expire,
    #                               or nil to disable automatic expiration
    config :index_expire_after
  end

  # The discover method initializes and returns a Discover object.
  #
  # This method sets up a Discover instance, either using a provided block for
  # configuration or creating a default instance. It ensures that only one
  # Discover object is created per instance by storing it in an instance
  # variable.
  #
  # @param block [ Proc ] optional block to configure the Discover object
  #
  # @return [ Utils::Discover ] a Discover object configured either with the
  # provided block or with default settings
  def discover(&block)
    if block
      @discover = Discover.new(&block)
    end
    @discover ||= Discover.new
  end

  class Scope < FileFinder
    # The prune_dirs method configures the pattern for identifying directories
    # to be pruned during file system operations.
    #
    # This method sets up a regular expression pattern that matches directory
    # names which should be excluded from processing. The default pattern
    # excludes version control directories (.svn, .git, CVS) and temporary
    # directories (tmp).
    #
    # @param first [ Regexp ] the regular expression pattern for matching directories to prune
    config :prune_dirs, /\A(\.svn|\.git|CVS|tmp)\z/

    # The skip_files configuration method sets up a regular expression pattern
    # for filtering out files based on their names.
    #
    # This method configures a pattern that matches filenames which should be
    # skipped during file processing operations.
    # It uses a regular expression to identify files that start with a dot, end
    # with common temporary file extensions, or match other patterns typically
    # associated with backup, swap, log, or temporary files.
    #
    # @param pattern [ Regexp ] the regular expression pattern used to identify files to skip
    config :skip_files, /(\A\.|\.sw[pon]\z|\.log\z|~\z)/
  end

  # The scope method initializes and returns a Scope object.
  #
  # This method creates a new Scope instance either from the provided block or
  # with default initialization if no block is given.
  # The scope object is stored as an instance variable and reused on subsequent
  # calls.
  #
  # @param block [ Proc ] optional block to pass to the Scope constructor
  #
  # @return [ Utils::Scope ] a Scope object configured with the provided block
  # or default settings
  def scope(&block)
    if block
      @scope = Scope.new(&block)
    end
    @scope ||= Scope.new
  end

  class StripSpaces < FileFinder
    # The prune_dirs method configures the pattern for directory names that
    # should be pruned.
    #
    # This method sets up a regular expression pattern that identifies
    # directories which should be excluded or removed during file system
    # operations.
    #
    # @param pattern [ Regexp ] the regular expression pattern to match
    # directory names
    config :prune_dirs, /\A(\..*|CVS)\z/

    # The skip_files configuration method sets up a regular expression pattern
    # for filtering out files based on their names.
    #
    # This method configures a pattern that matches filenames which should be
    # skipped during file processing operations.
    # It uses a regular expression to identify files that start with a dot, end
    # with common temporary file extensions, or match other patterns typically
    # associated with backup, swap, log, or temporary files.
    #
    # @param pattern [ Regexp ] the regular expression pattern used to identify
    # files to skip
    config :skip_files, /(\A\.|\.sw[pon]\z|\.log\z|~\z)/
  end

  # The strip_spaces method configures and returns a StripSpaces object for
  # processing whitespace.
  #
  # This method initializes a StripSpaces processor that can be used to remove
  # or modify whitespace in strings. When a block is provided, it sets up the
  # processor with custom behavior defined by the block. Otherwise, it returns
  # a default StripSpaces instance.
  #
  # @param block [ Proc ] optional block to customize the strip spaces behavior
  #
  # @return [ Utils::StripSpaces ] a configured StripSpaces processor instance
  def strip_spaces(&block)
    if block
      @strip_spaces = StripSpaces.new(&block)
    end
    @strip_spaces ||= StripSpaces.new
  end

  class SshTunnel < BlockConfig
    # The terminal_multiplexer method configures the terminal multiplexer
    # setting.
    #
    # This method sets up the terminal multiplexer that will be used for
    # managing multiple terminal sessions within the application environment.
    #
    # @param value [ String ] the name of the terminal multiplexer to use
    config :terminal_multiplexer, 'tmux'

    # The env method configures the environment variables for the session.
    #
    # @param value [ Hash ] environment variable hash
    config :env, {}

    # The login_session method configures the login session settings.
    #
    # @param block [ Proc ] a block containing the login session configuration
    config :login_session do
      ENV.fetch('HOME',  'session')
    end

    # The initialize method sets up the instance by calling the superclass
    # constructor and assigning the terminal multiplexer configuration.
    #
    # @param terminal_multiplexer [ Object ] the terminal multiplexer to be assigned
    def initialize
      super
      self.terminal_multiplexer = terminal_multiplexer
    end

    # The terminal_multiplexer= method sets the terminal multiplexer type for
    # the editor.
    #
    # This method assigns the specified terminal multiplexer to the editor
    # configuration, validating that it is either 'screen' or 'tmux'. It
    # converts the input to a string and ensures it matches one of the
    # supported multiplexer types.
    #
    # @param terminal_multiplexer [ Symbol ] the terminal multiplexer type to
    # be configured
    def terminal_multiplexer=(terminal_multiplexer)
      @multiplexer = terminal_multiplexer.to_s
      @multiplexer =~ /\A(screen|tmux)\z/ or
        fail "invalid terminal_multiplexer #{terminal_multiplexer.inspect} was configured"
    end

    # The multiplexer_list method returns the appropriate command string for
    # listing sessions based on the current multiplexer type.
    #
    # @return [ String, nil ] the command string to list sessions for the configured
    #         multiplexer ('screen -ls' or 'tmux ls'), or nil if no multiplexer is set
    def multiplexer_list
      case @multiplexer
      when 'screen'
        'screen -ls'
      when 'tmux'
        'tmux ls'
      end
    end

    # The multiplexer_new method generates a command string for creating a new
    # session in the specified terminal multiplexer.
    #
    # @param session [ String ] the name of the session to be created
    #
    # @return [ String, nil ] a command string for creating a new session in screen
    #         or tmux, or nil if the multiplexer type is not supported
    def multiplexer_new(session)
      case @multiplexer
      when 'screen'
        'false'
      when 'tmux'
        'tmux -u new -s "%s"' % session
      end
    end

    # The multiplexer_attach method generates a command string for attaching to
    # a session using either screen or tmux multiplexer.
    #
    # @param session [ String ] the name or identifier of the session to attach to
    #
    # @return [ String ] a formatted command string ready for execution
    def multiplexer_attach(session)
      case @multiplexer
      when 'screen'
        'screen -DUR "%s"' % session
      when 'tmux'
        'tmux -u attach -d -t "%s"' % session
      end
    end

    class CopyPaste < BlockConfig
      # The bind_address method configures the network address to which the
      # server will bind for incoming connections.
      #
      # @param value [ String ] the IP address or hostname to bind the server to
      config :bind_address, 'localhost'

      # The port method configures the port number for the SSH tunnel
      # specification.
      #
      # This method sets up the port component of an SSH tunnel configuration,
      # allowing for the specification of a network port to be used in the
      # tunnel.
      #
      # @param name [ String ] the name of the port configuration
      # @param default [ Integer ] the default port number to use if none is specified
      config :port, 6166

      # The host method configures the hostname for the SSH tunnel specification.
      #
      # This method sets up the host parameter that will be used in the SSH tunnel
      # configuration, allowing connections to be established through the specified
      # host address.
      #
      # @param value [ String ] the hostname or IP address to use for the SSH tunnel
      config :host, 'localhost'

      # The host_port method configures the host port setting for the
      # application.
      #
      # @param value [ Integer ] the port number to be used for host communication
      config :host_port, 6166

      # The to_s method returns a colon-separated string representation of the
      # SSH tunnel specification.
      #
      # This method combines the bind address, port, host, and host port components
      # into a single string format using colons as separators.
      #
      # @return [ String ] a colon-separated string containing the tunnel specification
      #         in the format "bind_address:port:host:host_port"
      def to_s
        [ bind_address, port, host, host_port ] * ':'
      end
    end

    # The copy_paste method manages the copy-paste functionality by returning
    # an existing instance or creating a new one.
    #
    # This method checks if a copy-paste instance already exists and returns it
    # if available. If no instance exists, it creates a new one based on
    # whether a block is provided or
    # the enable flag is set to true.
    #
    # @param enable [ TrueClass, FalseClass ] flag to enable copy-paste functionality
    #
    # @yield [ block ] optional block to initialize the copy-paste instance
    #
    # @return [ CopyPaste, nil ] the existing or newly created copy-paste
    # instance, or nil if not enabled
    def copy_paste(enable = false, &block)
      if @copy_paste
        @copy_paste
      else
        if block
          @copy_paste = CopyPaste.new(&block)
        elsif enable
          @copy_paste = CopyPaste.new {}
        end
      end
    end
    self.config_settings << :copy_paste
  end

  def ssh_tunnel(&block)
    if block
      @ssh_tunnel = SshTunnel.new(&block)
    end
    @ssh_tunnel ||= SshTunnel.new
  end

  class Edit < BlockConfig
    # The vim_path method determines the path to the vim executable.
    #
    # This method executes the which command to locate the vim executable in
    # the system's PATH and returns the resulting path after stripping any
    # trailing whitespace.
    #
    # @return [ String ] the full path to the vim executable as determined by the which command
    config :vim_path do `which vim`.chomp end

    config :vim_default_args, nil
  end

  # The edit method initializes and returns an Edit object.
  #
  # This method creates an Edit instance either from a provided block or with
  # default settings. It stores the Edit object as an instance variable and
  # returns it on subsequent calls.
  #
  # @param block [ Proc ] optional block to configure the Edit object
  #
  # @return [ Edit ] an Edit object configured either by the block or with default settings
  def edit(&block)
    if block
      @edit = Edit.new(&block)
    end
    @edit ||= Edit.new
  end

  class Classify < BlockConfig
    # The shift_path_by_default method configuration accessor
    #
    # This method provides access to the shift_path_by_default configuration
    # setting which determines the default path shifting value used in various
    # operations.
    #
    # @return [ Integer ] the default path shifting value configured for the system
    config :shift_path_by_default, 0

    # The shift_path_for_prefix method configures path shifting behavior for
    # prefix handling.
    #
    # This method sets up the configuration for how paths should be shifted
    # when dealing with prefix-based operations, typically used in file system
    # or directory navigation contexts.
    #
    # @param config [ Array ] the configuration array for shift path settings
    config :shift_path_for_prefix, []
  end

  # The classify method initializes and returns a Classify object.
  #
  # This method creates a Classify instance either from the provided block or
  # with default settings if no block is given. It ensures that only one
  # Classify object is created per instance by storing it in an instance variable.
  #
  # @param block [ Proc ] optional block to configure the Classify object
  #
  # @return [ Classify ] a Classify object configured either by the block or with defaults
  def classify(&block)
    if block
      @classify = Classify.new(&block)
    end
    @classify ||= Classify.new
  end

  class SyncDir < BlockConfig
    # The skip_path method configures a regular expression pattern for skipping
    # paths.
    #
    # This method sets up a configuration option that defines a regular
    # expression used to identify and skip certain paths during processing
    # operations.
    #
    # @param pattern [ Regexp ] the regular expression pattern used to match
    # paths to be skipped
    config :skip_path, %r((\A|/)\.\w)

    # The skip? method determines whether a given path should be skipped based
    # on the skip_path pattern.
    #
    # This method checks if the provided path matches the internal skip_path
    # regular expression, returning true if the path should be excluded from
    # processing, or false otherwise.
    #
    # @param path [ String ] the path to check against the skip pattern
    #
    # @return [ TrueClass, FalseClass ] true if the path matches the skip
    # pattern, false otherwise
    def skip?(path)
      path =~ skip_path
    end
  end

  # The sync_dir method provides access to a SyncDir instance.
  #
  # This method returns the existing SyncDir instance if one has already been
  # created, or initializes and returns a new SyncDir instance if no instance
  # exists. If a block is provided, it will be passed to the SyncDir constructor
  # when creating a new instance.
  #
  # @return [ SyncDir ] the SyncDir instance associated with this object
  def sync_dir(&block)
    if block
      @sync_dir = SyncDir.new(&block)
    end
    @sync_dir ||= SyncDir.new
  end

  # The to_ruby method generates a Ruby configuration string by collecting
  # configuration data from various components and combining them into a
  # single formatted output.
  #
  # @return [ String ] a Ruby formatted string containing configuration
  #         settings from search, discover, strip_spaces, probe, ssh_tunnel,
  #         edit, and classify components
  def to_ruby
    result = "# vim: set ft=ruby:\n"
    for bc in %w[search discover strip_spaces probe ssh_tunnel edit classify]
      result << "\n" << __send__(bc).to_ruby
    end
    result
  end
end