lib/haml_lint/utils.rb
# frozen_string_literal: true
require 'pathname'
module HamlLint
# A miscellaneous set of utility functions.
module Utils # rubocop:disable Metrics/ModuleLength
module_function
# Returns whether a glob pattern (or any of a list of patterns) matches the
# specified file.
#
# This is defined here so our file globbing options are consistent
# everywhere we perform globbing.
#
# @param glob [String, Array]
# @param file [String]
# @return [Boolean]
def any_glob_matches?(globs_or_glob, file)
get_abs_and_rel_path(file).any? do |path|
Array(globs_or_glob).any? do |glob|
::File.fnmatch?(glob, path,
::File::FNM_PATHNAME | # Wildcards don't match path separators
::File::FNM_DOTMATCH) # `*` wildcard matches dotfiles
end
end
end
# Returns an array of two items, the first being the absolute path, the second
# the relative path.
#
# The relative path is relative to the current working dir. The path passed can
# be either relative or absolute.
#
# @param path [String] Path to get absolute and relative path of
# @return [Array<String>] Absolute and relative path
def get_abs_and_rel_path(path)
original_path = Pathname.new(path)
root_dir_path = Pathname.new(File.expand_path(Dir.pwd))
if original_path.absolute?
[path, original_path.relative_path_from(root_dir_path)]
else
[root_dir_path + original_path, path]
end
end
# Yields interpolated values within a block of text.
#
# @param text [String]
# @yield Passes interpolated code and line number that code appears on in
# the text.
# @yieldparam interpolated_code [String] code that was interpolated
# @yieldparam line [Integer] line number code appears on in text
def extract_interpolated_values(text) # rubocop:disable Metrics/AbcSize
dumped_text = text.dump
# Basically, match pairs of '\' and '\ followed by the letter 'n'
quoted_regex_s = "(#{Regexp.quote('\\\\')}|#{Regexp.quote('\\n')})"
newline_positions = extract_substring_positions(dumped_text, quoted_regex_s)
# Filter the matches to only keep those ending in 'n'.
# This way, escaped \n will not be considered
newline_positions.select! do |pos|
dumped_text[pos - 1] == 'n'
end
Haml::Util.handle_interpolation(dumped_text) do |scan|
line = (newline_positions.find_index { |marker| scan.charpos <= marker } ||
newline_positions.size) + 1
escape_count = (scan[2].size - 1) / 2
break unless escape_count.even?
dumped_interpolated_str = Haml::Util.balance(scan, '{', '}', 1)[0][0...-1]
# Hacky way to turn a dumped string back into a regular string
yield [eval('"' + dumped_interpolated_str + '"'), line] # rubocop:disable Security/Eval
end
end
def handle_interpolation_with_indexes(text)
newline_indexes = extract_substring_positions(text, "\n")
handle_interpolation_with_newline(text) do |scan|
line_index = newline_indexes.find_index { |index| scan.charpos <= index }
line_index ||= newline_indexes.size
line_start_char_index = if line_index == 0
0
else
newline_indexes[line_index - 1]
end
char_index = scan.charpos - line_start_char_index
yield scan, line_index, char_index
end
end
if Gem::Version.new(Haml::VERSION) >= Gem::Version.new('5')
# Same as Haml::Util.handle_interpolation, but enables multiline mode on the regex
def handle_interpolation_with_newline(str)
scan = StringScanner.new(str)
yield scan while scan.scan(/(.*?)(\\*)#([{@$])/m)
scan.rest
end
else
# Same as Haml::Util.handle_interpolation, but enables multiline mode on the regex
def handle_interpolation_with_newline(str)
scan = StringScanner.new(str)
yield scan while scan.scan(/(.*?)(\\*)\#\{/m)
scan.rest
end
end
# Returns indexes of all occurrences of a substring within a string.
#
# Note, this will not return overlapping substrings, so searching for "aa"
# in "aaa" will only find one substring, not two.
#
# @param text [String] the text to search
# @param substr [String] the substring to search for
# @return [Array<Integer>] list of indexes where the substring occurs
def extract_substring_positions(text, substr)
positions = []
scanner = StringScanner.new(text)
positions << scanner.charpos while scanner.scan(/(.*?)#{substr}/)
positions
end
# Converts a string containing underscores/hyphens/spaces into CamelCase.
#
# @param str [String]
# @return [String]
def camel_case(str)
str.split(/_|-| /).map { |part| part.sub(/^\w/, &:upcase) }.join
end
# Find all consecutive items satisfying the given block of a minimum size,
# yielding each group of consecutive items to the provided block.
#
# @param items [Array]
# @param satisfies [Proc] function that takes an item and returns true/false
# @param min_consecutive [Fixnum] minimum number of consecutive items before
# yielding the group
# @yield Passes list of consecutive items all matching the criteria defined
# by the `satisfies` {Proc} to the provided block
# @yieldparam group [Array] List of consecutive items
# @yieldreturn [Boolean] block should return whether item matches criteria
# for inclusion
def for_consecutive_items(items, satisfies, min_consecutive = 2)
current_index = -1
while (current_index += 1) < items.count
next unless satisfies[items[current_index]]
count = count_consecutive(items, current_index, &satisfies)
next unless count >= min_consecutive
# Yield the chunk of consecutive items
yield items[current_index...(current_index + count)]
current_index += count # Skip this patch of consecutive items to find more
end
end
# Count the number of consecutive items satisfying the given {Proc}.
#
# @param items [Array]
# @param offset [Fixnum] index to start searching from
# @yield [item] Passes item to the provided block.
# @yieldparam item [Object] Item to evaluate as matching criteria for
# inclusion
# @yieldreturn [Boolean] whether to include the item
# @return [Integer]
def count_consecutive(items, offset = 0)
count = 1
count += 1 while (offset + count < items.count) && yield(items[offset + count])
count
end
# Process ERB, providing some values for for versions to it
#
# @param content [String] the (usually yaml) content to process
# @return [String]
def process_erb(content)
# Variables for use in the ERB's post-processing
rubocop_version = HamlLint::VersionComparer.for_rubocop
ERB.new(content).result(binding)
end
def insert_after_indentation(code, insert)
index = code.index(/\S/)
"#{code[0...index]}#{insert}#{code[index..]}"
end
# Calls a block of code with a modified set of environment variables,
# restoring them once the code has executed.
#
# @param env [Hash] environment variables to set
def with_environment(env)
old_env = {}
env.each do |var, value|
old_env[var] = ENV[var.to_s]
ENV[var.to_s] = value
end
yield
ensure
old_env.each { |var, value| ENV[var.to_s] = value }
end
def indent(string, nb_indent)
if nb_indent < 0
string.gsub(/^ {1,#{-nb_indent}}/, '')
else
string.gsub(/^/, ' ' * nb_indent)
end
end
def map_subset!(array, range, &block)
subset = array[range]
return if subset.nil? || subset.empty?
array[range] = subset.map(&block)
end
def map_after_first!(array, &block)
map_subset!(array, 1..-1, &block)
end
# Returns true if line is only whitespace.
# Note, this is not like blank? is rails. For nil, this returns false.
def is_blank_line?(line)
line && line.index(/\S/).nil?
end
def check_error_when_compiling_haml(haml_string)
begin
ruby_code = ::HamlLint::Adapter.detect_class.new(haml_string).precompile
rescue StandardError => e
return e
end
eval("BEGIN {return nil}; #{ruby_code}", binding, __FILE__, __LINE__) # rubocop:disable Security/Eval
# The eval will return nil
rescue ::SyntaxError
$!
end
# Overrides the global stdin, stdout and stderr while within the block, to
# push a string in stdin, and capture both stdout and stderr which are returned.
#
# @param stdin_str [String] the string to push in as stdin
# @param _block [Block] the block to perform with the overridden std streams
# @return [String, String]
def with_captured_streams(stdin_str, &_block)
original_stdin = $stdin
# The dup is needed so that stdin_data isn't altered (encoding-wise at least)
$stdin = StringIO.new(stdin_str.dup)
begin
original_stdout = $stdout
$stdout = StringIO.new
begin
original_stderr = $stderr
$stderr = StringIO.new
yield
[$stdout.string, $stderr.string]
ensure
$stderr = original_stderr
end
ensure
$stdout = original_stdout
end
ensure
$stdin = original_stdin
end
def regexp_for_parts(parts, join_regexp, prefix: nil, suffix: nil)
regexp_code = parts.map { |c| Regexp.quote(c) }.join(join_regexp)
regexp_code = "#{prefix}#{regexp_code}#{suffix}"
Regexp.new(regexp_code)
end
end
end