vinter 0.2.0 → 0.4.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.
data/lib/vinter/lexer.rb CHANGED
@@ -2,17 +2,18 @@ module Vinter
2
2
  class Lexer
3
3
  TOKEN_TYPES = {
4
4
  # Vim9 specific keywords
5
- keyword: /\b(if|else|elseif|endif|while|endwhile|for|endfor|def|enddef|function|endfunction|return|const|var|final|import|export|class|extends|static|enum|type|vim9script|abort)\b/,
5
+ keyword: /\b(if|else|elseif|endif|while|endwhile|for|endfor|def|enddef|function|endfunction|endfunc|return|const|var|final|import|export|class|extends|static|enum|type|vim9script|abort|autocmd|echom|echoerr|echohl|echomsg|let|unlet|execute|exec|continue|break|try|catch|finally|endtry|throw|runtime|silent|delete|command|call|set|setlocal|syntax|highlight|sleep|source|nnoremap|nmap|inoremap|imap|vnoremap|vmap|xnoremap|xmap|cnoremap|cmap|noremap|map)\b/,
6
6
  # Identifiers can include # and special characters
7
7
  identifier: /\b[a-zA-Z_][a-zA-Z0-9_#]*\b/,
8
8
  # Single-character operators
9
- operator: /[\+\-\*\/=<>!&\|\.]/,
9
+ operator: /[\+\-\*\/=%<>!&\|\.]/,
10
10
  # Multi-character operators handled separately
11
- number: /\b\d+(\.\d+)?\b/,
11
+ number: /\b(0[xX][0-9A-Fa-f]+|0[oO][0-7]+|0[bB][01]+|\d+(\.\d+)?([eE][+-]?\d+)?[smh]?)\b/,
12
12
  # Handle both single and double quoted strings
13
- string: /"([^"\\]|\\.)*"|'([^'\\]|\\.)*'/,
13
+ # string: /"(\\"|[^"])*"|'(\\'|[^'])*'/,
14
+ register_access: /@[a-zA-Z0-9":.%#=*+~_\/\-]/,
14
15
  # Vim9 comments use #
15
- comment: /#.*/,
16
+ comment: /(#|").*/,
16
17
  whitespace: /\s+/,
17
18
  brace_open: /\{/,
18
19
  brace_close: /\}/,
@@ -23,8 +24,12 @@ module Vinter
23
24
  colon: /:/,
24
25
  semicolon: /;/,
25
26
  comma: /,/,
27
+ backslash: /\\/,
28
+ question_mark: /\?/,
29
+ command_separator: /\|/,
26
30
  }
27
31
 
32
+ CONTINUATION_OPERATORS = %w(. .. + - * / = == ==# ==? != > < >= <= && || ? : -> =>)
28
33
  def initialize(input)
29
34
  @input = input
30
35
  @tokens = []
@@ -33,36 +38,428 @@ module Vinter
33
38
  @column = 1
34
39
  end
35
40
 
41
+ def should_parse_as_regex
42
+ # Look at recent tokens to determine if we're in a regex context
43
+ recent_tokens = @tokens.last(3)
44
+
45
+ # Check for contexts where regex is expected
46
+ return true if recent_tokens.any? { |t|
47
+ t && t[:type] == :keyword && ['syntax'].include?(t[:value])
48
+ }
49
+
50
+ return true if recent_tokens.any? { |t|
51
+ t && t[:type] == :identifier && ['match', 'region', 'keyword'].include?(t[:value])
52
+ }
53
+
54
+ # Check for comparison operators that often use regex
55
+ return true if recent_tokens.any? { |t|
56
+ t && t[:type] == :operator && ['=~', '!~', '=~#', '!~#', '=~?', '!~?'].include?(t[:value])
57
+ }
58
+
59
+ false
60
+ end
61
+
62
+ def find_unescaped_newline(chunk)
63
+ i = 0
64
+ while i < chunk.length
65
+ if chunk[i] == "\n" && (i == 0 || chunk[i - 1] != '\\')
66
+ return i
67
+ end
68
+ i += 1
69
+ end
70
+ nil # Return nil if no unescaped newline is found
71
+ end
72
+
36
73
  def tokenize
37
74
  until @position >= @input.length
38
75
  chunk = @input[@position..-1]
76
+
77
+ # First check if the line starts with a quote (comment in Vim)
78
+ # Check if we're at the beginning of a line (optionally after whitespace)
79
+ line_start = @position == 0 || @input[@position - 1] == "\n"
80
+ if !line_start
81
+ # Check if we're after whitespace at the start of a line
82
+ temp_pos = @position - 1
83
+ while temp_pos >= 0 && @input[temp_pos] =~ /[ \t]/
84
+ temp_pos -= 1
85
+ end
86
+ line_start = temp_pos < 0 || @input[temp_pos] == "\n"
87
+ end
39
88
 
40
- # Handle multi-character operators explicitly
41
- if match = chunk.match(/\A(==|!=|=>|->|\.\.)/)
42
- @tokens << {
43
- type: :operator,
44
- value: match[0],
45
- line: @line_num,
46
- column: @column
89
+ # If we're at the start of a line and it begins with a quote
90
+ if line_start && chunk.start_with?('"')
91
+ # Find the end of the line
92
+ line_end = find_unescaped_newline(chunk) || chunk.length
93
+ comment_text = chunk[0...line_end]
94
+
95
+ @tokens << {
96
+ type: :comment,
97
+ value: comment_text,
98
+ line: @line_num,
99
+ column: @column
100
+ }
101
+
102
+ @position += comment_text.length
103
+ @column += comment_text.length
104
+ next
105
+ end
106
+ # Handle string literals manually
107
+ if chunk.start_with?("'") || chunk.start_with?('"')
108
+ quote = chunk[0]
109
+ i = 1
110
+ escaped = false
111
+ string_value = quote
112
+
113
+ # Keep going until we find an unescaped closing quote
114
+ while i < chunk.length
115
+ char = chunk[i]
116
+ string_value += char
117
+
118
+ if char == '\\' && !escaped
119
+ escaped = true
120
+ elsif (char == "\n" or char == quote) && !escaped
121
+ # Found closing quote
122
+ break
123
+ elsif escaped
124
+ escaped = false
125
+ end
126
+
127
+ i += 1
128
+ end
129
+
130
+ # Add the string token if we found a closing quote
131
+ if i < chunk.length || (i == chunk.length && chunk[-1] == quote)
132
+ @tokens << {
133
+ type: :string,
134
+ value: string_value,
135
+ line: @line_num,
136
+ column: @column
137
+ }
138
+
139
+ @column += string_value.length
140
+ @position += string_value.length
141
+ @line_num += 1 if string_value.include?("\n")
142
+ next
143
+ end
144
+ end
145
+
146
+ # Add special handling for command options in the tokenize method
147
+ if chunk.start_with?('<q-args>', '<f-args>', '<args>')
148
+ arg_token = chunk.match(/\A(<q-args>|<f-args>|<args>)/)[0]
149
+ @tokens << {
150
+ type: :command_arg_placeholder,
151
+ value: arg_token,
152
+ line: @line_num,
153
+ column: @column
154
+ }
155
+ @column += arg_token.length
156
+ @position += arg_token.length
157
+ next
158
+ end
159
+
160
+ # Special handling for a:000 variable arguments array
161
+ if chunk =~ /\Aa:0+/
162
+ varargs_token = chunk.match(/\Aa:0+/)[0]
163
+ @tokens << {
164
+ type: :arg_variable,
165
+ value: varargs_token,
166
+ line: @line_num,
167
+ column: @column
168
+ }
169
+ @column += varargs_token.length
170
+ @position += varargs_token.length
171
+ next
172
+ end
173
+
174
+ # Also add special handling for 'silent!' keyword
175
+ # Add this after the keyword check in tokenize method
176
+ if chunk.start_with?('silent!')
177
+ @tokens << {
178
+ type: :silent_bang,
179
+ value: 'silent!',
180
+ line: @line_num,
181
+ column: @column
182
+ }
183
+ @column += 7
184
+ @position += 7
185
+ next
186
+ end
187
+
188
+ # Check for keywords first, before other token types
189
+ if match = chunk.match(/\A\b(if|else|elseif|endif|while|endwhile|for|endfor|def|enddef|function|endfunction|endfunc|return|const|var|final|import|export|class|extends|static|enum|type|vim9script|abort|autocmd|echoerr|echohl|echomsg|let|unlet|execute|setlocal|syntax|highlight|sleep|source)\b/)
190
+ @tokens << {
191
+ type: :keyword,
192
+ value: match[0],
193
+ line: @line_num,
194
+ column: @column
195
+ }
196
+ @column += match[0].length
197
+ @position += match[0].length
198
+ next
199
+ end
200
+
201
+ # Handle Vim scoped option variables with &l: or &g: prefix
202
+ if match = chunk.match(/\A&[lg]:[a-zA-Z_][a-zA-Z0-9_]*/)
203
+ @tokens << {
204
+ type: :scoped_option_variable,
205
+ value: match[0],
206
+ line: @line_num,
207
+ column: @column
47
208
  }
48
209
  @column += match[0].length
49
210
  @position += match[0].length
50
211
  next
51
212
  end
52
213
 
214
+ # Handle Vim option variables with & prefix
215
+ if match = chunk.match(/\A&[a-zA-Z_][a-zA-Z0-9_]*/)
216
+ @tokens << {
217
+ type: :option_variable,
218
+ value: match[0],
219
+ line: @line_num,
220
+ column: @column
221
+ }
222
+ @column += match[0].length
223
+ @position += match[0].length
224
+ next
225
+ end
226
+
227
+ # Handle Vim special variables with v: prefix
228
+ if match = chunk.match(/\Av:[a-zA-Z_][a-zA-Z0-9_]*/)
229
+ @tokens << {
230
+ type: :special_variable,
231
+ value: match[0],
232
+ line: @line_num,
233
+ column: @column
234
+ }
235
+ @column += match[0].length
236
+ @position += match[0].length
237
+ next
238
+ end
239
+
240
+ # Handle script-local identifiers with s: prefix
241
+ if match = chunk.match(/\As:[a-zA-Z_][a-zA-Z0-9_]*/)
242
+ @tokens << {
243
+ type: :script_local,
244
+ value: match[0],
245
+ line: @line_num,
246
+ column: @column
247
+ }
248
+ @column += match[0].length
249
+ @position += match[0].length
250
+ next
251
+ end
252
+
253
+ # Handle buffer-local identifiers with b: prefix
254
+ if match = chunk.match(/\Ab:[a-zA-Z_][a-zA-Z0-9_]*/)
255
+ @tokens << {
256
+ type: :buffer_local,
257
+ value: match[0],
258
+ line: @line_num,
259
+ column: @column
260
+ }
261
+ @column += match[0].length
262
+ @position += match[0].length
263
+ next
264
+ end
265
+
266
+ # Handle window-local identifiers with w: prefix
267
+ if match = chunk.match(/\Aw:[a-zA-Z_][a-zA-Z0-9_]*/)
268
+ @tokens << {
269
+ type: :window_local,
270
+ value: match[0],
271
+ line: @line_num,
272
+ column: @column
273
+ }
274
+ @column += match[0].length
275
+ @position += match[0].length
276
+ next
277
+ end
278
+
279
+ # Handle tab-local identifiers with t: prefix
280
+ if match = chunk.match(/\At:[a-zA-Z_][a-zA-Z0-9_]*/)
281
+ @tokens << {
282
+ type: :tab_local,
283
+ value: match[0],
284
+ line: @line_num,
285
+ column: @column
286
+ }
287
+ @column += match[0].length
288
+ @position += match[0].length
289
+ next
290
+ end
291
+
292
+
293
+ # Handle global variables with g: prefix
294
+ if match = chunk.match(/\Ag:[a-zA-Z_][a-zA-Z0-9_]*/)
295
+ @tokens << {
296
+ type: :global_variable,
297
+ value: match[0],
298
+ line: @line_num,
299
+ column: @column
300
+ }
301
+ @column += match[0].length
302
+ @position += match[0].length
303
+ next
304
+ end
305
+
306
+ # Handle argument variables with a: prefix
307
+ if match = chunk.match(/\Aa:[a-zA-Z_][a-zA-Z0-9_]*/) || match = chunk.match(/\Aa:[A-Z0-9]/)
308
+ @tokens << {
309
+ type: :arg_variable,
310
+ value: match[0],
311
+ line: @line_num,
312
+ column: @column
313
+ }
314
+ @column += match[0].length
315
+ @position += match[0].length
316
+ next
317
+ end
318
+
319
+ # Handle argument variables with a: prefix
320
+ if match = chunk.match(/\Al:[a-zA-Z_][a-zA-Z0-9_]*/)
321
+ @tokens << {
322
+ type: :local_variable,
323
+ value: match[0],
324
+ line: @line_num,
325
+ column: @column
326
+ }
327
+ @column += match[0].length
328
+ @position += match[0].length
329
+ next
330
+ end
331
+
332
+ # Add support for standalone namespace prefixes (like g:)
333
+ if match = chunk.match(/\A([sgbwtal]):/)
334
+ @tokens << {
335
+ type: :namespace_prefix,
336
+ value: match[0],
337
+ line: @line_num,
338
+ column: @column
339
+ }
340
+ @column += match[0].length
341
+ @position += match[0].length
342
+ next
343
+ end
344
+
345
+ # Handle compound assignment operators
346
+ if match = chunk.match(/\A(\+=|-=|\*=|\/=|\.\.=|\.=)/)
347
+ @tokens << {
348
+ type: :compound_operator,
349
+ value: match[0],
350
+ line: @line_num,
351
+ column: @column
352
+ }
353
+ @column += match[0].length
354
+ @position += match[0].length
355
+ next
356
+ end
357
+
53
358
  # Handle ellipsis for variable args
54
359
  if chunk.start_with?('...')
55
- @tokens << {
56
- type: :ellipsis,
57
- value: '...',
58
- line: @line_num,
59
- column: @column
360
+ @tokens << {
361
+ type: :ellipsis,
362
+ value: '...',
363
+ line: @line_num,
364
+ column: @column
60
365
  }
61
366
  @column += 3
62
367
  @position += 3
63
368
  next
64
369
  end
65
-
370
+
371
+ # Handle multi-character operators explicitly
372
+ if match = chunk.match(/\A(=~#|=~\?|=~|!~#|!~\?|!~|==#|==\?|==|!=#|!=\?|!=|=>\?|=>|>=#|>=\?|>=|<=#|<=\?|<=|->#|->\?|->|\.\.|\|\||&&)/)
373
+ @tokens << {
374
+ type: :operator,
375
+ value: match[0],
376
+ line: @line_num,
377
+ column: @column
378
+ }
379
+ @column += match[0].length
380
+ @position += match[0].length
381
+ next
382
+ end
383
+
384
+ # Handle regex patterns /pattern/ - only in specific contexts
385
+ if chunk.start_with?('/') && should_parse_as_regex
386
+ i = 1
387
+ regex_value = '/'
388
+
389
+ # Keep going until we find the closing slash
390
+ while i < chunk.length
391
+ char = chunk[i]
392
+ regex_value += char
393
+
394
+ if char == '/' && (i == 1 || chunk[i-1] != '\\')
395
+ # Found closing slash
396
+ i += 1
397
+ break
398
+ end
399
+
400
+ i += 1
401
+ end
402
+
403
+ # Add the regex token if we found a closing slash
404
+ if regex_value.end_with?('/')
405
+ @tokens << {
406
+ type: :regex,
407
+ value: regex_value,
408
+ line: @line_num,
409
+ column: @column
410
+ }
411
+ @column += regex_value.length
412
+ @position += regex_value.length
413
+ next
414
+ end
415
+ end
416
+
417
+ # Handle hex colors like #33FF33
418
+ if match = chunk.match(/\A#[0-9A-Fa-f]{6}/)
419
+ @tokens << {
420
+ type: :hex_color,
421
+ value: match[0],
422
+ line: @line_num,
423
+ column: @column
424
+ }
425
+ @column += match[0].length
426
+ @position += match[0].length
427
+ next
428
+ end
429
+
430
+ # Handle register access (@a, @", etc.)
431
+ if chunk =~ /\A@[a-zA-Z0-9":.%#=*+~_\/\-]/
432
+ register_token = chunk.match(/\A@[a-zA-Z0-9":.%#=*+~_\/\-]/)[0]
433
+ @tokens << {
434
+ type: :register_access,
435
+ value: register_token,
436
+ line: @line_num,
437
+ column: @column
438
+ }
439
+ @column += register_token.length
440
+ @position += register_token.length
441
+ next
442
+ end
443
+
444
+ # In the tokenize method, add special handling for common mapping components
445
+ if chunk.start_with?('<CR>', '<Esc>', '<Tab>', '<Space>', '<C-') ||
446
+ (chunk =~ /\A<[A-Za-z0-9\-_]+>/)
447
+ # Extract the special key notation
448
+ match = chunk.match(/\A(<[^>]+>)/)
449
+ if match
450
+ special_key = match[1]
451
+ @tokens << {
452
+ type: :special_key,
453
+ value: special_key,
454
+ line: @line_num,
455
+ column: @column
456
+ }
457
+ @position += special_key.length
458
+ @column += special_key.length
459
+ next
460
+ end
461
+ end
462
+
66
463
  # Skip whitespace but track position
67
464
  if match = chunk.match(/\A(\s+)/)
68
465
  whitespace = match[0]
@@ -77,20 +474,55 @@ module Vinter
77
474
  @position += whitespace.length
78
475
  next
79
476
  end
80
-
477
+
478
+ # Handle backslash for line continuation
479
+ if chunk.start_with?('\\')
480
+ @tokens << {
481
+ type: :line_continuation,
482
+ value: '\\',
483
+ line: @line_num,
484
+ column: @column
485
+ }
486
+ @column += 1
487
+ @position += 1
488
+
489
+ # If followed by a newline, advance to next line
490
+ if @position < @input.length && @input[@position] == "\n"
491
+ @line_num += 1
492
+ @column = 1
493
+ @position += 1
494
+ end
495
+
496
+ next
497
+ end
498
+
499
+ # Check for special case where 'function' is followed by '('
500
+ # which likely means it's used as a built-in function
501
+ if chunk =~ /\Afunction\s*\(/
502
+ @tokens << {
503
+ type: :identifier, # Treat as identifier, not keyword
504
+ value: 'function',
505
+ line: @line_num,
506
+ column: @column
507
+ }
508
+ @column += 'function'.length
509
+ @position += 'function'.length
510
+ next
511
+ end
512
+
81
513
  match_found = false
82
-
514
+
83
515
  TOKEN_TYPES.each do |type, pattern|
84
516
  if match = chunk.match(/\A(#{pattern})/)
85
517
  value = match[0]
86
- token = {
87
- type: type,
88
- value: value,
89
- line: @line_num,
90
- column: @column
518
+ token = {
519
+ type: type,
520
+ value: value,
521
+ line: @line_num,
522
+ column: @column
91
523
  }
92
524
  @tokens << token unless type == :whitespace
93
-
525
+
94
526
  # Update position
95
527
  if value.include?("\n")
96
528
  lines = value.split("\n")
@@ -103,33 +535,33 @@ module Vinter
103
535
  else
104
536
  @column += value.length
105
537
  end
106
-
538
+
107
539
  @position += value.length
108
540
  match_found = true
109
541
  break
110
542
  end
111
543
  end
112
-
544
+
113
545
  unless match_found
114
546
  # Try to handle unknown characters
115
- @tokens << {
116
- type: :unknown,
117
- value: chunk[0],
118
- line: @line_num,
119
- column: @column
547
+ @tokens << {
548
+ type: :unknown,
549
+ value: chunk[0],
550
+ line: @line_num,
551
+ column: @column
120
552
  }
121
-
553
+
122
554
  if chunk[0] == "\n"
123
555
  @line_num += 1
124
556
  @column = 1
125
557
  else
126
558
  @column += 1
127
559
  end
128
-
560
+
129
561
  @position += 1
130
562
  end
131
563
  end
132
-
564
+
133
565
  @tokens
134
566
  end
135
567
  end
data/lib/vinter/linter.rb CHANGED
@@ -1,7 +1,12 @@
1
+ require "yaml"
2
+
1
3
  module Vinter
2
4
  class Linter
3
- def initialize
5
+ def initialize(config_path: nil)
4
6
  @rules = []
7
+ @ignored_rules = []
8
+ @config_path = config_path || find_config_path
9
+ load_config
5
10
  register_default_rules
6
11
  end
7
12
 
@@ -94,7 +99,7 @@ module Vinter
94
99
  lexer = Lexer.new(content)
95
100
  tokens = lexer.tokenize
96
101
 
97
- parser = Parser.new(tokens)
102
+ parser = Parser.new(tokens, content)
98
103
  result = parser.parse
99
104
 
100
105
  issues = []
@@ -121,8 +126,9 @@ module Vinter
121
126
  }
122
127
  end
123
128
 
124
- # Apply rules
129
+ # Apply rules, ignoring those specified in config
125
130
  @rules.each do |rule|
131
+ next if @ignored_rules.include?(rule.id)
126
132
  rule_issues = rule.apply(result[:ast])
127
133
  issues.concat(rule_issues.map { |i| {
128
134
  type: :rule,
@@ -135,6 +141,25 @@ module Vinter
135
141
 
136
142
  issues
137
143
  end
144
+
145
+ private
146
+
147
+ def find_config_path
148
+ # check for project level config
149
+ project_config = Dir.glob(".vinter{.yaml,.yml,}").first
150
+ project_config if project_config
151
+
152
+ # check for user-level config
153
+ user_config = File.expand_path("~/.vinter")
154
+ user_config if File.exist?(user_config)
155
+ end
156
+
157
+ def load_config
158
+ return unless @config_path && File.exist?(@config_path)
159
+
160
+ config = YAML.load_file(@config_path)
161
+ @ignored_rules = config["ignore_rules"] || []
162
+ end
138
163
  end
139
164
 
140
165
  class Rule