# frozen_string_literal: true

module RuboCop
  module Cop
    module Style
      # Use `File::NULL` instead of hardcoding the null device (`/dev/null` on Unix-like
      # OSes, `NUL` or `NUL:` on Windows), so that code is platform independent.
      # Only looks for full string matches, substrings within a longer string are not
      # considered.
      #
      # However, only files that use the string `'/dev/null'` are targeted for detection.
      # This is because the string `'NUL'` is not limited to the null device.
      # This behavior results in false negatives when the `'/dev/null'` string is not used,
      # but it is a trade-off to avoid false positives. `NULL:`
      # Unlike `'NUL'`, `'NUL:'` is regarded as something like `C:` and is always detected.
      #
      # NOTE: Uses inside arrays and hashes are ignored.
      #
      # @safety
      #   It is possible for a string value to be changed if code is being run
      #   on multiple platforms and was previously hardcoded to a specific null device.
      #
      #   For example, the following string will change on Windows when changed to
      #   `File::NULL`:
      #
      #   [source,ruby]
      #   ----
      #   path = "/dev/null"
      #   ----
      #
      # @example
      #   # bad
      #   '/dev/null'
      #   'NUL'
      #   'NUL:'
      #
      #   # good
      #   File::NULL
      #
      #   # ok - inside an array
      #   null_devices = %w[/dev/null nul]
      #
      #   # ok - inside a hash
      #   { unix: "/dev/null", windows: "nul" }
      class FileNull < Base
        extend AutoCorrector

        REGEXP = %r{\A(/dev/null|NUL:?)\z}i.freeze
        MSG = 'Use `File::NULL` instead of `%<source>s`.'

        def on_new_investigation
          return unless (ast = processed_source.ast)

          @contain_dev_null_string_in_file = ast.each_descendant(:str).any? do |str|
            content = str.str_content

            valid_string?(content) && content.downcase == '/dev/null' # rubocop:disable Style/FileNull
          end
        end

        def on_str(node)
          value = node.value
          return unless valid_string?(value)
          return if acceptable?(node)
          return if value.downcase == 'nul' && !@contain_dev_null_string_in_file # rubocop:disable Style/FileNull
          return unless REGEXP.match?(value)

          add_offense(node, message: format(MSG, source: value)) do |corrector|
            corrector.replace(node, 'File::NULL')
          end
        end

        private

        def valid_string?(value)
          !value.empty? && value.valid_encoding?
        end

        def acceptable?(node)
          # Using a hardcoded null device is acceptable when inside an array or
          # inside a hash to ensure behavior doesn't change. A `str` that is part of
          # an interpolated or concatenated string (`dstr`) is not a standalone null
          # device either, and replacing it would corrupt the surrounding string.
          return false unless node.parent

          node.parent.type?(:array, :pair, :dstr)
        end
      end
    end
  end
end
