a11y-lint 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a0d462b5e30cff7f4b1c5adaa3eeb9d2ac1a1aa9d3dad477b1841e38e271f22a
4
- data.tar.gz: 66d8731b7371b98e4fede0445536d994fcd3405721dfb0617f1c139bd5fce925
3
+ metadata.gz: ecfe715cda47636bb8dc87f9cf3655c641d33d2e10cfd8471be73b2dd7336a1f
4
+ data.tar.gz: 818bb7d22993ac9036ac148d60adce32c2ef7ee342bc741bbd8e64d893414d2f
5
5
  SHA512:
6
- metadata.gz: 6459a600d8cbd2465d5a9096eb99e36f319d1576cc685273b78505c636c15f77590104eec89ff4142d6525f70d5fc861822837fdf55cca9ed1afbad9a20011b5
7
- data.tar.gz: 3c22904b9d168f559b9bc83522e1a5422fad6f2dd3d5beffd158ad06135ef6dacc85fdf3d832970b330ed1f4fd25c68a41b3bd8a34ef57ca6df8c2211a694dca
6
+ metadata.gz: 3a0d354908cfc0c670db3556f0854e1e91f0486ae502ff6f93395a95923d1a6b7b456bd625ffb476ba8f7e8d3f8bea5f859ed4875e0cf172770be5e87da8517f
7
+ data.tar.gz: e0e5e582649edc60f0df35635af190beb49b2ab69c9da392e9ae31326918c6704962b5a5cd6d60f8e1bb8bf47474a9d4b2fdb18baf424f3013871a7d3cc17a10
data/.rubocop.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  AllCops:
2
- TargetRubyVersion: 3.1
2
+ TargetRubyVersion: 3.3
3
3
 
4
4
  Style/StringLiterals:
5
5
  EnforcedStyle: double_quotes
@@ -13,6 +13,12 @@ Layout/LineLength:
13
13
  - "a11y-lint.gemspec"
14
14
 
15
15
  Metrics/ClassLength:
16
+ Enabled: false
17
+
18
+ Metrics/ModuleLength:
19
+ Enabled: false
20
+
21
+ Metrics/AbcSize:
16
22
  Exclude:
17
23
  - "test/**/*"
18
24
 
data/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.10.0] - 2026-04-15
11
+
12
+ ### Changed
13
+
14
+ - Introduce `A11y::Lint::CallNode` wrapper so rules express accessibility logic, not AST traversal
15
+ - Expose Prism `CallNode` from `SlimNode` and `ErbNode` via the new `RubyCode` parser
16
+ - Expose Prism `CallNode` from `PhlexNode` instead of converting to string
17
+ - Extract Phlex HTML tag constants into `PhlexTags` module
18
+
19
+ ## [0.9.0] - 2026-04-15
20
+
21
+ ### Changed
22
+
23
+ - **Breaking:** Require Ruby >= 3.3.0 (was 3.1.0). Prism ships with Ruby 3.3+ as a bundled gem
24
+ - **Breaking:** `slim` is now an optional dependency. Add `gem "slim"` to your Gemfile if you lint `.slim` files. A `SlimLoadError` is raised with a helpful message if the gem is missing
25
+ - `prism` is no longer a declared dependency (bundled with Ruby 3.3+)
26
+ - Replace Ripper with Prism for Ruby code parsing in `ImageTagMissingAlt` and `MissingAccessibleName` rules
27
+ - Custom error classes are now defined in `lib/a11y/lint/errors.rb`
28
+
29
+ ### Fixed
30
+
31
+ - `MissingAccessibleName`: fix false positive when block contains visible text or HTML tags (e.g. `link_to do` with a `span` or plain text inside)
32
+ - Add `block_body_codes` and `block_has_text_children?` to all three node types (Slim, ERB, Phlex) so block content inspection works across all pipelines
33
+
8
34
  ## [0.8.0] - 2026-04-14
9
35
 
10
36
  ### Added
data/CLAUDE.md CHANGED
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
4
4
 
5
5
  ## Project Overview
6
6
 
7
- a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::Lint` module namespace. Requires Ruby >= 3.1.0.
7
+ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::Lint` module namespace. Requires Ruby >= 3.3.0.
8
8
 
9
9
  ## Commands
10
10
 
@@ -24,8 +24,9 @@ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::L
24
24
  - **Slim pipeline:** `SlimRunner` parses Slim templates via `Slim::Parser`; `SlimNode` wraps Slim S-expressions
25
25
  - **ERB pipeline:** `ErbRunner` parses ERB templates via Nokogiri; `ErbNode` wraps Nokogiri nodes and extracted `<%= %>` Ruby code
26
26
  - **Phlex pipeline:** `PhlexRunner` parses Phlex view classes via Prism; `PhlexNode` wraps Prism AST call nodes; detects Phlex files by the presence of `def view_template`
27
+ - **Ruby code parsing:** `RubyCode` (`lib/a11y/lint/ruby_code.rb`) — parses a Ruby code string once with Prism and exposes the resulting `CallNode`; used by `SlimNode` and `ErbNode` so rules work against a parsed AST instead of re-parsing strings
27
28
  - **Configuration:** `lib/a11y/lint/configuration.rb` — loads `.a11y-lint.yml` to enable/disable individual rules; searches upward from the target path
