sabiql 3.0.0

A fast, driver-less TUI for browsing and editing PostgreSQL, MySQL, and SQLite databases
#!/usr/bin/env ruby
# frozen_string_literal: true

ROOT = File.expand_path("..", __dir__)
DEFAULT_PATHS = [
  "src",
  "tests",
].freeze
TEST_ONLY_ITEM_PATTERN = /\A(?:(?:pub(?:\([^)]*\))?|unsafe|async|extern(?:\s+"[^"]+")?)\s+)*(fn|struct|impl|const|type|static)\b/
MODULE_PATTERN = /\A(?:(?:pub(?:\([^)]*\))?|unsafe)\s+)*mod\s+([a-zA-Z0-9_]+)\s*\{/
PRIVATE_TEST_USE_PATTERN = /\Ause\b/

# This lint only checks mechanically detectable anti-patterns.
# Category-specific naming still relies on local rules and review.
def rust_files(paths)
  patterns = paths.flat_map do |path|
    absolute = File.expand_path(path, ROOT)
    if File.directory?(absolute)
      [File.join(absolute, "**/*.rs")]
    else
      [absolute]
    end
  end

  patterns.flat_map { |pattern| Dir.glob(pattern) }.sort.uniq
end

def starts_char_literal?(line, index)
  return false unless line[index] == "'"

  if line[index + 1] == "\\"
    closing = line.index("'", index + 2)
    return !closing.nil?
  end

  line[index + 2] == "'"
end

def count_braces(line, state)
  opens = 0
  closes = 0
  index = 0

  while index < line.length
    char = line[index]

    if state[:in_block_comment].positive?
      if char == "/" && line[index + 1] == "*"
        state[:in_block_comment] += 1
        index += 2
        next
      end

      if char == "*" && line[index + 1] == "/"
        state[:in_block_comment] -= 1
        index += 2
        next
      end

      index += 1
      next
    end

    if state[:in_single]
      if state[:escaped]
        state[:escaped] = false
        index += 1
        next
      end

      state[:escaped] = true if char == "\\"
      state[:in_single] = false if char == "'"
      index += 1
      next
    end

    if state[:in_double]
      if state[:escaped]
        state[:escaped] = false
        index += 1
        next
      end

      state[:escaped] = true if char == "\\"
      state[:in_double] = false if char == "\""
      index += 1
      next
    end

    if state[:in_raw]
      if char == "\""
        terminator = "\"" + ("#" * state[:raw_hashes])
        if line[index, terminator.length] == terminator
          state[:in_raw] = false
          state[:raw_hashes] = 0
          index += terminator.length
          next
        end
      end

      index += 1
      next
    end

    break if char == "/" && line[index + 1] == "/"

    if char == "/" && line[index + 1] == "*"
      state[:in_block_comment] = 1
      index += 2
      next
    end

    if char == "r"
      hashes = 0
      probe = index + 1
      while line[probe] == "#"
        hashes += 1
        probe += 1
      end

      if line[probe] == "\""
        state[:in_raw] = true
        state[:raw_hashes] = hashes
        index = probe + 1
        next
      end
    end

    case char
    when "'"
      state[:in_single] = true if starts_char_literal?(line, index)
    when "\""
      state[:in_double] = true
    when "{"
      opens += 1
    when "}"
      closes += 1
    end

    index += 1
  end

  [opens, closes]
end

def normalized_mod_name(name)
  return nil if name.nil?
  return nil if name == "tests"

  normalized = name.sub(/_tests?\z/, "")
  return nil if normalized.empty?

  normalized
end

def cfg_test_attribute?(stripped)
  stripped.delete(" \t") == "#[cfg(test)]"
end

def test_only_item_kind(stripped)
  match = stripped.match(TEST_ONLY_ITEM_PATTERN)
  match && match[1]
end

def private_test_use?(stripped)
  PRIVATE_TEST_USE_PATTERN.match?(stripped)
end

paths = ARGV.empty? ? DEFAULT_PATHS : ARGV
errors = []

rust_files(paths).each do |file|
  rel = file.delete_prefix("#{ROOT}/")
  lines = File.readlines(file, chomp: true)

  brace_depth = 0
  brace_state = {
    in_block_comment: 0,
    in_single: false,
    in_double: false,
    in_raw: false,
    raw_hashes: 0,
    escaped: false,
  }
  mod_stack = []
  pending_test_attr = false
  pending_cfg_test_attr = false
  seen_test_names = {}

  lines.each_with_index do |line, idx|
    stripped = line.strip
    line_no = idx + 1

    while mod_stack.any? && brace_depth < mod_stack.last[:depth]
      mod_stack.pop
    end

    module_match = stripped.match(MODULE_PATTERN)
    item_kind = test_only_item_kind(stripped)
    private_test_use = private_test_use?(stripped)
    cfg_test_module = pending_cfg_test_attr
    module_depth = mod_stack.last&.dig(:depth) || 0
    at_module_scope = brace_depth == module_depth
    inside_test_module = mod_stack.any? { |entry| entry[:test_module] }

    if stripped.start_with?("#[")
      pending_cfg_test_attr ||= cfg_test_attribute?(stripped)
    elsif pending_cfg_test_attr && (item_kind || module_match || private_test_use)
      if item_kind && at_module_scope && !inside_test_module
        errors << "#{rel}:#{line_no} test-only item must be inside a cfg(test) module: #{item_kind}"
      elsif private_test_use && at_module_scope && !inside_test_module
        errors << "#{rel}:#{line_no} test-only use must be inside a cfg(test) module"
      end

      pending_cfg_test_attr = false
    elsif pending_cfg_test_attr && !stripped.empty? && !stripped.start_with?("//")
      pending_cfg_test_attr = false
    end

    if module_match
      mod_stack << {
        name: module_match[1],
        depth: brace_depth + 1,
        test_module: cfg_test_module,
      }
      pending_cfg_test_attr = false
    end

    if stripped.match?(/^#\[(?:test|rstest|tokio::test)\b/) || (pending_test_attr && stripped.start_with?("#["))
      pending_test_attr = true
    elsif pending_test_attr && (match = stripped.match(/^(?:async\s+)?fn\s+([a-zA-Z0-9_]+)\s*\(/))
      test_name = match[1]

      if test_name.include?("returns_expected")
        errors << "#{rel}:#{line_no} test name must not contain `returns_expected`: #{test_name} (use a concrete result, drop the suffix if the result is already implied, or split the rstest if cases do not share one guarantee)"
      end

      mod_path = mod_stack.map { |entry| entry[:name] }.join("::")
      scoped_name = mod_path.empty? ? test_name : "#{mod_path}::#{test_name}"

      if (previous = seen_test_names[scoped_name])
        errors << "#{rel}:#{line_no} duplicate test name in same module: #{test_name} (first seen at line #{previous})"
      else
        seen_test_names[scoped_name] = line_no
      end

      mod_name = normalized_mod_name(mod_stack.last&.dig(:name))
      if mod_name && test_name.start_with?("#{mod_name}_")
        errors << "#{rel}:#{line_no} redundant module prefix `#{mod_name}_` in test name: #{test_name}"
      end

      pending_test_attr = false
    elsif pending_test_attr && !stripped.empty? && !stripped.start_with?("//")
      pending_test_attr = false
    end

    opens, closes = count_braces(line, brace_state)
    brace_depth += opens - closes
  end
end

if errors.empty?
  puts "test-name lint passed"
else
  warn "test-name lint failed:"
  errors.each { |error| warn "- #{error}" }
  exit 1
end