lib/solargraph/workspace/config.rb



# frozen_string_literal: true


require 'yaml'

module Solargraph
  class Workspace
    # Configuration data for a workspace.

    #

    class Config
      # The maximum number of files that can be added to a workspace.

      # The workspace's .solargraph.yml can override this value.

      MAX_FILES = 5000

      # @return [String]

      attr_reader :directory

      # @todo To make this strongly typed we'll need a record syntax

      # @return [Hash{String => undefined}]

      attr_reader :raw_data

      # @param directory [String]

      def initialize directory = ''
        @directory = File.absolute_path(directory)
        @raw_data = config_data
        included
        excluded
      end

      # An array of files included in the workspace (before calculating excluded files).

      #

      # @return [Array<String>]

      def included
        return [] if directory.empty? || directory == '*'
        @included ||= process_globs(@raw_data['include'])
      end

      # An array of files excluded from the workspace.

      #

      # @return [Array<String>]

      def excluded
        return [] if directory.empty? || directory == '*'
        @excluded ||= process_exclusions(@raw_data['exclude'])
      end

      # @param filename [String]

      def allow? filename
        filename = File.absolute_path(filename, directory)
        filename.start_with?(directory) &&
          !excluded.include?(filename) &&
          excluded_directories.none? { |d| filename.start_with?(d) }
      end

      # The calculated array of (included - excluded) files in the workspace.

      #

      # @return [Array<String>]

      def calculated
        Solargraph.logger.info "Indexing workspace files in #{directory}" unless @calculated || directory.empty? || directory == '*'
        @calculated ||= included - excluded
      end

      # An array of domains configured for the workspace.

      # A domain is a namespace that the ApiMap should include in the global

      # namespace. It's typically used to identify available DSLs.

      #

      # @return [Array<String>]

      def domains
        raw_data['domains']
      end

      # An array of required paths to add to the workspace.

      #

      # @return [Array<String>]

      def required
        raw_data['require']
      end

      # An array of load paths for required paths.

      #

      # @return [Array<String>]

      def require_paths
        raw_data['require_paths'] || []
      end

      # An array of reporters to use for diagnostics.

      #

      # @return [Array<String>]

      def reporters
        raw_data['reporters']
      end

      # A hash of options supported by the formatter

      #

      # @return [Hash]

      def formatter
        raw_data['formatter']
      end

      # An array of plugins to require.

      #

      # @return [Array<String>]

      def plugins
        raw_data['plugins']
      end

      # The maximum number of files to parse from the workspace.

      #

      # @return [Integer]

      def max_files
        raw_data['max_files']
      end

      private

      # @return [String]

      def global_config_path
        ENV['SOLARGRAPH_GLOBAL_CONFIG'] ||
          File.join(Dir.home, '.config', 'solargraph', 'config.yml')
      end

      # @return [String]

      def workspace_config_path
        return '' if @directory.empty?
        File.join(@directory, '.solargraph.yml')
      end

      # @return [Hash{String => Array, Hash, Integer}]

      def config_data
        workspace_config = read_config(workspace_config_path)
        global_config = read_config(global_config_path)

        defaults = default_config
        defaults.merge({'exclude' => []}) unless workspace_config.nil?

        defaults
          .merge(global_config || {})
          .merge(workspace_config || {})
      end

      # Read a .solargraph yaml config

      #

      # @param config_path [String]

      # @return [Hash{String => Array, Hash, Integer}, nil]

      def read_config config_path = ''
        return nil if config_path.empty?
        return nil unless File.file?(config_path)
        YAML.safe_load(File.read(config_path))
      end

      # @return [Hash{String => Array, Hash, Integer}]

      def default_config
        {
          'include' => ['**/*.rb'],
          'exclude' => ['spec/**/*', 'test/**/*', 'vendor/**/*', '.bundle/**/*'],
          'require' => [],
          'domains' => [],
          'reporters' => %w[rubocop require_not_found],
          'formatter' => {
            'rubocop' => {
              'cops' => 'safe',
              'except' => [],
              'only' => [],
              'extra_args' =>[]
            }
          },
          'require_paths' => [],
          'plugins' => [],
          'max_files' => MAX_FILES
        }
      end

      # Get an array of files from the provided globs.

      #

      # @param globs [Array<String>]

      # @return [Array<String>]

      def process_globs globs
        result = globs.flat_map do |glob|
          Dir[File.absolute_path(glob, directory)]
            .map{ |f| f.gsub(/\\/, '/') }
            .select { |f| File.file?(f) }
        end
        result
      end

      # Modify the included files based on excluded directories and get an

      # array of additional files to exclude.

      #

      # @param globs [Array<String>]

      # @return [Array<String>]

      def process_exclusions globs
        remainder = globs.select do |glob|
          if glob_is_directory?(glob)
            exdir = File.absolute_path(glob_to_directory(glob), directory)
            included.delete_if { |file| file.start_with?(exdir) }
            false
          else
            true
          end
        end
        process_globs remainder
      end

      # True if the glob translates to a whole directory.

      #

      # @example

      #   glob_is_directory?('path/to/dir')       # => true

      #   glob_is_directory?('path/to/dir/**/*)   # => true

      #   glob_is_directory?('path/to/file.txt')  # => false

      #   glob_is_directory?('path/to/*.txt')     # => false

      #

      # @param glob [String]

      # @return [Boolean]

      def glob_is_directory? glob
        File.directory?(glob) || File.directory?(glob_to_directory(glob))
      end

      # Translate a glob to a base directory if applicable

      #

      # @example

      #   glob_to_directory('path/to/dir/**/*') # => 'path/to/dir'

      #

      # @param glob [String]

      # @return [String]

      def glob_to_directory glob
        glob.gsub(/(\/\*|\/\*\*\/\*\*?)$/, '')
      end

      # @return [Array<String>]

      def excluded_directories
        @raw_data['exclude']
          .select { |g| glob_is_directory?(g) }
          .map { |g| File.absolute_path(glob_to_directory(g), directory) }
      end
    end
  end
end