28
- - **Rules:** `lib/a11y/lint/rules/` — organized by WCAG principle (`perceivable/`, `operable/`, `understandable/`, `robust/`); rules implement `check(node)` against the shared node interface (`tag_name`, `attribute?`, `attributes`, `ruby_code`, `line`)
29
+ - **Rules:** `lib/a11y/lint/rules/` — organized by WCAG principle (`perceivable/`, `operable/`, `understandable/`, `robust/`); rules implement `check(node)` against the shared node interface (`tag_name`, `attribute?`, `attributes`, `call_node`, `line`)
29
30
  - **Version:** `lib/a11y/lint/version.rb`
30
31
  - **Type signatures (RBS):** `sig/a11y/lint.rbs`
31
32
  - **Tests:** `test/` directory using Minitest; test helper at `test/test_helper.rb`
@@ -34,5 +35,5 @@ a11y-lint is a Ruby gem (v0.1.0) for accessibility linting. It uses the `A11y::L
34
35
  ## Code Style
35
36
 
36
37
  RuboCop is configured in `.rubocop.yml`:
37
- - Target Ruby version: 3.1
38
+ - Target Ruby version: 3.3
38
39
  - Enforces double quotes for strings
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11y
4
+ module Lint
5
+ # Shared block-content queries for node types.
6
+ # Depends on the host class implementing
7
+ # #block_has_text_children? and #block_body_codes.
8
+ module BlockInspection
9
+ ICON_HELPERS = %w[inline_svg icon image_tag svg_icon].freeze
10
+
11
+ def block_has_only_icon_helpers?
12
+ return false if block_has_text_children?
13
+
14
+ codes = block_body_codes
15
+ return true unless codes&.any?
16
+
17
+ codes.all? { |code| icon_helper_call?(code) }
18
+ end
19
+
20
+ private
21
+
22
+ def icon_helper_call?(code)
23
+ call = RubyCode.new(code).call_node
24
+ call && ICON_HELPERS.include?(call.method_name)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module A11y
6
+ module Lint
7
+ # Wraps a Prism::CallNode with a rule-friendly query API.
8
+ class CallNode
9
+ attr_reader :prism_node
10
+
11
+ def initialize(prism_node)
12
+ @prism_node = prism_node
13
+ end
14
+
15
+ def method_name
16
+ @prism_node.name.to_s
17
+ end
18
+
19
+ # Checks for a keyword argument by name.
20
+ # keyword?(:alt) => alt: or "alt" =>
21
+ # keyword?(:aria, :label) => aria: { label: ... }
22
+ # keyword?(:"aria-label") => "aria-label" =>
23
+ def keyword?(*keys)
24
+ return false unless (kw_hash = find_keyword_hash)
25
+
26
+ if keys.length == 1
27
+ flat_keyword?(kw_hash, keys[0])
28
+ else
29
+ nested_keyword?(kw_hash, keys[0], keys[1])
30
+ end
31
+ end
32
+
33
+ def positional_args
34
+ return [] unless @prism_node.arguments
35
+
36
+ @prism_node.arguments.arguments.reject do |a|
37
+ a.is_a?(Prism::KeywordHashNode)
38
+ end
39
+ end
40
+
41
+ def first_positional_arg_empty_string?
42
+ first = positional_args.first
43
+ first.is_a?(Prism::StringNode) && first.unescaped.empty?
44
+ end
45
+
46
+ def block?
47
+ !@prism_node.block.nil?
48
+ end
49
+
50
+ # Finds a receiverless call by method name in this node's
51
+ # subtree (including self). Returns a CallNode or nil.
52
+ def find(name)
53
+ found = search_for_call(@prism_node, name)
54
+ found ? self.class.new(found) : nil
55
+ end
56
+
57
+ private
58
+
59
+ def flat_keyword?(kw_hash, key)
60
+ kw_hash.elements.any? do |assoc|
61
+ key_name(assoc) == key.to_s
62
+ end
63
+ end
64
+
65
+ def nested_keyword?(kw_hash, outer_key, inner_key)
66
+ assoc = kw_hash.elements.find do |a|
67
+ key_name(a) == outer_key.to_s
68
+ end
69
+ return false unless assoc&.value.is_a?(Prism::HashNode)
70
+
71
+ assoc.value.elements.any? do |inner|
72
+ key_name(inner) == inner_key.to_s
73
+ end
74
+ end
75
+
76
+ def find_keyword_hash
77
+ return unless @prism_node.arguments
78
+
79
+ @prism_node.arguments.arguments.find do |arg|
80
+ arg.is_a?(Prism::KeywordHashNode)
81
+ end
82
+ end
83
+
84
+ def key_name(assoc)
85
+ return unless assoc.is_a?(Prism::AssocNode)
86
+
87
+ case assoc.key
88
+ when Prism::SymbolNode then assoc.key.unescaped
89
+ when Prism::StringNode then assoc.key.unescaped
90
+ end
91
+ end
92
+
93
+ def search_for_call(node, name)
94
+ if node.is_a?(Prism::CallNode) &&
95
+ node.receiver.nil? &&
96
+ node.name.to_s == name
97
+ return node
98
+ end
99
+
100
+ node.child_nodes.compact.each do |child|
101
+ found = search_for_call(child, name)
102
+ return found if found
103
+ end
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ end
data/lib/a11y/lint/cli.rb CHANGED
@@ -4,8 +4,7 @@ require "optparse"
4
4
 
5
5
  module A11y
6
6
  module Lint
7
- # Command-line interface for running accessibility
8
- # linting on Slim templates.
7
+ # Command-line interface for running accessibility linting.
9
8
  class CLI
10
9
  def initialize(argv, stdout: $stdout, stderr: $stderr)
11
10
  @argv = argv
@@ -5,16 +5,26 @@ module A11y
5
5
  # Wraps a Nokogiri node or extracted ERB output tag
6
6
  # as a queryable node for lint rules.
7
7
  class ErbNode
8
- attr_reader :line
8
+ include BlockInspection
9
9
 
10
- def initialize(line:, nokogiri_node: nil, ruby_code: nil)
11
- @nokogiri_node = nokogiri_node
12
- @ruby_code_string = ruby_code
10
+ attr_reader(:block_body_codes, :line, :ruby_code)
11
+
12
+ def initialize(
13
+ line:,
14
+ block_body_codes: nil,
15
+ block_has_text_children: false,
16
+ nokogiri_node: nil,
17
+ ruby_code: nil
18
+ )
13
19
  @line = line
20
+ @block_body_codes = block_body_codes
21
+ @block_has_text_children = block_has_text_children
22
+ @nokogiri_node = nokogiri_node
23
+ @ruby_code = ruby_code
14
24
  end
15
25
 
16
26
  def tag_name
17
- @nokogiri_node&.name
27
+ nokogiri_node&.name
18
28
  end
19
29
 
20
30
  def attribute?(name)
@@ -25,25 +35,31 @@ module A11y
25
35
  @attributes ||= extract_attributes
26
36
  end
27
37
 
28
- def ruby_code
29
- @ruby_code_string
38
+ def call_node
39
+ @call_node ||= RubyCode.new(ruby_code).call_node
40
+ end
41
+
42
+ def block_has_text_children?
43
+ @block_has_text_children
30
44
  end
31
45
 
32
46
  # Returns direct element children wrapped as ErbNode objects.
33
47
  def children
34
- return [] unless @nokogiri_node
48
+ return [] unless nokogiri_node
35
49
 
36
- @nokogiri_node.element_children.map do |child|
50
+ nokogiri_node.element_children.map do |child|
37
51
  ErbNode.new(nokogiri_node: child, line: child.line)
38
52
  end
39
53
  end
40
54
 
41
55
  private
42
56
 
57
+ attr_reader(:nokogiri_node)
58
+
43
59
  def extract_attributes
44
- return {} unless @nokogiri_node
60
+ return {} unless nokogiri_node
45
61
 
46
- @nokogiri_node
62
+ nokogiri_node
47
63
  .attributes
48
64
  .each_with_object({}) do |(name, _attr), result|
49
65
  result[name] = true
@@ -53,12 +53,38 @@ module A11y
53
53
  source.scan(ERB_OUTPUT_TAG) do
54
54
  match = Regexp.last_match
55
55
  code = match[1]
56
- line_number = source[0...match.begin(0)].count("\n") + 1
57
- node = ErbNode.new(ruby_code: code, line: line_number)
58
- check_node(node)
56
+ line = source[0...match.begin(0)].count("\n") + 1
57
+ check_node(
58
+ build_erb_output_node(source, code, line, match.end(0))
59
+ )
59
60
  end
60
61
  end
61
62
 
63
+ def build_erb_output_node(source, code, line, match_end)
64
+ block_body_codes, block_has_text =
65
+ extract_block_info(source, code, match_end)
66
+
67
+ ErbNode.new(
68
+ ruby_code: code, line: line,
69
+ block_body_codes: block_body_codes,
70
+ block_has_text_children: block_has_text
71
+ )
72
+ end
73
+
74
+ def extract_block_info(source, code, match_end)
75
+ return [nil, false] unless code.match?(/\s+do\s*\z/)
76
+
77
+ rest = source[match_end..]
78
+ end_match = rest.match(/<%-?\s*end\s*-?%>/m)
79
+ return [nil, false] unless end_match
80
+
81
+ block_content = rest[0...end_match.begin(0)]
82
+ codes = block_content.scan(ERB_OUTPUT_TAG).map { |m| m[0].strip }
83
+ text_only = block_content.gsub(ERB_TAG, "").strip
84
+
85
+ [codes, !text_only.empty?]
86
+ end
87
+
62
88
  def check_node(node)
63
89
  rules.each do |rule_class|
64
90
  message = rule_class.check(node)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11y
4
+ module Lint
5
+ class Error < StandardError; end
6
+
7
+ # Raised when slim is not installed.
8
+ class SlimLoadError < Error
9
+ def initialize
10
+ super(
11
+ "a11y-lint needs the `slim` gem to lint .slim files. " \
12
+ "Add `gem \"slim\"` to your Gemfile."
13
+ )
14
+ end
15
+ end
16
+ end
17
+ end
@@ -5,66 +5,45 @@ module A11y
5
5
  # Wraps a Phlex HTML tag call or helper method call
