simple_english 0.1.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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The resolved LanguageTool installation: where the jars live and which
4
+ # java runs them. Build it once from the environment at the process
5
+ # edge with Install.from_env. Everything downstream takes the value,
6
+ # never the environment. A nil java means "not found": ask with java?
7
+ # or fail with java! and get the fix in the message.
8
+
9
+ require_relative "languagetool"
10
+
11
+ module SimpleEnglish
12
+ class Install
13
+ class SetupError < StandardError
14
+ end
15
+
16
+ attr_reader :cache_dir, :java
17
+
18
+ def initialize(cache_dir:, java: nil)
19
+ @cache_dir = cache_dir
20
+ @java = java
21
+ end
22
+
23
+ # The one place the environment is read. SE_JAVA wins outright,
24
+ # because the user asserted it and nobody re-probes an override.
25
+ # Otherwise the code probes the PATH candidate by running it,
26
+ # because a file can exist and still be the macOS stub that
27
+ # reports no runtime. The last resort is the Homebrew location,
28
+ # which sits outside PATH.
29
+ def self.from_env(env = ENV)
30
+ cache = File.expand_path(env.fetch(LanguageTool::CACHE_DIR_ENV) do
31
+ File.join(Dir.home, ".cache", "se")
32
+ end)
33
+ java = env[LanguageTool::JAVA_ENV] ||
34
+ ("java" if probe?("java", env)) ||
35
+ (LanguageTool::HOMEBREW_JAVA if File.executable?(LanguageTool::HOMEBREW_JAVA))
36
+ new(cache_dir: cache, java: java)
37
+ end
38
+
39
+ # Runs the candidate under the given PATH, isolated from this
40
+ # process's own PATH so tests can probe a fake environment.
41
+ def self.probe?(candidate, env)
42
+ system({"PATH" => env.fetch("PATH", "")}, candidate, "-version",
43
+ out: File::NULL, err: File::NULL)
44
+ end
45
+ private_class_method :probe?
46
+
47
+ def java?
48
+ !java.nil?
49
+ end
50
+
51
+ def java!
52
+ return java if java?
53
+ raise SetupError,
54
+ "java not found. Install a JRE (on macOS: brew install openjdk), " \
55
+ "or set SE_JAVA to your java binary."
56
+ end
57
+
58
+ def lt_dir
59
+ File.join(cache_dir, "LanguageTool-#{LanguageTool::LT_VERSION}")
60
+ end
61
+
62
+ def commandline_jar
63
+ File.join(lt_dir, "languagetool-commandline.jar")
64
+ end
65
+
66
+ def server_jar
67
+ File.join(lt_dir, "languagetool-server.jar")
68
+ end
69
+
70
+ def setup_error
71
+ "LanguageTool #{LanguageTool::LT_VERSION} not found at #{server_jar}. " \
72
+ "This gem pins that version. Run `se setup`."
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The pinned LanguageTool distro: its version, its rules file, and the
4
+ # downloader. Everything that resolves "where it is on this machine and
5
+ # which java runs it" lives in Install. The engine runs as the daemon's
6
+ # inner HTTP server (lib/simple_english/server.rb).
7
+
8
+ module SimpleEnglish
9
+ module LanguageTool
10
+ RULES_FILE = File.expand_path("../../rules/simple-english.xml", __dir__)
11
+ # The only place a LanguageTool version number appears. `se
12
+ # setup` downloads this version, and every jar path derives from it. No
13
+ # env var can point at another one.
14
+ LT_VERSION = "6.6"
15
+ CACHE_DIR_ENV = "SE_CACHE_DIR"
16
+ JAVA_ENV = "SE_JAVA"
17
+ # macOS ships a /usr/bin/java stub that reports no runtime. Homebrew
18
+ # installs the real one here, outside PATH.
19
+ HOMEBREW_JAVA = "/opt/homebrew/opt/openjdk/bin/java"
20
+ TIMEOUT_SECONDS = 300
21
+
22
+ module_function
23
+
24
+ # Download and unpack the pinned LanguageTool into DIR. Idempotent:
25
+ # returns the install directory, or nil after warning why. Pure Ruby
26
+ # throughout: the download uses Net::HTTP and the unpack uses rubyzip,
27
+ # so setup needs no curl or unzip on the machine.
28
+ def install(dir)
29
+ require "fileutils"
30
+ dest = File.join(dir, "LanguageTool-#{LT_VERSION}")
31
+ if File.exist?(File.join(dest, "languagetool-commandline.jar"))
32
+ remove_stale_versions(dir, dest)
33
+ return dest
34
+ end
35
+ zip = File.join(dir, "LanguageTool-#{LT_VERSION}.zip")
36
+ FileUtils.mkdir_p(dir)
37
+ return nil unless download(download_url, zip)
38
+ return nil unless extract(zip, dir)
39
+ File.delete(zip)
40
+ remove_stale_versions(dir, dest)
41
+ dest
42
+ end
43
+
44
+ def download_url
45
+ "https://languagetool.org/download/LanguageTool-#{LT_VERSION}.zip"
46
+ end
47
+
48
+ # Streams URL to TO and follows redirects. Warns and returns false
49
+ # on any transport or HTTP failure, so callers never see a stack
50
+ # trace.
51
+ def download(url, to, redirects: 3)
52
+ require "net/http"
53
+ uri = URI(url)
54
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
55
+ open_timeout: 10, read_timeout: 60) do |http|
56
+ http.request(Net::HTTP::Get.new(uri)) do |response|
57
+ case response
58
+ when Net::HTTPRedirection
59
+ return false if redirects.zero?
60
+ return download(response["location"], to, redirects: redirects - 1)
61
+ when Net::HTTPSuccess
62
+ File.binwrite(to, "")
63
+ File.open(to, "wb") do |file|
64
+ response.read_body { |chunk| file.write(chunk) }
65
+ end
66
+ return true
67
+ else
68
+ warn "error: download failed: HTTP #{response.code} from #{url}"
69
+ return false
70
+ end
71
+ end
72
+ end
73
+ rescue SystemCallError, SocketError, Timeout::Error,
74
+ OpenSSL::SSL::SSLError, Net::ProtocolError => e
75
+ warn "error: download failed: #{e.class}: #{e.message}"
76
+ false
77
+ end
78
+
79
+ # Unpacks ZIP into DIR and refuses entries that try to escape it.
80
+ # Warns and returns false on any failure.
81
+ def extract(zip, dir)
82
+ require "zip"
83
+ Zip::File.open(zip) do |archive|
84
+ archive.each do |entry|
85
+ unless safe_entry_target(dir, entry.name)
86
+ warn "error: zip entry escapes the install dir: #{entry.name}"
87
+ return false
88
+ end
89
+ entry.extract(entry.name, destination_directory: dir,
90
+ create_parent_directories: true)
91
+ end
92
+ end
93
+ true
94
+ rescue SystemCallError, Zip::Error => e
95
+ warn "error: unpack failed: #{e.class}: #{e.message}"
96
+ false
97
+ end
98
+
99
+ # The path to write zip entry NAME into, or nil when NAME escapes
100
+ # DIR. A download is untrusted input: this is the zip-slip
101
+ # guard.
102
+ def safe_entry_target(dir, name)
103
+ target = File.expand_path(File.join(dir, name))
104
+ base = File.expand_path(dir) + File::SEPARATOR
105
+ target.start_with?(base) ? target : nil
106
+ end
107
+
108
+ # One gem version pins one LanguageTool version. Older dirs are
109
+ # hundreds of MB each, so delete them on every setup run and keep
110
+ # only the current version (and any zips: they are re-deleted or
111
+ # re-extracted by their own flow).
112
+ def remove_stale_versions(dir, keep)
113
+ require "fileutils"
114
+ Dir.glob(File.join(dir, "LanguageTool-*")).each do |entry|
115
+ FileUtils.rm_rf(entry) if File.directory?(entry) && entry != keep
116
+ end
117
+ end
118
+
119
+ # Proves the installation works end to end: java runs the
120
+ # commandline jar and the custom rules load. The command exits
121
+ # nonzero on a broken java, a corrupt jar, or invalid rules XML,
122
+ # so setup can report a real verdict instead of "files exist".
123
+ # Returns a boolean, never raises or warns: the caller owns the
124
+ # message.
125
+ def smoke(install)
126
+ rule = rule_ids.first
127
+ return false if rule.nil?
128
+ require "tmpdir"
129
+ Dir.mktmpdir do |dir|
130
+ text = File.join(dir, "smoke.txt")
131
+ File.write(text, "Check this sentence.\n")
132
+ system(install.java!, "-jar", install.commandline_jar,
133
+ "--language", "en", "--rulefile", RULES_FILE,
134
+ "--enable", rule, "--enabledonly", text,
135
+ out: File::NULL, err: File::NULL) ? true : false
136
+ end
137
+ end
138
+
139
+ def rule_ids
140
+ File.read(RULES_FILE).scan(/<rule(?:group)? id="(\w+)"/).flatten
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "paragraph"
4
+
5
+ # Markdown stripping and paragraph splitting. Block structure comes
6
+ # from the tree-sitter Markdown grammar shipped with the pack the
7
+ # extractor already uses. Inline fixes stay regular expressions.
8
+ # Stripping keeps the line count identical to the source.
9
+
10
+ module SimpleEnglish
11
+ module Markdown
12
+ SENTENCE_END = /[.!?]/
13
+ # Non-prose blocks: their lines never reach the counting rules or
14
+ # LanguageTool.
15
+ BLANKED_BLOCKS = %w[fenced_code_block indented_code_block thematic_break].freeze
16
+ UNDERLINE = /\Asetext_h\d_underline\z/
17
+
18
+ module_function
19
+
20
+ # Blank out code and other non-prose blocks and neutralize inline
21
+ # code and heading markers. The line count stays identical to the
22
+ # source.
23
+ def strip(text)
24
+ lines = text.lines.map(&:chomp)
25
+ blank_rows(text).each { |row| lines[row] = "" }
26
+ lines.map { |line| strip_line(line) }.join("\n")
27
+ end
28
+
29
+ def strip_line(line)
30
+ line.gsub(/`[^`]*`/, "X").sub(/\A\#{1,6} /, "")
31
+ end
32
+
33
+ # A vertical list is not one paragraph: each list item is its own.
34
+ # Paragraph boundaries come from the tree-sitter Markdown grammar,
35
+ # so lazy continuations and interrupted lists match the spec
36
+ # instead of line-shape heuristics.
37
+ def paragraphs(text)
38
+ root = parse(text)
39
+ nodes = []
40
+ each_node(root) { |node| nodes << node if node.kind == "paragraph" }
41
+ nodes.sort_by!(&:start_byte)
42
+ nodes.filter_map do |node|
43
+ body = text.byteslice(node.start_byte, node.end_byte - node.start_byte)
44
+ lines = body.lines.map(&:strip).reject(&:empty?)
45
+ next if lines.empty?
46
+ Paragraph.new(lines: lines, start_line: node.start_position.row + 1,
47
+ procedural: inside_list_item?(node))
48
+ end
49
+ end
50
+
51
+ def sentences_of(paragraph_body)
52
+ paragraph_body.split(SENTENCE_END).map(&:strip).reject(&:empty?)
53
+ end
54
+
55
+ # The rows (0-based) a node fully occupies. Tree-sitter end
56
+ # positions are exclusive. A node ending at column 0 does not
57
+ # cover that row.
58
+ def node_rows(node)
59
+ last = node.end_position.column.zero? ? node.end_position.row - 1 : node.end_position.row
60
+ (node.start_position.row..last)
61
+ end
62
+
63
+ def blank_rows(text)
64
+ rows = []
65
+ each_node(parse(text)) do |node|
66
+ next if node.kind == "paragraph" # prose, not a block to blank
67
+ rows.concat(node_rows(node).to_a) if
68
+ BLANKED_BLOCKS.include?(node.kind) || UNDERLINE.match?(node.kind)
69
+ end
70
+ rows
71
+ end
72
+
73
+ def each_node(root)
74
+ stack = [root]
75
+ until stack.empty?
76
+ node = stack.pop
77
+ yield node
78
+ # No paragraphs live inside code blocks. Skip their subtrees.
79
+ next if BLANKED_BLOCKS.include?(node.kind)
80
+ node.child_count.times { |i| stack.push(node.child(i)) }
81
+ end
82
+ end
83
+
84
+ def inside_list_item?(node)
85
+ return false unless node.parent
86
+ node.parent.kind == "list_item" || inside_list_item?(node.parent)
87
+ end
88
+
89
+ def parse(text)
90
+ require "tree_sitter_language_pack"
91
+ TreeSitterLanguagePack.get_parser("markdown").parse(text).root_node
92
+ end
93
+
94
+ private_class_method :strip_line, :node_rows, :blank_rows, :each_node,
95
+ :inside_list_item?, :parse
96
+ end
97
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleEnglish
4
+ module Markdown
5
+ # A paragraph of prose: its lines, the first line's number in the
6
+ # source, and whether it starts with a list item (procedural text
7
+ # gets a tighter sentence limit).
8
+ Paragraph = Struct.new(:lines, :start_line, :procedural)
9
+ end
10
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleEnglish
4
+ module Client
5
+ # The plain-text payload for /v2/check, duck-compatible with
6
+ # AnnotatedText::Result: #lt_params and #locate.
7
+ PlainText = Struct.new(:text) do
8
+ def lt_params
9
+ {"text" => text}
10
+ end
11
+
12
+ def locate(utf16_offset)
13
+ [Client.offset_to_line(text, utf16_offset), nil]
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleEnglish
4
+ module AnnotatedText
5
+ # The payload for code comments. Duck interface shared with
6
+ # Client::PlainText: #lt_params (the form data for LanguageTool)
7
+ # and #locate (a match offset back to file line and column).
8
+ Result = Struct.new(:source, :segments, :stream) do
9
+ def lt_params
10
+ {"data" => SimpleEnglish::AnnotatedText.data_json(self)}
11
+ end
12
+
13
+ def locate(utf16_offset)
14
+ SimpleEnglish::AnnotatedText.locate(self, utf16_offset)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleEnglish
4
+ module AnnotatedText
5
+ # One piece of the annotation stream: checkable comment text or
6
+ # uncheckable markup (markers, closers, gaps between comments).
7
+ # stream_start is the segment's offset into Result#stream.
8
+ Segment = Struct.new(:text, :content, :file_char, :stream_start,
9
+ :interpret_as)
10
+ end
11
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The se daemon: process lifecycle only. The wire protocol lives in
4
+ # lib/simple_english/http.rb, the lint engine in lib/simple_english/engine.rb.
5
+ # Failures raise typed errors (PortInUse, SetupError, InnerDied,
6
+ # InnerTimeout). bin/se owns turning them into warnings and exit codes.
7
+
8
+ require "fileutils"
9
+ require "net/http"
10
+ require "socket"
11
+ require "tempfile"
12
+ require "tmpdir"
13
+
14
+ require_relative "install"
15
+
16
+ module SimpleEnglish
17
+ module Server
18
+ class ServerError < StandardError; end
19
+
20
+ class PortInUse < ServerError
21
+ def initialize(port)
22
+ super("port #{port} is already in use.")
23
+ end
24
+ end
25
+
26
+ class InnerDied < ServerError; end
27
+ class InnerTimeout < ServerError; end
28
+
29
+ module_function
30
+
31
+ # LanguageTool's HTTP server has no --rulefile flag. Custom rules
32
+ # load from the classpath at this exact path.
33
+ def stage_rules(dir)
34
+ target = File.join(dir, "org/languagetool/rules/en/grammar_custom.xml")
35
+ FileUtils.mkdir_p(File.dirname(target))
36
+ FileUtils.cp(SimpleEnglish::LanguageTool::RULES_FILE, target)
37
+ target
38
+ end
39
+
40
+ # Probes both loopback families: on IPv6-first resolvers a "localhost"
41
+ # probe falls back to IPv4 and misses an IPv6 listener.
42
+ def assert_port_free(port)
43
+ ["127.0.0.1", "::1"].each do |address|
44
+ TCPServer.new(address, port).close
45
+ rescue Errno::EADDRNOTAVAIL
46
+ next
47
+ end
48
+ rescue Errno::EADDRINUSE
49
+ raise PortInUse, port
50
+ rescue Errno::EACCES
51
+ raise ServerError, "port #{port} cannot be bound. Use a port above 1024."
52
+ end
53
+
54
+ def start(port: Client::DEFAULT_PORT, install: Install.from_env, log: $stderr)
55
+ assert_port_free(port)
56
+ # The inner JVM lives on port + 1. Guard it too so an occupied
57
+ # inner port raises in milliseconds instead of timing out later.
58
+ assert_port_free(port + 1)
59
+ rules_dir = Dir.mktmpdir("se-rules")
60
+ stage_rules(rules_dir)
61
+ # The inner JVM's stderr goes to a file so failure messages can quote
62
+ # its first line. The dev log has a path. $stderr does not, so fall
63
+ # back to a temp file that Ruby unlinks when the process exits.
64
+ inner_log_path =
65
+ if log.respond_to?(:path)
66
+ log.path
67
+ else
68
+ inner_log = Tempfile.new("se-inner")
69
+ inner_log.close
70
+ inner_log.path
71
+ end
72
+ inner = spawn_inner(install: install, port: port + 1, rules_dir: rules_dir,
73
+ log_path: inner_log_path)
74
+ # TCPServer.new binds synchronously: construction means ready, so
75
+ # callers need no readiness polling. Closing the socket makes the
76
+ # serving loop's select raise and the loop exit.
77
+ server = TCPServer.new(port)
78
+ inner_died = false
79
+ # Closing the listening socket does not interrupt a syscall already
80
+ # blocked on the old fd, so the serving loop selects with a short
81
+ # timeout: the next IO.select on a closed socket raises IOError, so
82
+ # every shutdown path (trap, inner-death monitor) exits the loop
83
+ # within the timeout, on every platform.
84
+ stop_server = proc do
85
+ server.close
86
+ rescue IOError
87
+ # Already closed by another shutdown path.
88
+ end
89
+ # The monitor reaps the inner process, so nil it in the callback to
90
+ # keep the cleanup idempotent against the already-reaped pid.
91
+ monitor = monitor_inner(inner, on_death: proc do
92
+ inner = nil
93
+ inner_died = true
94
+ stop_server.call
95
+ end)
96
+ trap("INT") { stop_server.call }
97
+ trap("TERM") { stop_server.call }
98
+ (log.respond_to?(:puts) ? log : $stderr).puts "se listening on http://localhost:#{port}"
99
+ loop do
100
+ ready = IO.select([server], nil, nil, 0.5)
101
+ # Select timeout on an idle socket: loop back and select again.
102
+ next if ready.nil?
103
+ client = server.accept
104
+ Thread.new(client) { |c| HTTP.handle_client(c, port: port) }
105
+ rescue IOError, Errno::EBADF
106
+ # A trap or the inner-death monitor closed the listener. This
107
+ # happens during the select, or between select and accept.
108
+ break
109
+ end
110
+ monitor.kill if monitor.alive?
111
+ raise InnerDied, "inner LanguageTool server died. Rerun se serve." if inner_died
112
+ ensure
113
+ if inner
114
+ begin
115
+ Process.kill("TERM", inner)
116
+ Process.wait(inner)
117
+ rescue Errno::ESRCH, Errno::ECHILD
118
+ # The monitor thread already reaped the inner process.
119
+ end
120
+ end
121
+ monitor&.kill if monitor&.alive?
122
+ FileUtils.remove_entry(rules_dir) if rules_dir
123
+ end
124
+
125
+ # Reaps the inner process and runs on_death (which stops the outer
126
+ # server) whenever it exits, so the daemon dies instead of serving
127
+ # without LanguageTool behind it.
128
+ def monitor_inner(pid, on_death:)
129
+ Thread.new do
130
+ Process.wait(pid)
131
+ on_death.call
132
+ end
133
+ end
134
+
135
+ # Boots the inner LanguageTool server and blocks until it answers
136
+ # /v2/check. Raises InnerDied when it dies during startup, and
137
+ # InnerTimeout when it never becomes ready (the child is killed and
138
+ # reaped first, so a failed boot leaks no JVM). Both messages quote
139
+ # the inner log's first line.
140
+ def spawn_inner(install:, port:, rules_dir:, log_path:)
141
+ raise Install::SetupError, install.setup_error unless File.exist?(install.server_jar)
142
+ java = install.java!
143
+ begin
144
+ pid = Process.spawn(java,
145
+ "-cp", [install.server_jar, rules_dir].join(File::PATH_SEPARATOR),
146
+ "org.languagetool.server.HTTPServer", "--port", port.to_s,
147
+ out: File::NULL, err: log_path)
148
+ rescue Errno::ENOENT
149
+ # The java lookup can resolve to a path that no longer exists.
150
+ # Quote what was resolved so the crash names its cause.
151
+ raise ServerError, "cannot exec #{java.inspect}."
152
+ end
153
+ ready = false
154
+ died = false
155
+ deadline = Time.now + SimpleEnglish::LanguageTool::TIMEOUT_SECONDS
156
+ until ready || died || Time.now > deadline
157
+ # A JVM that dies instantly must not be polled for the full timeout.
158
+ # WNOHANG reaps it here, so nothing else may wait on this pid after.
159
+ died = !!Process.wait(pid, Process::WNOHANG)
160
+ break if died
161
+ begin
162
+ Net::HTTP.post_form(URI("http://localhost:#{port}/v2/check"),
163
+ {"language" => "en", "text" => "a"})
164
+ ready = true
165
+ rescue SystemCallError
166
+ sleep 0.5
167
+ end
168
+ end
169
+ if died
170
+ raise InnerDied,
171
+ "inner LanguageTool server exited during startup. " \
172
+ "First log line: #{first_log_line(log_path)}"
173
+ end
174
+ unless ready
175
+ kill_and_reap(pid)
176
+ raise InnerTimeout,
177
+ "inner LanguageTool server did not start " \
178
+ "within #{SimpleEnglish::LanguageTool::TIMEOUT_SECONDS} seconds. " \
179
+ "First log line: #{first_log_line(log_path)}"
180
+ end
181
+ pid
182
+ end
183
+
184
+ def kill_and_reap(pid)
185
+ Process.kill("TERM", pid)
186
+ Process.wait(pid)
187
+ rescue SystemCallError
188
+ end
189
+
190
+ def first_log_line(path)
191
+ File.read(path).split("\n").first || "unknown"
192
+ rescue SystemCallError
193
+ "unknown"
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SimpleEnglish
4
+ module Extractor
5
+ # One comment: its text and byte range in the source file.
6
+ Span = Struct.new(:text, :start_byte, :end_byte)
7
+ end
8
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Inline suppressions. A line containing `se: ignore` (or
4
+ # `se: ignore=RULE1,RULE2`) suppresses findings reported on that
5
+ # same line. Pattern findings cite the line of the match, not the
6
+ # paragraph or comment start, so put the directive on the line the
7
+ # finding reports.
8
+
9
+ module SimpleEnglish
10
+ module Suppressions
11
+ DIRECTIVE = /se:\s*ignore\s*(?:=\s*(?<rules>[\w,]+))?/
12
+
13
+ module_function
14
+
15
+ def filter(source, findings)
16
+ return findings if findings.empty?
17
+ blocked = directives(source)
18
+ return findings if blocked.empty?
19
+ findings.reject do |finding|
20
+ blocked.any? do |directive|
21
+ directive[:line] == finding.line &&
22
+ (directive[:rules].empty? || directive[:rules].include?(finding.rule))
23
+ end
24
+ end
25
+ end
26
+
27
+ def directives(source)
28
+ source.lines.each_with_index.filter_map do |line, index|
29
+ next unless (match = line.match(DIRECTIVE))
30
+ rules = match[:rules] ? match[:rules].split(",").map(&:strip) : []
31
+ {line: index + 1, rules: rules}
32
+ end
33
+ end
34
+
35
+ private_class_method :directives
36
+ end
37
+ end