mdlint 0.1.0 → 0.2.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.pre-commit-hooks.yaml +6 -0
  3. data/CHANGELOG.md +33 -0
  4. data/README.md +136 -4
  5. data/Rakefile +14 -0
  6. data/Steepfile +17 -0
  7. data/action.yml +54 -0
  8. data/benchmark/compare.rb +49 -0
  9. data/benchmark/format.rb +29 -0
  10. data/lib/mdlint/cache_store.rb +85 -0
  11. data/lib/mdlint/cli/output_formatter.rb +150 -0
  12. data/lib/mdlint/cli.rb +268 -106
  13. data/lib/mdlint/config.rb +87 -1
  14. data/lib/mdlint/dialect.rb +53 -0
  15. data/lib/mdlint/linter/directive_filter.rb +91 -0
  16. data/lib/mdlint/linter/rule.rb +25 -5
  17. data/lib/mdlint/linter/rule_engine.rb +44 -7
  18. data/lib/mdlint/linter/rules/code_block_syntax.rb +128 -0
  19. data/lib/mdlint/linter/rules/first_line_heading.rb +10 -3
  20. data/lib/mdlint/linter/rules/heading_increment.rb +4 -3
  21. data/lib/mdlint/linter/rules/heading_style.rb +24 -2
  22. data/lib/mdlint/linter/rules/japanese.rb +201 -0
  23. data/lib/mdlint/linter/rules/line_length.rb +37 -0
  24. data/lib/mdlint/linter/rules/link_check.rb +151 -0
  25. data/lib/mdlint/linter/rules/no_multiple_blanks.rb +1 -0
  26. data/lib/mdlint/linter/rules/no_trailing_spaces.rb +1 -0
  27. data/lib/mdlint/linter/rules/source_style.rb +317 -0
  28. data/lib/mdlint/linter/violation.rb +16 -1
  29. data/lib/mdlint/linter.rb +9 -3
  30. data/lib/mdlint/lsp.rb +176 -0
  31. data/lib/mdlint/parallel_runner.rb +42 -0
  32. data/lib/mdlint/parser/block_parser.rb +627 -50
  33. data/lib/mdlint/parser/inline_parser.rb +259 -27
  34. data/lib/mdlint/parser/state.rb +21 -2
  35. data/lib/mdlint/parser.rb +5 -5
  36. data/lib/mdlint/plugin.rb +31 -0
  37. data/lib/mdlint/renderer/html_renderer.rb +346 -0
  38. data/lib/mdlint/renderer/md_renderer.rb +147 -11
  39. data/lib/mdlint/renderer.rb +5 -0
  40. data/lib/mdlint/text_width.rb +39 -0
  41. data/lib/mdlint/toc.rb +80 -0
  42. data/lib/mdlint/token.rb +3 -1
  43. data/lib/mdlint/version.rb +1 -1
  44. data/lib/mdlint.rb +25 -5
  45. data/script/commonmark_compatibility.rb +24 -0
  46. data/script/fetch_commonmark_spec.rb +14 -0
  47. data/sig/internal.rbs +405 -0
  48. data/sig/mdlint.rbs +107 -0
  49. metadata +26 -2
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../text_width"
4
+
5
+ module Mdlint
6
+ module Linter
7
+ module Rules
8
+ class LineLength < Rule
9
+ self.rule_id = "MD013"
10
+ self.aliases = ["line-length"]
11
+ self.description = "Line length should not exceed the configured limit"
12
+
13
+ def check(_tokens, source)
14
+ maximum = @options.fetch(:line_length, @options.fetch(:length, 80)).to_i
15
+ return @violations if maximum <= 0
16
+
17
+ in_fence = false
18
+ source.each_line.with_index(1) do |line, line_number|
19
+ stripped = line.chomp
20
+ in_fence = !in_fence if stripped.match?(/\A {0,3}(`{3,}|~{3,})/)
21
+ next if in_fence && @options.fetch(:ignore_code_blocks, false)
22
+ next if TextWidth.measure(stripped) <= maximum
23
+
24
+ add_violation(
25
+ message: "Line length #{TextWidth.measure(stripped)} exceeds #{maximum}",
26
+ line: line_number,
27
+ column: maximum + 1,
28
+ fixable: false
29
+ )
30
+ end
31
+
32
+ @violations
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "timeout"
5
+ require "uri"
6
+
7
+ module Mdlint
8
+ module Linter
9
+ module Rules
10
+ class LinkCheck < Rule
11
+ self.rule_id = "MD052"
12
+ self.aliases = ["link-check"]
13
+ self.description = "Relative links should point to existing files"
14
+
15
+ def check(tokens, source)
16
+ return @violations unless @options[:check_links] || @options[:check_external_links]
17
+
18
+ @headings = heading_slugs(tokens)
19
+
20
+ tokens.each do |token|
21
+ next unless token.type == :inline
22
+
23
+ token.children.each do |child|
24
+ next unless child.type == :link_open || child.type == :image
25
+
26
+ target = child.attrs[:href] || child.attrs[:src]
27
+ image = child.is_a?(Token) && child.type == :image
28
+ check_target(target, token, image, source)
29
+ end
30
+ end
31
+ @violations
32
+ end
33
+
34
+ private
35
+
36
+ def check_target(target, token, image, source)
37
+ return if target.nil? || target.empty?
38
+
39
+ if target.match?(%r{\Ahttps?://}i)
40
+ check_external_target(target, token) if @options[:check_external_links]
41
+ return
42
+ end
43
+ return if target.match?(%r{\A(?:ftp|mailto):}i)
44
+
45
+ path, fragment = target.split("#", 2)
46
+ base = @options[:filename] && File.dirname(@options[:filename])
47
+ return unless base || path.empty?
48
+
49
+ if path.empty?
50
+ check_fragment(fragment, token, image, @headings)
51
+ return
52
+ end
53
+
54
+ resolved_path = File.expand_path(path, base)
55
+ unless File.file?(resolved_path)
56
+ add_missing_target(path, token)
57
+ return
58
+ end
59
+
60
+ return if image || fragment.nil? || fragment.empty?
61
+ return if non_markdown_file?(resolved_path)
62
+
63
+ target_headings = begin
64
+ heading_slugs(Parser.parse(File.read(resolved_path), @options))
65
+ rescue StandardError
66
+ []
67
+ end
68
+ check_fragment(fragment, token, image, target_headings)
69
+ end
70
+
71
+ def check_fragment(fragment, token, image, headings)
72
+ return if image || fragment.nil? || fragment.empty?
73
+ decoded = URI::DEFAULT_PARSER.unescape(fragment).downcase
74
+ return if headings.include?(decoded)
75
+
76
+ add_violation(
77
+ message: "Link anchor does not exist: ##{fragment}",
78
+ line: (token.map&.first || 0) + 1,
79
+ fixable: false
80
+ )
81
+ end
82
+
83
+ def add_missing_target(path, token)
84
+ add_violation(
85
+ message: "Link target does not exist: #{path}",
86
+ line: (token.map&.first || 0) + 1,
87
+ fixable: false
88
+ )
89
+ end
90
+
91
+ def check_external_target(target, token)
92
+ uri = URI.parse(target)
93
+ host = uri.host.to_s
94
+ request_path = uri.path.to_s
95
+ request_path = "/" if request_path.empty?
96
+ request_path += "?#{uri.query}" if uri.query
97
+ response = Net::HTTP.start(
98
+ host,
99
+ uri.port,
100
+ use_ssl: uri.scheme == "https",
101
+ open_timeout: 3,
102
+ read_timeout: 3
103
+ ) do |http|
104
+ result = http.head(request_path)
105
+ result.is_a?(Net::HTTPMethodNotAllowed) ? http.get(request_path) : result
106
+ end
107
+ return if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
108
+
109
+ add_violation(
110
+ message: "External link returned HTTP #{response.code}: #{target}",
111
+ line: (token.map&.first || 0) + 1,
112
+ fixable: false
113
+ )
114
+ rescue URI::InvalidURIError, SocketError, SystemCallError, Timeout::Error, IOError => error
115
+ add_violation(
116
+ message: "External link could not be reached: #{target} (#{error.class})",
117
+ line: (token.map&.first || 0) + 1,
118
+ fixable: false
119
+ )
120
+ end
121
+
122
+ def non_markdown_file?(path)
123
+ !%w[.md .markdown .mdown .mkdn].include?(File.extname(path).downcase)
124
+ end
125
+
126
+ def heading_slugs(tokens)
127
+ counts = Hash.new(0)
128
+ slugs = []
129
+ tokens.each_with_index do |token, index|
130
+ next unless token.type == :heading_open
131
+
132
+ inline = tokens[(index + 1)..]&.find { |candidate| candidate.type == :inline }
133
+ base = slugify(inline&.content.to_s)
134
+ next if base.empty?
135
+
136
+ suffix = counts[base]
137
+ counts[base] += 1
138
+ slugs << (suffix.zero? ? base : "#{base}-#{suffix}")
139
+ end
140
+ slugs
141
+ end
142
+
143
+ def slugify(value)
144
+ value = value.gsub(/[`*_~\[\]()<>]/, "")
145
+ value.downcase.gsub(/[^\p{Alnum}\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}\s-]/, "")
146
+ .strip.gsub(/\s+/, "-")
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -5,6 +5,7 @@ module Mdlint
5
5
  module Rules