6
6
  # as a queryable node for lint rules.
7
7
  class PhlexNode
8
- attr_reader :line, :children
9
-
10
- # Phlex method names that map to a different HTML tag.
11
- TAG_ALIASES = {
12
- "ruby_element" => "ruby",
13
- "template_tag" => "template"
14
- }.freeze
15
-
16
- HTML_TAGS = Set.new(
17
- %w[
18
- a abbr address article aside b bdi bdo
19
- blockquote body br button caption cite code
20
- col colgroup data datalist dd del details dfn
21
- dialog div dl dt em embed fieldset figcaption
22
- figure footer form h1 h2 h3 h4 h5 h6 head
23
- header hgroup hr html i iframe img input ins
24
- kbd label legend li link main map mark menu
25
- meter nav noscript object ol optgroup option
26
- output p picture pre progress q rp rt
27
- ruby_element s samp script search section
28
- select slot small span strong style sub
29
- summary sup table tbody td template_tag
30
- textarea tfoot th thead time title tr u ul
31
- var video wbr
32
- ]
33
- ).freeze
8
+ include BlockInspection
9
+ extend PhlexTags
10
+
11
+ attr_reader(
12
+ :attributes,
13
+ :block_body_codes,
14
+ :call_node,
15
+ :children,
16
+ :line,
17
+ :tag_name
18
+ )
34
19
 
