Class: LcpRuby::I18nLint

Inherits:
Object
  • Object
show all
Defined in:
lib/lcp_ruby/i18n_lint.rb

Overview

Walks ERB templates and Stimulus JavaScript controllers for hardcoded user-visible strings. Complements the RuboCop cop for Ruby (LcpRuby/NoHardcodedI18nString) which handles .rb files via AST.

Pure file walk — no Rails environment boot. Invoked by the ‘lcp_ruby:i18n_lint` rake task and exercised directly by specs.

Allow-list: append ‘<%# i18n-exempt: <reason> %>` or `// i18n-exempt: <reason>` on the same line as a flagged pattern to suppress that occurrence.

Constant Summary collapse

ROOT =
File.expand_path("../..", __dir__)
INCLUDE_GLOBS =
[
  "app/views/**/*.erb",
  "app/assets/javascripts/**/*.js"
].freeze
EXCLUDE_FRAGMENTS =
[
  "spec/dummy/",
  "spec/fixtures/",
  "examples/",
  "lib/generators/lcp_ruby/templates/"
].freeze
ERB_RULES =
{
  hardcoded_title: /<title>[^<][^<]*<\/title>/,
  pluralize_literal_noun: /pluralize\(\s*[^,)]+,\s*["'][A-Za-z]/,
  hardcoded_placeholder_attr: /\bplaceholder=["'][A-Z]/,
  hardcoded_title_attr: /\btitle=["'][A-Z]/,
  hardcoded_data_confirm: /\bdata-confirm=["'][A-Z]/
}.freeze
JS_RULES =
{
  alert_literal: /\balert\(\s*["'][A-Z]/,
  confirm_literal: /\bconfirm\(\s*["'][A-Z]/,
  prompt_literal: /\bprompt\(\s*["'][A-Z]/,
  text_content_literal: /\.textContent\s*=\s*["'][A-Z]/,
  title_literal: /\.title\s*=\s*["'][A-Z]/,
  placeholder_literal: /\.placeholder\s*=\s*["'][A-Z]/,
  set_attribute_literal: /setAttribute\(\s*['"](?:title|aria-label|placeholder)['"]\s*,\s*["'][A-Z]/,
  new_error_literal: /\bnew\s+Error\(\s*["'][A-Z]/,
  throw_error_literal: /\bthrow\s+new\s+Error\(\s*["'][A-Z]/
}.freeze
ERB_EXEMPT_RE =

ERB exemption marker: ‘<%# i18n-exempt: … %>`

/<%#\s*i18n-exempt:/.freeze
JS_EXEMPT_RE =

JS exemption marker: ‘// i18n-exempt: …`

%r{//\s*i18n-exempt:}.freeze

Instance Method Summary collapse

Instance Method Details

#pluralize_second_arg_is_translation?(line) ⇒ Boolean

Returns true when the second argument of ‘pluralize(…)` on this line starts with a `t(` or `I18n.t(` call (i.e. the noun is already a translation, not a hardcoded literal).

Uses a depth-counting scan so commas inside nested parens in the first argument (e.g. ‘pluralize(a > 1 ? 2 : 1, t(“…”))`) are handled correctly — a plain regex breaks on that shape.

Returns:

  • (Boolean)


91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/lcp_ruby/i18n_lint.rb', line 91

def pluralize_second_arg_is_translation?(line)
  idx = line.index("pluralize(")
  return false unless idx

  depth = 0
  i = idx + "pluralize(".length
  while i < line.length
    c = line[i]
    case c
    when "(" then depth += 1
    when ")"
      return false if depth.zero? # closed before we found a comma
      depth -= 1
    when ","
      if depth.zero?
        rest = line[(i + 1)..].lstrip
        return rest.start_with?("t(", "I18n.t(")
      end
    end
    i += 1
  end
  false
end

#runObject



54
55
56
57
58
59
60
61
62
63
64
# File 'lib/lcp_ruby/i18n_lint.rb', line 54

def run
  offenses = []
  files.each do |path|
    next if excluded?(path)
    next if path.end_with?(".js.erb") # compile-time template is exempt by design
    rules = path.end_with?(".erb") ? ERB_RULES : JS_RULES
    exempt_re = path.end_with?(".erb") ? ERB_EXEMPT_RE : JS_EXEMPT_RE
    offenses.concat(scan_file(path, rules, exempt_re))
  end
  offenses
end

#skip_false_positive?(rule_name, line) ⇒ Boolean

Per-rule post-filters to reduce false positives (spec §“Per-pattern classifier refinements”). Public so specs can exercise the individual classifiers without spinning up a full rake invocation.

Returns:

  • (Boolean)


69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/lcp_ruby/i18n_lint.rb', line 69

def skip_false_positive?(rule_name, line)
  case rule_name
  when :pluralize_literal_noun
    pluralize_second_arg_is_translation?(line)
  when :title_literal
    # Skip assignments like `x.title = data.title` / `el.title = record.title`
    line.match?(/\.title\s*=\s*[A-Za-z_][A-Za-z0-9_.]*(\[|\s*;|\s*$)/)
  when :new_error_literal, :throw_error_literal
    # Skip re-throws `new Error(err.message)` / `new Error(e)`
    line.match?(/new\s+Error\(\s*[a-z_][A-Za-z0-9_]*(?:\.\w+)?\s*\)/)
  else
    false
  end
end