Class: LcpRuby::I18nLint
- Inherits:
-
Object
- Object
- LcpRuby::I18nLint
- 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.("../..", __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
-
#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).
- #run ⇒ Object
-
#skip_false_positive?(rule_name, line) ⇒ Boolean
Per-rule post-filters to reduce false positives (spec §“Per-pattern classifier refinements”).
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.
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 |
#run ⇒ Object
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.
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 |