20
+ # rubocop:disable Metrics/ParameterLists
35
21
  def initialize(
36
- line:, tag_name: nil,
37
- attributes: {}, ruby_code: nil, children: []
22
+ line:, tag_name: nil, attributes: {},
23
+ call_node: nil, children: [],
24
+ block_body_codes: nil,
25
+ block_has_text_children: false
38
26
  )
39
- @tag_name_string = tag_name
40
- @attributes_hash = attributes
41
- @ruby_code_string = ruby_code
27
+ @tag_name = tag_name
28
+ @attributes = attributes
29
+ @call_node = call_node
42
30
  @line = line
43
31
  @children = children
32
+ @block_body_codes = block_body_codes
33
+ @block_has_text_children = block_has_text_children
44
34
  end
45
-
46
- def tag_name
47
- @tag_name_string
48
- end
49
-
50
- def attribute?(name)
51
- @attributes_hash.key?(name)
52
- end
53
-
54
- def attributes
55
- @attributes_hash
56
- end
35
+ # rubocop:enable Metrics/ParameterLists
57
36
 
58
37
  def ruby_code
59
- @ruby_code_string
38
+ nil
60
39
  end
61
40
 
62
- def self.html_tag?(method_name)
63
- HTML_TAGS.include?(method_name)
41
+ def attribute?(name)
42
+ attributes.key?(name)
64
43
  end
65
44
 
66
- def self.html_tag_name(method_name)
67
- TAG_ALIASES.fetch(method_name, method_name)
45
+ def block_has_text_children?
46
+ @block_has_text_children
68
47
  end
69
48
 
70
49
  def self.build_tag(call_node, children: [])
@@ -77,10 +56,16 @@ module A11y
77
56
  )
78
57
  end
79
58
 
80
- def self.build_helper(call_node, source)
59
+ def self.build_helper(
60
+ call_node,
61
+ block_body_codes: nil,
62
+ block_has_text_children: false
63
+ )
81
64
  new(
82
- ruby_code: ruby_code_for(call_node, source),
83
- line: call_node.location.start_line
65
+ call_node: CallNode.new(call_node),
66
+ line: call_node.location.start_line,
67
+ block_body_codes: block_body_codes,
68
+ block_has_text_children: block_has_text_children
84
69
  )
85
70
  end
86
71
 
@@ -106,16 +91,7 @@ module A11y
106
91
  end
107
92
  end
108
93
 
109
- def self.ruby_code_for(call_node, source)
110
- return call_node.slice unless call_node.block
111
-
112
- stop = call_node.block.location.start_offset
113
- start = call_node.location.start_offset
114
- "#{source[start...stop].rstrip} do"
115
- end
116
-
117
94
  private_class_method :kwarg_key, :kwarg_nodes,
118
- :ruby_code_for,
119
95
  :extract_attributes
120
96
  end
121
97
  end
@@ -37,8 +37,7 @@ module A11y
37
37
  end
38
38
 
39
39
  def process_call(node)
40
- name = node.name.to_s
41
- if PhlexNode.html_tag?(name)
40
+ if PhlexNode.html_tag?(node.name.to_s)
42
41
  check_tag(node)
43
42
  else
44
43
  check_helper(node)
@@ -51,16 +50,20 @@ module A11y
51
50
  end
52
51
 
53
52
  def check_helper(node)
54
- check_node(PhlexNode.build_helper(node, @source))
53
+ codes, has_text = analyze_helper_block(node)
54
+ helper = PhlexNode.build_helper(
55
+ node,
56
+ block_body_codes: codes,
57
+ block_has_text_children: has_text
58
+ )
59
+ check_node(helper)
55
60
  walk_block(node.block)
56
61
  end
57
62
 
58
63
  def collect_block_children(block)
59
64
  return [] unless block.is_a?(Prism::BlockNode)
60
65
 
61
- children = []
62
- gather_children(block, children)
63
- children
66
+ [].tap { |c| gather_children(block, c) }
64
67
  end
65
68
 
66
69
  def gather_children(parent, result)
@@ -77,11 +80,32 @@ module A11y
77
80
 
78
81
  def gather_tag_child(child, result)
79
82
  kids = collect_block_children(child.block)