6
6
  class NoMultipleBlanks < Rule
7
7
  self.rule_id = "MD012"
8
+ self.aliases = ["no-multiple-blanks"]
8
9
  self.description = "Multiple consecutive blank lines"
9
10
 
10
11
  def check(_tokens, source)
@@ -5,6 +5,7 @@ module Mdlint
5
5
  module Rules
6
6
  class NoTrailingSpaces < Rule
7
7
  self.rule_id = "MD009"
8
+ self.aliases = ["no-trailing-spaces"]
8
9
  self.description = "Trailing spaces"
9
10
 
10
11
  def check(_tokens, source)
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../dialect"
4
+
5
+ module Mdlint
6
+ module Linter
7
+ module Rules
8
+ module SourceStyleSupport
9
+ FENCE_REGEXP = /\A {0,3}(`{3,}|~{3,})/
10
+ LIST_MARKER_REGEXP = /\A( {0,3}(?:[-+*]|\d+[.)]))([ \t]+)(\S.*)?\z/
11
+
12
+ private
13
+
14
+ def lines(source)
15
+ source.lines
16
+ end
17
+
18
+ def fenced_lines(source)
19
+ in_fence = false
20
+ source.each_line.with_index(1).filter_map do |line, line_number|
21
+ marker = line.chomp.match(FENCE_REGEXP)
22
+ in_fence = !in_fence if marker
23
+ [line_number, in_fence, marker]
24
+ end
25
+ end
26
+
27
+ def outside_fence?(fence_state)
28
+ !fence_state
29
+ end
30
+
31
+ def line_is_blank?(line)
32
+ line.to_s.match?(/\A\s*\z/)
33
+ end
34
+
35
+ def inline_tokens(tokens)
36
+ tokens.select { |token| token.type == :inline }.flat_map(&:children)
37
+ end
38
+ end
39
+
40
+ class NoHardTabs < Rule
41
+ include SourceStyleSupport
42
+
43
+ self.rule_id = "MD010"
44
+ self.aliases = ["no-hard-tabs"]
45
+ self.description = "Hard tabs should not be used"
46
+
47
+ def check(_tokens, source)
48
+ in_fence = false
49
+ source.each_line.with_index(1) do |line, line_number|
50
+ in_fence = !in_fence if line.match?(FENCE_REGEXP)
51
+ next if in_fence && @options.fetch(:ignore_code_blocks, false)
52
+ next unless line.include?("\t")
53
+
54
+ add_violation(message: "Hard tab character", line: line_number, column: line.index("\t").to_i + 1, fixable: true)
55
+ end
56
+ @violations
57
+ end
58
+
59
+ def fix(_tokens, source)
60
+ source.each_line.map { |line| line.gsub("\t", " ") }.join
61
+ end
62
+ end
63
+
64
+ class NoSpaceAfterHash < Rule
65
+ self.rule_id = "MD018"
66
+ self.aliases = ["no-missing-space-atx"]
67
+ self.description = "No space after the hash on an ATX heading"
68
+
69
+ def check(_tokens, source)
70
+ source.each_line.with_index(1) do |line, line_number|
71
+ next unless line.chomp.match?(/\A {0,3}\#{1,6}(?!\#)\S/)
72
+
73
+ add_violation(message: "Add a space after the heading marker", line: line_number, fixable: true)
74
+ end
75
+ @violations
76
+ end
77
+
78
+ def fix(_tokens, source)
79
+ source.each_line.map { |line| line.sub(/\A( {0,3}\#{1,6})(?!\#)(\S)/, '\\1 \\2') }.join
80
+ end
81
+ end
82
+
83
+ class NoMultipleSpacesAfterHash < Rule
84
+ self.rule_id = "MD019"
85
+ self.aliases = ["no-multiple-space-atx"]
86
+ self.description = "No more than one space after the hash on an ATX heading"
87
+
88
+ def check(_tokens, source)
89
+ source.each_line.with_index(1) do |line, line_number|
90
+ next unless line.chomp.match?(/\A {0,3}#+ {2,}\S/)
91
+
92
+ add_violation(message: "Use one space after the heading marker", line: line_number, fixable: true)
93
+ end
94
+ @violations
95
+ end
96
+
97
+ def fix(_tokens, source)
98
+ source.each_line.map { |line| line.sub(/\A( {0,3}#+) {2,}/, '\\1 ') }.join
99
+ end
100
+ end
101
+
102
+ class NoMultipleSpacesAfterBlockquote < Rule
103
+ self.rule_id = "MD027"
104
+ self.aliases = ["no-multiple-space-blockquote"]
105
+ self.description = "No more than one space after a blockquote marker"
106
+
107
+ def check(_tokens, source)
108
+ source.each_line.with_index(1) do |line, line_number|
109
+ next unless line.chomp.match?(/\A {0,3}> {2,}/)
110
+
111
+ add_violation(message: "Use one space after the blockquote marker", line: line_number, fixable: true)
112
+ end
113
+ @violations
114
+ end
115
+
116
+ def fix(_tokens, source)
117
+ source.each_line.map { |line| line.sub(/\A( {0,3}>) {2,}/, '\\1 ') }.join
118
+ end
119
+ end
120
+
121
+ class ListMarkerSpace < Rule
122
+ self.rule_id = "MD030"
123
+ self.aliases = ["list-marker-space"]
124
+ self.description = "List markers should be followed by one space"
125
+
126
+ def check(_tokens, source)
127
+ source.each_line.with_index(1) do |line, line_number|
128
+ match = line.chomp.match(/\A( {0,3}(?:[-+*]|\d+[.)]))([ \t]+)(\S)/)
129
+ next unless match && match[2] != " "
130
+
131
+ add_violation(message: "Use one space after a list marker", line: line_number, fixable: true)
132
+ end
133
+ @violations
134
+ end
135
+
136
+ def fix(_tokens, source)
137
+ source.each_line.map { |line| line.sub(/\A( {0,3}(?:[-+*]|\d+[.)]))[ \t]+/, '\\1 ') }.join
138
+ end
139
+ end
140
+
141
+ class FencedCodeBlankLines < Rule
142
+ include SourceStyleSupport
143
+
144
+ self.rule_id = "MD031"
145
+ self.aliases = ["blanks-around-fences"]
146
+ self.description = "Fenced code blocks should be surrounded by blank lines"
147
+
148
+ def check(_tokens, source)
149
+ source_lines = lines(source)
150
+ in_fence = false
151
+ source_lines.each_with_index do |line, index|
152
+ next unless line.chomp.match?(FENCE_REGEXP)
153
+
154
+ if !in_fence && index.positive? && !line_is_blank?(source_lines[index - 1])
155
+ add_violation(message: "Add a blank line before the fenced code block", line: index + 1)
156
+ elsif in_fence && index < source_lines.length - 1 && !line_is_blank?(source_lines[index + 1])
157
+ add_violation(message: "Add a blank line after the fenced code block", line: index + 1)
158
+ end
159
+ in_fence = !in_fence
160
+ end
161
+ @violations
162
+ end
163
+ end
164
+
165
+ class ListBlankLines < Rule
166
+ include SourceStyleSupport
167
+
168
+ self.rule_id = "MD032"
169
+ self.aliases = ["blanks-around-lists"]
170
+ self.description = "Lists should be surrounded by blank lines"
171
+
172
+ def check(_tokens, source)
173
+ source_lines = lines(source)
174
+ list_indexes = source_lines.each_index.select { |index| source_lines[index].match?(LIST_MARKER_REGEXP) }
175
+ return @violations if list_indexes.empty?
176
+
177
+ first = list_indexes.first
178
+ last = list_indexes.last
179
+ if first.positive? && !line_is_blank?(source_lines[first - 1])
180
+ add_violation(message: "Add a blank line before the list", line: first + 1)
181
+ end
182
+ if last < source_lines.length - 1 && !line_is_blank?(source_lines[last + 1])
183
+ add_violation(message: "Add a blank line after the list", line: last + 1)
184
+ end
185
+ @violations
186
+ end
187
+ end
188
+
189
+ class NoBareUrls < Rule
190
+ include SourceStyleSupport
191
+
192
+ self.rule_id = "MD034"
193
+ self.aliases = ["no-bare-urls"]
194
+ self.description = "Bare URLs should be enclosed in angle brackets"
195
+
196
+ def check(tokens, source)
197
+ return @violations unless Dialect.resolve(@options[:dialect]).feature?(:bare_autolinks)
198
+
199
+ source.each_line.with_index(1) do |line, line_number|
200
+ next if line.match?(FENCE_REGEXP)
201
+ next unless line.match?(%r{(?<![<\w"'=(])https?://[^\s<>]+})
202
+
203
+ add_violation(message: "Enclose bare URLs in angle brackets", line: line_number, fixable: false)
204
+ end
205
+ @violations
206
+ end
207
+ end
208
+
209
+ class NoSpaceInEmphasis < Rule
210
+ include SourceStyleSupport
211
+
212
+ self.rule_id = "MD037"
213
+ self.aliases = ["no-space-in-emphasis"]
214
+ self.description = "Emphasis markers should not contain extra spaces"
215
+
216
+ def check(_tokens, source)
217
+ source.each_line.with_index(1) do |line, line_number|
218
+ next unless line.match?(/(?:\*\*|__|(?<!\*)\*)(?:\s+)[^\n]+?(?:\s+)(?:\*\*|__|(?<!\*)\*(?!\*))/)
219
+
220
+ add_violation(message: "Remove spaces inside emphasis markers", line: line_number, fixable: false)
221
+ end
222
+ @violations
223
+ end
224
+ end
225
+
226
+ class NoSpaceInCode < Rule
227
+ include SourceStyleSupport
228
+
229
+ self.rule_id = "MD038"
230
+ self.aliases = ["no-space-in-code"]
231
+ self.description = "Code spans should not contain extra spaces"
232
+
233
+ def check(_tokens, source)
234
+ source.each_line.with_index(1) do |line, line_number|
235
+ invalid = line.scan(/(`+)([^`\n]*?)\1/).any? { |_marker, content| content.to_s != content.to_s.strip }
236
+ next unless invalid
237
+
238
+ add_violation(message: "Remove spaces inside code span markers", line: line_number, fixable: false)
239
+ end
240
+ @violations
241
+ end
242
+ end
243
+
244
+ class FencedCodeStyle < Rule
245
+ include SourceStyleSupport
246
+
247
+ self.rule_id = "MD046"
248
+ self.aliases = ["fenced-code-style"]
249
+ self.description = "Use fenced code blocks instead of indented code blocks"
250
+
251
+ def check(_tokens, source)
252
+ source.each_line.with_index(1) do |line, line_number|
253
+ next unless line.match?(/\A {4}\S/)
254
+
255
+ add_violation(message: "Use a fenced code block", line: line_number, fixable: false)
256
+ end
257
+ @violations
258
+ end
259
+ end
260
+
261
+ class SingleTrailingNewline < Rule
262
+ self.rule_id = "MD047"
263
+ self.aliases = ["single-trailing-newline"]
264
+ self.description = "Files should end with a single newline"
265
+
266
+ def check(_tokens, source)
267
+ return @violations if source.empty? || source.end_with?("\n") && !source.end_with?("\n\n")
268
+
269
+ add_violation(message: "File should end with a single newline", line: source.lines.length, fixable: true)
270
+ @violations
271
+ end
272
+
273
+ def fix(_tokens, source)
274
+ source.rstrip + "\n"
275
+ end
276
+ end
277
+
278
+ class EmphasisStyle < Rule
279
+ include SourceStyleSupport
280
+
281
+ self.rule_id = "MD049"
282
+ self.aliases = ["emphasis-style"]
283
+ self.description = "Emphasis style should be consistent"
284
+
285
+ def check(tokens, _source)
286
+ expected = @options.fetch(:emphasis_style, :asterisk).to_s
287
+ inline_tokens(tokens).each do |token|
288
+ next unless token.type == :em_open
289
+ next unless (expected == "asterisk" && token.markup == "_") || (expected == "underscore" && token.markup == "*")
290
+
291
+ add_violation(message: "Use #{expected} emphasis markers", line: 1, fixable: false)
292
+ end
293
+ @violations
294
+ end
295
+ end
296
+
297
+ class StrongStyle < Rule
298
+ include SourceStyleSupport
299
+
300
+ self.rule_id = "MD050"
301
+ self.aliases = ["strong-style"]
302
+ self.description = "Strong style should be consistent"
303
+
304
+ def check(tokens, _source)
305
+ expected = @options.fetch(:strong_style, :asterisk).to_s
306
+ inline_tokens(tokens).each do |token|
307
+ next unless token.type == :strong_open
308
+ next unless (expected == "asterisk" && token.markup == "__") || (expected == "underscore" && token.markup == "**")
309
+
310
+ add_violation(message: "Use #{expected} strong markers", line: 1, fixable: false)
311
+ end
312
+ @violations
313
+ end
314
+ end
315
+ end
316
+ end
317
+ end
@@ -10,7 +10,7 @@ module Mdlint
10
10
  @message = message
11
11
  @line = line
12
12
  @column = column
13
- @severity = severity
13
+ @severity = severity.to_sym
14
14
  @fixable = fixable
15
15
  end
16
16
 
@@ -30,6 +30,21 @@ module Mdlint
30
30
  def warning?
31
31
  @severity == :warning
32
32
  end
33
+
34
+ def info?
35
+ @severity == :info
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ rule_id: @rule_id,
41
+ message: @message,
42
+ line: @line,
43
+ column: @column,
44
+ severity: @severity,
45
+ fixable: @fixable
46
+ }
47
+ end
33
48
  end
34
49
  end
35
50
  end
data/lib/mdlint/linter.rb CHANGED
@@ -3,23 +3,29 @@
3
3
  require_relative "linter/violation"
4
4
  require_relative "linter/rule"
5
5
  require_relative "linter/rule_engine"
6
+ require_relative "linter/directive_filter"
6
7
  require_relative "linter/rules/heading_style"
7
8
  require_relative "linter/rules/heading_increment"
8
9
  require_relative "linter/rules/no_trailing_spaces"
9
10
  require_relative "linter/rules/no_multiple_blanks"
10
11
  require_relative "linter/rules/first_line_heading"
12
+ require_relative "linter/rules/line_length"
13
+ require_relative "linter/rules/link_check"
14
+ require_relative "linter/rules/japanese"
15
+ require_relative "linter/rules/code_block_syntax"
16
+ require_relative "linter/rules/source_style"
11
17
 
12
18
  module Mdlint
13
19
  module Linter
14
20
  class << self
15
21
  def check(src, options = {})
16
- tokens = Parser.parse(src)
22
+ tokens = Parser.parse(src, options)
17
23
  engine = RuleEngine.new(options)
18
- engine.check(tokens, src)
24
+ DirectiveFilter.apply(engine.check(tokens, src), src)
19
25
  end
20
26
 
21
27
  def fix(src, options = {})
22
- tokens = Parser.parse(src)
28
+ tokens = Parser.parse(src, options)
23
29
  engine = RuleEngine.new(options)
24
30
  engine.fix(tokens, src)
25
31
  end