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
@@ -1,17 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../dialect"
4
+
3
5
  module Mdlint
4
6
  module Parser
5
7
  class InlineParser
6
8
  ESCAPE_CHARS = '!"#$%&\'()*+,\\-./:;<=>?@[\\\\\\]^_`{|}~'
7
9
  ESCAPE_REGEXP = /\\([#{Regexp.escape(ESCAPE_CHARS)}])/
8
10
  BACKTICK_REGEXP = /(`+)(.+?)\1(?!`)/
9
- AUTOLINK_REGEXP = %r{<((?:https?|ftp)://[^>]+)>}
11
+ AUTOLINK_REGEXP = %r{<(([A-Za-z][A-Za-z0-9+.-]{1,31}):[^\s<>]+)>}
10
12
  EMAIL_AUTOLINK_REGEXP = /<([a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*)>/
11
- HTML_INLINE_REGEXP = %r{</?[a-zA-Z][a-zA-Z0-9]*(?:\s+[^>]*)?>}
13
+ HTML_INLINE_REGEXP = %r{</?[a-zA-Z][a-zA-Z0-9:-]*(?:\s+[^>]*)?/?>}
12
14
 
13
15
  def initialize(options = {})
14
16
  @options = options
17
+ @dialect = Dialect.resolve(options[:dialect])
15
18
  end
16
19
 
17
20
  def parse(content)
@@ -27,24 +30,62 @@ module Mdlint
27
30
  while pos < content.length
28
31
  remaining = content[pos..]
29
32
 
30
- if (match = remaining.match(/\A\\([\\`*_\[\]()#+\-.!{}<>])/))
33
+ if (match = remaining.match(/\A\[\^([^\]]+)\]/))
34
+ flush_text(text_buffer, tokens)
35
+ text_buffer = ""
36
+ tokens << Token.new(type: :footnote_ref, attrs: { label: match[1].downcase })
37
+ pos += match[0].length
38
+ elsif (match = remaining.match(/\A\$([^$\n]+)\$/))
39
+ flush_text(text_buffer, tokens)
40
+ text_buffer = ""
41
+ tokens << Token.new(type: :math_inline, content: match[1])
42
+ pos += match[0].length
43
+ elsif remaining.start_with?("\\\n")
44
+ flush_text(text_buffer, tokens)
45
+ text_buffer = ""
46
+ tokens << Token.new(type: :hardbreak, tag: "br")
47
+ pos += 2
48
+ elsif (match = remaining.match(/\A#{ESCAPE_REGEXP}/))
31
49
  flush_text(text_buffer, tokens)
32
50
  text_buffer = ""
33
51
  tokens << Token.new(type: :text, content: match[1])
34
52
  pos += match[0].length
35
53
  elsif remaining.start_with?("![")
36
- if (match = remaining.match(/\A!\[([^\]]*)\]\(([^)\s]*)(?:\s+"([^"]*)")?\)/))
54
+ if (match = inline_link_match(remaining, image: true))
37
55
  flush_text(text_buffer, tokens)
38
56
  text_buffer = ""
39
57
  tokens << Token.new(
40
58
  type: :image,
41
59
  tag: "img",
42
60
  attrs: {
43
- src: match[2],
44
- alt: match[1],
45
- title: match[3]
61
+ src: match[:url],
62
+ alt: match[:text],
63
+ title: match[:title]
46
64
  }.compact,
47
- content: match[1]
65
+ content: match[:text]
66
+ )
67
+ pos += match[:length]
68
+ elsif (match = remaining.match(/\A!\[([^\]]*)\]\[([^\]]*)\]/))
69
+ flush_text(text_buffer, tokens)
70
+ text_buffer = ""
71
+ label = match[2].empty? ? match[1] : match[2]
72
+ tokens << Token.new(
73
+ type: :image,
74
+ tag: "img",
75
+ attrs: { reference_label: label.downcase, reference_kind: :full, alt: match[1] },
76
+ content: match[1],
77
+ markup: "reference"
78
+ )
79
+ pos += match[0].length
80
+ elsif (match = remaining.match(/\A!\[([^\]]+)\](?!\()/))
81
+ flush_text(text_buffer, tokens)
82
+ text_buffer = ""
83
+ tokens << Token.new(
84
+ type: :image,
85
+ tag: "img",
86
+ attrs: { reference_label: match[1].downcase, reference_kind: :shortcut, alt: match[1] },
87
+ content: match[1],
88
+ markup: "reference"
48
89
  )
49
90
  pos += match[0].length
50
91
  else
@@ -53,7 +94,7 @@ module Mdlint
53
94
  end
54
95
  elsif remaining.start_with?("[")
55
96
  # Inline link: [text](url "title")
56
- if (match = remaining.match(/\A\[([^\]]*)\]\(([^)\s]*)(?:\s+"([^"]*)")?\)/))
97
+ if (match = inline_link_match(remaining))
57
98
  flush_text(text_buffer, tokens)
58
99
  text_buffer = ""
59
100
  tokens << Token.new(
@@ -61,17 +102,17 @@ module Mdlint
61
102
  tag: "a",
62
103
  nesting: 1,
63
104
  attrs: {
64
- href: match[2],
65
- title: match[3]
105
+ href: match[:url],
106
+ title: match[:title]
66
107
  }.compact
67
108
  )
68
- parse_inline(match[1], tokens)
109
+ parse_inline(match[:text], tokens)
69
110
  tokens << Token.new(
70
111
  type: :link_close,
71
112
  tag: "a",
72
113
  nesting: -1
73
114
  )
74
- pos += match[0].length
115
+ pos += match[:length]
75
116
  # Full reference link: [text][label]
76
117
  elsif (match = remaining.match(/\A\[([^\]]*)\]\[([^\]]*)\]/))
77
118
  flush_text(text_buffer, tokens)
@@ -81,7 +122,7 @@ module Mdlint
81
122
  type: :link_open,
82
123
  tag: "a",
83
124
  nesting: 1,
84
- attrs: { reference_label: label.downcase },
125
+ attrs: { reference_label: label.downcase, reference_kind: :full },
85
126
  markup: "reference"
86
127
  )
87
128
  parse_inline(match[1], tokens)
@@ -93,7 +134,7 @@ module Mdlint
93
134
  )
94
135
  pos += match[0].length
95
136
  # Shortcut reference link: [label]
96
- elsif (match = remaining.match(/\A\[([^\]]+)\](?!\(|\[)/))
137
+ elsif (match = remaining.match(/\A\[([^\[\]]+)\](?!\(|\[)/))
97
138
  flush_text(text_buffer, tokens)
98
139
  text_buffer = ""
99
140
  label = match[1]
@@ -101,7 +142,7 @@ module Mdlint
101
142
  type: :link_open,
102
143
  tag: "a",
103
144
  nesting: 1,
104
- attrs: { reference_label: label.downcase },
145
+ attrs: { reference_label: label.downcase, reference_kind: :shortcut },
105
146
  markup: "reference"
106
147
  )
107
148
  tokens << Token.new(type: :text, content: label)
@@ -117,24 +158,27 @@ module Mdlint
117
158
  pos += 1
118
159
  end
119
160
  elsif remaining.start_with?("`")
120
- if (match = remaining.match(/\A(`+)(.+?)\1(?!`)/m))
161
+ if (match = code_span_match(remaining))
121
162
  flush_text(text_buffer, tokens)
122
163
  text_buffer = ""
123
- code_content = match[2]
124
- code_content = code_content.strip if code_content.start_with?(" ") && code_content.end_with?(" ")
164
+ code_content = match[:content]
165
+ code_content = code_content.gsub(/\r?\n/, " ")
166
+ if code_content.start_with?(" ") && code_content.end_with?(" ") && !code_content.match?(/\A +\z/)
167
+ code_content = code_content[1...-1]
168
+ end
125
169
  tokens << Token.new(
126
170
  type: :code_inline,
127
171
  tag: "code",
128
172
  content: code_content,
129
- markup: match[1]
173
+ markup: match[:delimiter]
130
174
  )
131
- pos += match[0].length
175
+ pos += match[:length]
132
176
  else
133
177
  text_buffer += remaining[0]
134
178
  pos += 1
135
179
  end
136
180
  elsif remaining.start_with?("**")
137
- if (match = remaining.match(/\A\*\*(?!\s)(.+?)(?<!\s)\*\*/m))
181
+ if (match = emphasis_match(remaining, "**", previous: pos.zero? ? nil : content[pos - 1], strong: true))
138
182
  flush_text(text_buffer, tokens)
139
183
  text_buffer = ""
140
184
  tokens << Token.new(type: :strong_open, tag: "strong", nesting: 1, markup: "**")
@@ -146,7 +190,7 @@ module Mdlint
146
190
  pos += 1
147
191
  end
148
192
  elsif remaining.start_with?("__")
149
- if (match = remaining.match(/\A__(?!\s)(.+?)(?<!\s)__/m))
193
+ if (match = emphasis_match(remaining, "__", previous: pos.zero? ? nil : content[pos - 1], strong: true))
150
194
  flush_text(text_buffer, tokens)
151
195
  text_buffer = ""
152
196
  tokens << Token.new(type: :strong_open, tag: "strong", nesting: 1, markup: "__")
@@ -157,8 +201,20 @@ module Mdlint
157
201
  text_buffer += remaining[0]
158
202
  pos += 1
159
203
  end
204
+ elsif @dialect.feature?(:strikethrough) && remaining.start_with?("~~")
205
+ if (match = remaining.match(/\A~~(?!\s)(.+?)(?<!\s)~~/m))
206
+ flush_text(text_buffer, tokens)
207
+ text_buffer = ""
208
+ tokens << Token.new(type: :s_open, tag: "del", nesting: 1, markup: "~~")
209
+ parse_inline(match[1], tokens)
210
+ tokens << Token.new(type: :s_close, tag: "del", nesting: -1, markup: "~~")
211
+ pos += match[0].length
212
+ else
213
+ text_buffer += remaining[0]
214
+ pos += 1
215
+ end
160
216
  elsif remaining.start_with?("*")
161
- if (match = remaining.match(/\A\*(?!\s)(.+?)(?<!\s)\*/m))
217
+ if (match = emphasis_match(remaining, "*", previous: pos.zero? ? nil : content[pos - 1]))
162
218
  flush_text(text_buffer, tokens)
163
219
  text_buffer = ""
164
220
  tokens << Token.new(type: :em_open, tag: "em", nesting: 1, markup: "*")
@@ -170,7 +226,7 @@ module Mdlint
170
226
  pos += 1
171
227
  end
172
228
  elsif remaining.start_with?("_")
173
- if (match = remaining.match(/\A_(?!\s)(.+?)(?<!\s)_/m))
229
+ if (match = emphasis_match(remaining, "_", previous: pos.zero? ? nil : content[pos - 1]))
174
230
  flush_text(text_buffer, tokens)
175
231
  text_buffer = ""
176
232
  tokens << Token.new(type: :em_open, tag: "em", nesting: 1, markup: "_")
@@ -227,11 +283,26 @@ module Mdlint
227
283
  text_buffer += remaining[0]
228
284
  pos += 1
229
285
  end
230
- elsif remaining.start_with?(" \n")
286
+ elsif @dialect.feature?(:bare_autolinks) && (match = remaining.match(/\A((?:https?|ftp):\/\/[^\s<]+)/))
287
+ flush_text(text_buffer, tokens)
288
+ text_buffer = ""
289
+ href = match[1].sub(/[.,!?;:]\z/, "")
290
+ consumed = href.length
291
+ tokens << Token.new(
292
+ type: :link_open,
293
+ tag: "a",
294
+ nesting: 1,
295
+ attrs: { href: href },
296
+ markup: "autolink"
297
+ )
298
+ tokens << Token.new(type: :text, content: href)
299
+ tokens << Token.new(type: :link_close, tag: "a", nesting: -1, markup: "autolink")
300
+ pos += consumed
301
+ elsif (match = remaining.match(/\A {2,}\n/))
231
302
  flush_text(text_buffer, tokens)
232
303
  text_buffer = ""
233
304
  tokens << Token.new(type: :hardbreak, tag: "br")
234
- pos += 3
305
+ pos += match[0].length
235
306
  elsif remaining[0] == "\n"
236
307
  flush_text(text_buffer, tokens)
237
308
  text_buffer = ""
@@ -248,6 +319,167 @@ module Mdlint
248
319
 
249
320
  private
250
321
 
322
+ def inline_link_match(value, image: false)
323
+ prefix = image ? "![" : "["
324
+ return unless value.start_with?(prefix)
325
+
326
+ depth = 1
327
+ index = prefix.length
328
+ while index < value.length
329
+ if value[index] == "\\"
330
+ index += 2
331
+ next
332
+ end
333
+ if value[index] == "`" && (code = code_span_match(value[index..]))
334
+ index += code[:length]
335
+ next
336
+ end
337
+ if value[index] == "["
338
+ depth += 1
339
+ elsif value[index] == "]"
340
+ depth -= 1
341
+ break if depth.zero?
342
+ end
343
+ index += 1
344
+ end
345
+ return unless depth.zero? && value[index + 1] == "("
346
+
347
+ destination = parse_link_destination(value[(index + 2)..])
348
+ return unless destination
349
+
350
+ {
351
+ text: value[prefix.length...index],
352
+ url: destination[:url],
353
+ title: destination[:title],
354
+ length: index + 2 + destination[:length]
355
+ }
356
+ end
357
+
358
+ def parse_link_destination(value)
359
+ index = 0
360
+ index += 1 while index < value.length && value[index].match?(/\s/)
361
+ return { url: "", title: nil, length: index + 1 } if value[index] == ")"
362
+
363
+ if value[index] == "<"
364
+ closing = find_unescaped(value, ">", index + 1)
365
+ return unless closing
366
+
367
+ url = value[(index + 1)...closing]
368
+ return if url.match?(/[\r\n]/)
369
+ index = closing + 1
370
+ else
371
+ start = index
372
+ depth = 0
373
+ while index < value.length
374
+ character = value[index]
375
+ if character == "\\"
376
+ index += 2
377
+ next
378
+ end
379
+ if character == "("
380
+ depth += 1
381
+ elsif character == ")"
382
+ break if depth.zero?
383
+
384
+ depth -= 1
385
+ elsif character.match?(/\s/) && depth.zero?
386
+ break
387
+ end
388
+ index += 1
389
+ end
390
+ return if index == start
391
+
392
+ url = value[start...index]
393
+ end
394
+
395
+ whitespace = index
396
+ index += 1 while index < value.length && value[index].match?(/\s/)
397
+ return { url: url, title: nil, length: index + 1 } if value[index] == ")"
398
+ return unless index > whitespace
399
+
400
+ title_start = value[index]
401
+ title_end = { '"' => '"', "'" => "'", "(" => ")" }[title_start]
402
+ return unless title_end
403
+
404
+ closing = find_unescaped(value, title_end, index + 1)
405
+ return unless closing
406
+ remainder = closing + 1
407
+ remainder += 1 while remainder < value.length && value[remainder].match?(/\s/)
408
+ return unless value[remainder] == ")"
409
+
410
+ { url: url, title: value[(index + 1)...closing], length: remainder + 1 }
411
+ end
412
+
413
+ def find_unescaped(value, character, start)
414
+ index = start
415
+ while index < value.length
416
+ return index if value[index] == character && (index.zero? || value[index - 1] != "\\")
417
+
418
+ index += 1
419
+ end
420
+ nil
421
+ end
422
+
423
+ def code_span_match(value)
424
+ opening = value[/\A`+/]
425
+ return unless opening
426
+
427
+ delimiter = opening
428
+ search = delimiter.length
429
+ while (closing = value.index(delimiter, search))
430
+ run = value[closing..].match(/\A`+/)[0].length
431
+ if run == delimiter.length
432
+ return { delimiter: delimiter, content: value[delimiter.length...closing], length: closing + delimiter.length }
433
+ end
434
+ search = closing + run
435
+ end
436
+ nil
437
+ end
438
+
439
+ def emphasis_match(value, delimiter, previous:, strong: false)
440
+ escaped = Regexp.escape(delimiter)
441
+ match = value.match(/\A#{escaped}(?!\s)(.+?)(?<!\s)#{escaped}/m)
442
+ return unless match
443
+
444
+ opening_next = match[1][0]
445
+ closing_previous = match[1][-1]
446
+ after = value[match[0].length]
447
+ return unless delimiter_open?(delimiter, previous, opening_next)
448
+ return unless delimiter_close?(delimiter, closing_previous, after)
449
+
450
+ match
451
+ end
452
+
453
+ def delimiter_open?(delimiter, before, after)
454
+ before_space = whitespace_character?(before)
455
+ after_space = whitespace_character?(after)
456
+ before_punctuation = punctuation_character?(before)
457
+ after_punctuation = punctuation_character?(after)
458
+ left_flanking = !after_space && (!after_punctuation || before_space || before_punctuation)
459
+ right_flanking = !before_space && (!before_punctuation || after_space || after_punctuation)
460
+
461
+ delimiter.start_with?("_") ? left_flanking && (!right_flanking || before_punctuation || before_space) : left_flanking
462
+ end
463
+
464
+ def delimiter_close?(delimiter, before, after)
465
+ before_space = whitespace_character?(before)
466
+ after_space = whitespace_character?(after)
467
+ before_punctuation = punctuation_character?(before)
468
+ after_punctuation = punctuation_character?(after)
469
+ left_flanking = !after_space && (!after_punctuation || before_space || before_punctuation)
470
+ right_flanking = !before_space && (!before_punctuation || after_space || after_punctuation)
471
+
472
+ delimiter.start_with?("_") ? right_flanking && (!left_flanking || after_punctuation || after_space) : right_flanking
473
+ end
474
+
475
+ def whitespace_character?(character)
476
+ character.nil? || character.match?(/\s|\p{Space}/u)
477
+ end
478
+
479
+ def punctuation_character?(character)
480
+ !character.nil? && character.match?(/[[:punct:]]/u)
481
+ end
482
+
251
483
  def flush_text(buffer, tokens)
252
484
  return if buffer.empty?
253
485
 
@@ -3,12 +3,13 @@
3
3
  module Mdlint
4
4
  module Parser
5
5
  class State
6
- attr_reader :src, :lines, :line_offsets
6
+ attr_reader :src, :lines, :raw_lines, :line_offsets
7
7
  attr_accessor :line, :pos, :tokens, :level
8
8
 
9
9
  def initialize(src)
10
10
  @src = src
11
- @lines = src.split("\n", -1)
11
+ @raw_lines = src.split("\n", -1)
12
+ @lines = @raw_lines.map { |line| expand_tabs(line) }
12
13
  @line_offsets = build_line_offsets
13
14
  @line = 0
14
15
  @pos = 0
@@ -24,6 +25,10 @@ module Mdlint
24
25
  @lines[@line]
25
26
  end
26
27
 
28
+ def raw_line
29
+ @raw_lines[@line]
30
+ end
31
+
27
32
  def next_line
28
33
  @line += 1
29
34
  end
@@ -50,6 +55,20 @@ module Mdlint
50
55
 
51
56
  private
52
57
 
58
+ def expand_tabs(line)
59
+ column = 0
60
+ line.each_char.with_object(+'') do |character, expanded|
61
+ if character == "\t"
62
+ spaces = 4 - (column % 4)
63
+ expanded << (" " * spaces)
64
+ column += spaces
65
+ else
66
+ expanded << character
67
+ column += 1
68
+ end
69
+ end
70
+ end
71
+
53
72
  def build_line_offsets
54
73
  offsets = [0]
55
74
  @lines.each do |line|
data/lib/mdlint/parser.rb CHANGED
@@ -7,17 +7,17 @@ require_relative "parser/inline_parser"
7
7
  module Mdlint
8
8
  module Parser
9
9
  class << self
10
- def parse(src)
11
- block_parser = BlockParser.new
10
+ def parse(src, options = {})
11
+ block_parser = BlockParser.new(options)
12
12
  tokens = block_parser.parse(src)
13
- parse_inline_tokens(tokens)
13
+ parse_inline_tokens(tokens, options)
14
14
  tokens
15
15
  end
16
16
 
17
17
  private
18
18
 
19
- def parse_inline_tokens(tokens)
20
- inline_parser = InlineParser.new
19
+ def parse_inline_tokens(tokens, options)
20
+ inline_parser = InlineParser.new(options)
21
21
  tokens.each do |token|
22
22
  next unless token.type == :inline
23
23
 
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mdlint
4
+ module Plugin
5
+ class Error < Mdlint::Error
6
+ end
7
+
8
+ def self.register_rule(rule_class)
9
+ unless defined?(Mdlint::Linter::Rule) && rule_class.is_a?(Class) && rule_class < Mdlint::Linter::Rule
10
+ raise Error, "plugin rules must inherit from Mdlint::Linter::Rule"
11
+ end
12
+
13
+ Mdlint::Linter::RuleRegistry.register(rule_class)
14
+ rule_class
15
+ end
16
+
17
+ def self.register_dialect(name, features: [])
18
+ Mdlint::Dialect.register(name, features: features)
19
+ end
20
+
21
+ def self.unregister_rule(rule_class)
22
+ Mdlint::Linter::RuleRegistry.unregister(rule_class)
23
+ end
24
+
25
+ def self.load(path)
26
+ require File.expand_path(path, Dir.pwd)
27
+ rescue LoadError, StandardError => error
28
+ raise Error, "could not load plugin #{path}: #{error.message}"
29
+ end
30
+ end
31
+ end