80
- node = PhlexNode.build_tag(child, children: kids)
81
- result << node
82
- check_node(node)
83
+ result << PhlexNode.build_tag(child, children: kids)
84
+ check_node(result.last)
83
85
  end
84
86
 
87
+ def analyze_helper_block(call_node)
88
+ block = call_node.block
89
+ return [nil, false] unless block.is_a?(Prism::BlockNode)
90
+
91
+ codes = []
92
+ has_text = scan_block_content(block, codes)
93
+ [codes.empty? ? nil : codes, has_text]
94
+ end
95
+
96
+ # rubocop:disable Metrics/CyclomaticComplexity
97
+ def scan_block_content(node, codes)
98
+ node.child_nodes.compact.each do |child|
99
+ return true if child.is_a?(Prism::YieldNode)
100
+ return true if tag_call?(child) && child.block
101
+ next if tag_call?(child)
102
+ next codes << child.slice if receiverless_call?(child)
103
+ return true if scan_block_content(child, codes)
104
+ end
105
+ false
106
+ end
107
+ # rubocop:enable Metrics/CyclomaticComplexity
108
+
85
109
  def walk_block(block)
86
110
  return unless block.is_a?(Prism::BlockNode)
87
111
 
@@ -89,8 +113,7 @@ module A11y
89
113
  end
90
114
 
91
115
  def tag_call?(node)
92
- receiverless_call?(node) &&
93
- PhlexNode.html_tag?(node.name.to_s)
116
+ receiverless_call?(node) && PhlexNode.html_tag?(node.name.to_s)
94
117
  end
95
118
 
96
119
  def receiverless_call?(node)
@@ -103,10 +126,8 @@ module A11y
103
126
  next unless message
104
127
 
105
128
  @offenses << Offense.new(
106
- rule: rule_class.rule_name,
107
- filename: @filename,
108
- line: node.line,
109
- message: message
129
+ rule: rule_class.rule_name, filename: @filename,
130
+ line: node.line, message: message
110
131
  )
111
132
  end
112
133
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module A11y
4
+ module Lint
5
+ # Maps Phlex method names to their HTML tag equivalents.
6
+ module PhlexTags
7
+ # Phlex method names that map to a different HTML tag.
8
+ TAG_ALIASES = {
9
+ "ruby_element" => "ruby",
10
+ "template_tag" => "template"
11
+ }.freeze
12
+
13
+ HTML_TAGS = Set.new(
14
+ %w[
15
+ a
16
+ abbr
17
+ address
18
+ article
19
+ aside
20
+ b
21
+ bdi
22
+ bdo
23
+ blockquote
24
+ body
25
+ br
26
+ button
27
+ caption
28
+ cite
29
+ code
30
+ col
31
+ colgroup
32
+ data
33
+ datalist
34
+ dd
35
+ del
36
+ details
37
+ dfn
38
+ dialog
39
+ div
40
+ dl
41
+ dt
42
+ em
43
+ embed
44
+ fieldset
45
+ figcaption
46
+ figure
47
+ footer
48
+ form
49
+ h1
50
+ h2
51
+ h3
52
+ h4
53
+ h5
54
+ h6
55
+ head
56
+ header
57
+ hgroup
58
+ hr
59
+ html
60
+ i
61
+ iframe
62
+ img
63
+ input
64
+ ins
65
+ kbd
66
+ label
67
+ legend
68
+ li
69
+ link
70
+ main
71
+ map
72
+ mark
73
+ menu
74
+ meter
75
+ nav
76
+ noscript
77
+ object
78
+ ol
79
+ optgroup
80
+ option
81
+ output
82
+ p
83
+ picture
84
+ pre
85
+ progress
86
+ q
87
+ rp
88
+ rt
89
+ ruby_element
90
+ s
91
+ samp
92
+ script
93
+ search
94
+ section
95
+ select
96
+ slot
97
+ small
98
+ span
99
+ strong
100
+ style
101
+ sub
102
+ summary
103
+ sup
104
+ table
105
+ tbody
106
+ td
107
+ template_tag
108
+ textarea
109
+ tfoot
110
+ th
111
+ thead
112
+ time
113
+ title
114
+ tr
115
+ u
116
+ ul
117
+ var
118
+ video
119
+ wbr
120
+ ]
121
+ ).freeze
122
+
123
+ def html_tag?(method_name)
124
+ HTML_TAGS.include?(method_name)
125
+ end
126
+
127
+ def html_tag_name(method_name)
128
+ TAG_ALIASES.fetch(method_name, method_name)
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module A11y
6
+ module Lint
7
+ # Represents a Ruby code string extracted from a template.
8
+ # Parses the code with Prism and exposes the resulting CallNode.
9
+ class RubyCode
10
+ def initialize(code)
11
+ @code = code
12
+ end
13
+
14
+ # Returns the Prism::CallNode for the outermost receiverless
15
+ # method call in the code, or nil if none exists.
16
+ def call_node
17
+ @call_node ||= parse
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader(:code)
23
+
24
+ # Parses the code string into a Prism AST and returns the first
25
+ # receiverless CallNode, or nil if the code is empty, invalid,
26
+ # or contains no method calls (e.g. a plain variable or literal).
27
+ def parse
28
+ return if code.nil? || code.empty?
29
+ return unless prism_parse_result.success?
30
+
31
+ prism_node = find_receiverless_call(prism_parse_result.value)
32
+ prism_node ? CallNode.new(prism_node) : nil
33
+ end
34
+
35
+ # Walks the Prism AST to find the first method call without a
36
+ # receiver (e.g. `link_to(...)` rather than `obj.link_to(...)`).
37
+ # Rails helper calls in templates are always receiverless.
38
+ def find_receiverless_call(node)
39
+ return node if node.is_a?(Prism::CallNode) && node.receiver.nil?
40
+
41
+ node.child_nodes.compact.each do |child|
42
+ found = find_receiverless_call(child)
43
+ return found if found
44
+ end
45
+ nil
46
+ end
47
+
48
+ # Prism.parse returns a Prism::ParseResult which contains the
49
+ # AST (via .value) and whether the parse succeeded (via .success?).
50
+ # The AST is always present since Prism does error-tolerant parsing.
51
+ def prism_parse_result
52
+ @prism_parse_result ||= Prism.parse(source)
53
+ end
54
+
55
+ # Slim/ERB block forms end with ` do` (e.g. `link_to("#") do`)
56
+ # which isn't valid Ruby on its own. Appending `\nend` makes it
57
+ # parseable and gives the resulting CallNode a `.block` attribute.
58
+ def source
59
+ if code.match?(/\s+do\s*\z/)
60
+ "#{code}\nend"
61
+ else
62
+ code
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -19,6 +19,10 @@ module A11y
19
19
  def check
20
20
  raise NotImplementedError
21
21
  end
22
+
23
+ private
24
+
25
+ attr_reader(:node)
22
26
  end
23
27
  end
24
28
  end
@@ -1,88 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ripper"
4
-
5
3
  module A11y
6
4
  module Lint
7
5
  module Rules
8
6
  # Checks that image_tag calls include an alt option (WCAG 1.1.1).
9
7
  class ImageTagMissingAlt < Rule
10
8
  def check
11
- return unless an_image_tag_without_an_alt_attribute?
9
+ return if no_offense?
12
10
 
13
11
  "image_tag is missing an alt option (WCAG 1.1.1)"
14
12
  end
15
13
 
16
14
  private
17
15
 
18
- def an_image_tag_without_an_alt_attribute?
19
- code = @node.ruby_code
20
- return false unless code
21
-
22
- sexp = Ripper.sexp(code)
23
- return false unless sexp
24
-
25
- call = extract_image_tag_call(sexp)
26
- call && !alt_key_within?(call)
27
- end
28
-
29
- # Walks the Ripper S-expression tree to find
30
- # the image_tag call node, if present.
31
- def extract_image_tag_call(sexp)
32
- return unless sexp.is_a?(Array)
33
- return sexp if image_tag_call?(sexp)
34
-
35
- sexp.each do |child|
36
- result = extract_image_tag_call(child)
37
- return result if result
38
- end
39
-
40
- nil
41
- end
42
-
43
- # Matches both calling styles:
44
- # image_tag "photo.jpg" => :command
45
- # image_tag("photo.jpg") => :method_add_arg
46
- def image_tag_call?(sexp)
47
- case sexp
48
- in [:command, [:@ident, "image_tag", *], *] then true
49
- in [:method_add_arg, [:fcall, [:@ident, "image_tag", *]], *] then true
50
- else false
51
- end
52
- end
53
-
54
- # Recursively searches the sexp for an
55
- # :assoc_new node whose key is "alt".
56
- def alt_key_within?(sexp)
57
- return true if alt_key?(sexp)
58
- return false unless sexp.is_a?(Array)
59
-
60
- sexp.any? { |child| alt_key_within?(child) }
61
- end
62
-
63
- # Checks if a sexp is a hash pair (assoc_new)
64
- # with "alt" as the key.
65
- def alt_key?(sexp)
66
- return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
67
-
68
- alt_key_value?(sexp[1])
69
- end
70
-
71
- def alt_key_value?(key)
72
- alt_symbol_key?(key) || alt_string_key?(key)
73
- end
74
-
75
- # Matches symbol-style key: `alt: "..."`
76
- def alt_symbol_key?(key)
77
- key in [:@label, "alt:", *]
16
+ def no_offense?
17
+ !image_tag || image_tag.keyword?(:alt)
78
18
  end
79
19
 
80
- # Matches string-style key: `"alt" => "..."`
81
- def alt_string_key?(key)
82
- key in [
83
- :string_literal,
84
- [:string_content, [:@tstring_content, "alt", *]]
85
- ]
20
+ def image_tag
21
+ @image_tag ||= node.call_node&.find("image_tag")
86
22
  end
87
23
  end
88
24
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ripper"
4
-
5
3
  module A11y
6
4
  module Lint
7
5
  module Rules
@@ -11,120 +9,37 @@ module A11y
11
9
  METHODS = %w[link_to external_link_to button_tag].freeze
12
10
 
13
11
  def check
14
- return unless (code = @node.ruby_code)
15
-
16
- clean_code = code.sub(/\s+do\s*\z/, "")
17
- is_block = clean_code != code
18
- call = parse_call(clean_code)
19
- return unless call
20
- return if aria_label_within?(call)
21
- return unless first_arg_empty_string?(call) || is_block
12
+ return if no_offense?
22
13
 
23
- offense_message(extract_method_name(call))
14
+ offense_message(helper_call.method_name)
24
15
  end
25
16
 
26
17
  private
27
18
 
28
- def offense_message(method_name)
29
- <<~MSG.strip
30
- #{method_name} missing an accessible name \
31
- requires an aria-label (WCAG 4.1.2)
32
- MSG
19
+ def no_offense?
20
+ !helper_call ||
21
+ aria_label? ||
22
+ !(helper_call.first_positional_arg_empty_string? ||
23
+ (helper_call.block? && node.block_has_only_icon_helpers?))
33
24
  end
34
25
 
35
- def parse_call(code)
36
- sexp = Ripper.sexp(code)
37
- return unless sexp
38
-
39
- extract_matching_call(sexp)
26
+ def aria_label?
27
+ helper_call.keyword?(:aria, :label) ||
28
+ helper_call.keyword?(:"aria-label")
40
29
  end
41
30
 
42
- def extract_matching_call(sexp)
43
- return unless sexp.is_a?(Array)
44
- return sexp if matching_call?(sexp)
45
-
46
- sexp.each do |child|
47
- result = extract_matching_call(child)
48
- return result if result
31
+ def helper_call
32
+ @helper_call ||= begin
33
+ call = node.call_node
34
+ call if call && METHODS.include?(call.method_name)
49
35
  end
50
-
51
- nil
52
36
  end
53
37
 
54
- def matching_call?(sexp)
55
- name = call_method_name(sexp)
56
- name ? METHODS.include?(name) : false
57
- end
58
-
59
- def call_method_name(sexp)
60
- case sexp
61
- in [:command, [:@ident, name, *], *]
62
- name
63
- in [:method_add_arg,
64
- [:fcall, [:@ident, name, *]], *]
65
- name
66
- else nil
67
- end
68
- end
69
-
70
- def extract_method_name(call)
71
- call_method_name(call)
72
- end
73
-
74
- def first_arg_empty_string?(call)
75
- args = extract_args(call)
76
- return false unless args&.first
77
-
78
- args.first in [:string_literal, [:string_content]]
79
- end
80
-
81
- def extract_args(call)
82
- case call
83
- in [:command, _, [:args_add_block, args, *]] then args
84
- in [:method_add_arg, _,
85
- [:arg_paren, [:args_add_block, args, *]]]
86
- then args
87
- in [:method_add_arg, _, [:arg_paren, Array => args]] then args
88
- else nil
89
- end
90
- end
91
-
92
- def aria_label_within?(sexp)
93
- return true if aria_hash_with_label?(sexp)
94
- return true if aria_label_string_key?(sexp)
95
- return false unless sexp.is_a?(Array)
96
-
97
- sexp.any? { |child| aria_label_within?(child) }
98
- end
99
-
100
- def aria_hash_with_label?(sexp)
101
- return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
102
-
103
- key = sexp[1]
104
- value = sexp[2]
105
-
106
- (key in [:@label, "aria:", *]) && label_key_within?(value)
107
- end
108
-
109
- def label_key_within?(sexp)
110
- return true if label_key?(sexp)
111
- return false unless sexp.is_a?(Array)
112
-
113
- sexp.any? { |child| label_key_within?(child) }
114
- end
115
-
116
- def label_key?(sexp)
117
- sexp.is_a?(Array) &&
118
- sexp[0] == :assoc_new &&
119
- (sexp[1] in [:@label, "label:", *])
120
- end
121
-
122
- def aria_label_string_key?(sexp)
123
- return false unless sexp.is_a?(Array) && sexp[0] == :assoc_new
124
-
125
- sexp[1] in [:string_literal,
126
- [:string_content,
127
- [:@tstring_content, "aria-label", *]]]
38
+ def offense_message(method_name)
39
+ <<~MSG.strip
40
+ #{method_name} missing an accessible name \
41
+ requires an aria-label (WCAG 4.1.2)
42
+ MSG
128
43
  end
129
44
  end
130
45
  end
@@ -4,6 +4,8 @@ module A11y
4
4
  module Lint
5
5
  # Wraps a Slim AST s-expression as a queryable node for lint rules.
6
6
  class SlimNode
7
+ include BlockInspection
8
+
7
9
  attr_reader :line
8
10
 
9
11
  def initialize(sexp, line:)
@@ -16,11 +18,17 @@ module A11y
16
18
  end
17
19
 
18
20
  def ruby_code
19
- return unless @sexp[0] == :slim && @sexp[1] == :output
21
+ return unless slim_output?
20
22
 
21
23
  @sexp[3]
22
24
  end
23
25
 
26
+ def call_node
27
+ return unless slim_output?
28
+
29
+ @call_node ||= parse_call_node
30
+ end
31
+
24
32
  def attribute?(name)
25
33
  attributes.key?(name)
26
34
  end
@@ -40,12 +48,59 @@ module A11y
40
48
  collect_children(body)
41
49
  end
42
50
 
51
+ # Returns ruby_code strings from child :slim :output nodes
52
+ # inside a block body. Only meaningful for output nodes
53
+ # (e.g. `= button_tag(...) do`).
54
+ def block_body_codes
55
+ return unless slim_output?
56
+
57
+ collect_output_codes(@sexp[4])
58
+ end
59
+
60
+ # Returns true when the block body contains visible text or
61
+ # HTML tag children (i.e. content that provides an accessible name).
62
+ def block_has_text_children?
63
+ return false unless slim_output?
64
+
65
+ text_content?(@sexp[4])
66
+ end
67
+
43
68
  private
44
69
 
45
70
  def html_tag?
46
71
  @sexp[0] == :html && @sexp[1] == :tag
47
72
  end
48
73
 
74
+ def slim_output?
75
+ @sexp[0] == :slim && @sexp[1] == :output
76
+ end
77
+
78
+ def parse_call_node
79
+ RubyCode.new(@sexp[3]).call_node
80
+ end
81
+
82
+ def collect_output_codes(sexp)
83
+ return [] unless sexp.is_a?(Array)
84
+ return [sexp[3]] if slim_output_sexp?(sexp)
85
+
86
+ sexp.flat_map { |child| collect_output_codes(child) }
87
+ end
88
+
89
+ def slim_output_sexp?(sexp)
90
+ sexp.is_a?(Array) && sexp[0] == :slim && sexp[1] == :output
91
+ end
92
+
93
+ def text_content?(sexp)
94
+ return false unless sexp.is_a?(Array)
95
+ return true if slim_text_sexp?(sexp) || html_tag_sexp?(sexp)
96
+
97
+ sexp.any? { |child| text_content?(child) }
98
+ end
99
+
100
+ def slim_text_sexp?(sexp)
101
+ sexp[0] == :slim && sexp[1] == :text
102
+ end
103
+
49
104
  def collect_children(sexp)
50
105
  return [] unless sexp.is_a?(Array)
51
106
  return [SlimNode.new(sexp, line: @line)] if html_tag_sexp?(sexp)
@@ -9,12 +9,15 @@ module A11y
9
9
  end
10
10
 
11
11
  def run(source, filename:)
12
+ require "slim"
12
13
  sexp = Slim::Parser.new.call(source)
13
14
  @line = 1
14
15
  @filename = filename
15
16
  @offenses = []
16
17
  walk(sexp)
17
18
  @offenses
19
+ rescue LoadError
20
+ raise SlimLoadError
18
21
  end
19
22
 
20
23
  private
@@ -2,6 +2,6 @@
2
2
 
3
3
  module A11y
4
4
  module Lint
5
- VERSION = "0.8.0"
5
+ VERSION = "0.10.0"
6
6
  end
7
7
  end
data/lib/a11y/lint.rb CHANGED
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "slim"
4
3
  require "nokogiri"
5
- require "prism"
4
+ require_relative "lint/errors"
6
5
  require_relative "lint/version"
7
6
  require_relative "lint/offense"
7
+ require_relative "lint/call_node"
8
+ require_relative "lint/ruby_code"
9
+ require_relative "lint/block_inspection"
8
10
  require_relative "lint/slim_node"
9
11
  require_relative "lint/erb_node"
12
+ require_relative "lint/phlex_tags"
10
13
  require_relative "lint/phlex_node"
11
14
  require_relative "lint/configuration"
12
15
  require_relative "lint/rule"
@@ -17,9 +20,3 @@ require_relative "lint/rules/robust/missing_accessible_name"
17
20
  require_relative "lint/slim_runner"
18
21
  require_relative "lint/erb_runner"
19
22
  require_relative "lint/phlex_runner"
20
-
21
- module A11y
22
- module Lint
23
- class Error < StandardError; end
24
- end
25
- end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: a11y-lint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdullah Hashim
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '1.13'
26
- - !ruby/object:Gem::Dependency
27
- name: prism
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - ">="
31
- - !ruby/object:Gem::Version
32
- version: '0'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - ">="
38
- - !ruby/object:Gem::Version
39
- version: '0'
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: slim
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -44,7 +30,7 @@ dependencies:
44
30
  - - ">="
45
31
  - !ruby/object:Gem::Version
46
32
  version: '4.0'
47
- type: :runtime
33
+ type: :development
48
34
  prerelease: false
49
35
  version_requirements: !ruby/object:Gem::Requirement
50
36
  requirements:
@@ -69,13 +55,18 @@ files:
69
55
  - Rakefile
70
56
  - exe/a11y-lint
71
57
  - lib/a11y/lint.rb
58
+ - lib/a11y/lint/block_inspection.rb
59
+ - lib/a11y/lint/call_node.rb
72
60
  - lib/a11y/lint/cli.rb
73
61
  - lib/a11y/lint/configuration.rb
74
62
  - lib/a11y/lint/erb_node.rb
75
63
  - lib/a11y/lint/erb_runner.rb
64
+ - lib/a11y/lint/errors.rb
76
65
  - lib/a11y/lint/offense.rb
77
66
  - lib/a11y/lint/phlex_node.rb
78
67
  - lib/a11y/lint/phlex_runner.rb
68
+ - lib/a11y/lint/phlex_tags.rb
69
+ - lib/a11y/lint/ruby_code.rb
79
70
  - lib/a11y/lint/rule.rb
80
71
  - lib/a11y/lint/rules/perceivable/image_tag_missing_alt.rb
81
72
  - lib/a11y/lint/rules/perceivable/img_missing_alt.rb
@@ -100,7 +91,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
100
91
  requirements:
101
92
  - - ">="
102
93
  - !ruby/object:Gem::Version
103
- version: 3.1.0
94
+ version: 3.3.0
104
95
  required_rubygems_version: !ruby/object:Gem::Requirement
105
96
  requirements:
106
97
  - - ">="