vinter 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '0916f02e9f17998516fc27eb0d2b2ec2807d1f4ae3ab12050590556c6d78e0ba'
4
+ data.tar.gz: 21cec3329527181dde80ebe5e12d5dba094c4596c1bcb725a1ef7dc95505b43e
5
+ SHA512:
6
+ metadata.gz: ce1879809bfc52ff91ad591bbd862ef347cfc8a6595a042cd7bc9efb0c9bb95824ee177844a5499b9264c9dc3c81f4e5bda1b7ccbdb9a85800ab2a87697d07d0
7
+ data.tar.gz: 10741136273e679ba2cf0c334e4ba815a9ba8fae25ba677fb0d7bd9f441bd912cff8c87e8919c27db110b6bab066102de5188f23305cbde76542793a4a4cb7c2
data/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # Vinter
2
+
3
+ A Ruby gem that provides linting capabilities for Vim9 script files. This linter helps identify syntax errors and enforce best practices for the new Vim9 script language introduced in Vim 9.0.
4
+
5
+ ## Features
6
+
7
+ - Lexical analysis of Vim9 script syntax
8
+ - Parsing of Vim9 script constructs
9
+ - Detection of common errors and code smells
10
+ - Command-line interface for easy integration with editors
11
+
12
+ ## Installation
13
+
14
+ Install the gem:
15
+
16
+ ```bash
17
+ gem install vinter
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ### Command Line
23
+
24
+ Lint a Vim9 script file:
25
+
26
+ ```bash
27
+ vim9-lint path/to/your/script.vim
28
+ ```
29
+
30
+ ### Ruby API
31
+
32
+ ```ruby
33
+ require 'vinter'
34
+
35
+ content = File.read('path/to/your/script.vim')
36
+ linter = Vim9Linter::Linter.new
37
+ issues = linter.lint(content)
38
+
39
+ issues.each do |issue|
40
+ puts "#{issue[:type]}: #{issue[:message]} at line #{issue[:line]}, column #{issue[:column]}"
41
+ end
42
+ ```
43
+
44
+ ## Supported Rules
45
+
46
+ The linter includes several built-in rules:
47
+
48
+ 1. **missing-vim9script-declaration**: Checks if Vim9 script files start with the required `vim9script` declaration
49
+ 2. **prefer-def-over-function**: Encourages using `def` instead of `function` in Vim9 scripts
50
+ 3. **missing-type-annotation**: Identifies variable declarations without type annotations
51
+ 4. **missing-return-type**: Identifies functions without return type annotations
52
+
53
+ ## Adding Custom Rules
54
+
55
+ You can extend the linter with your own custom rules:
56
+
57
+ ```ruby
58
+ linter = Vinter::Linter.new
59
+
60
+ # Define a custom rule
61
+ custom_rule = Vinter::Rule.new(
62
+ "my-custom-rule",
63
+ "Description of what the rule checks"
64
+ ) do |ast|
65
+ issues = []
66
+
67
+ # Analyze the AST and identify issues
68
+ # ...
69
+
70
+ issues
71
+ end
72
+
73
+ # Register the custom rule
74
+ linter.register_rule(custom_rule)
75
+
76
+ # Run the linter with your custom rule
77
+ issues = linter.lint(content)
78
+ ```
79
+
80
+ ## Vim9 Script Resources
81
+
82
+ - [Vim9 Script Documentation](https://vimhelp.org/vim9.txt.html)
83
+ - [Upgrading to Vim9 Script](https://www.baeldung.com/linux/vim-script-upgrade)
84
+
85
+ ## Contributing
86
+
87
+ 1. Fork the repository
88
+ 2. Create a feature branch: `git checkout -b my-new-feature`
89
+ 3. Commit your changes: `git commit -am 'Add some feature'`
90
+ 4. Push to the branch: `git push origin my-new-feature`
91
+ 5. Submit a pull request
92
+
93
+ ## License
94
+
95
+ This project is licensed under the MIT License - see the LICENSE file for details.
data/bin/vinter ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "vinter"
4
+
5
+ exit Vinter::CLI.new.run(ARGV)
data/lib/vinter/cli.rb ADDED
@@ -0,0 +1,47 @@
1
+ module Vinter
2
+ class CLI
3
+ def initialize
4
+ @linter = Linter.new
5
+ end
6
+
7
+ def run(args)
8
+ if args.empty?
9
+ puts "Usage: vim9-lint [file.vim]"
10
+ return 1
11
+ end
12
+
13
+ file_path = args[0]
14
+
15
+ unless File.exist?(file_path)
16
+ puts "Error: File not found: #{file_path}"
17
+ return 1
18
+ end
19
+
20
+ content = File.read(file_path)
21
+ issues = @linter.lint(content)
22
+
23
+ if issues.empty?
24
+ puts "No issues found in #{file_path}"
25
+ return 0
26
+ else
27
+ puts "Found #{issues.length} issues in #{file_path}:"
28
+
29
+ issues.each do |issue|
30
+ type_str = case issue[:type]
31
+ when :error then "ERROR"
32
+ when :warning then "WARNING"
33
+ when :rule then "RULE(#{issue[:rule]})"
34
+ else "UNKNOWN"
35
+ end
36
+
37
+ line = issue[:line] || 1
38
+ column = issue[:column] || 1
39
+
40
+ puts "#{file_path}:#{line}:#{column}: #{type_str}: #{issue[:message]}"
41
+ end
42
+
43
+ return issues.any? { |i| i[:type] == :error } ? 1 : 0
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,136 @@
1
+ module Vinter
2
+ class Lexer
3
+ TOKEN_TYPES = {
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/,
6
+ # Identifiers can include # and special characters
7
+ identifier: /\b[a-zA-Z_][a-zA-Z0-9_#]*\b/,
8
+ # Single-character operators
9
+ operator: /[\+\-\*\/=<>!&\|\.]/,
10
+ # Multi-character operators handled separately
11
+ number: /\b\d+(\.\d+)?\b/,
12
+ # Handle both single and double quoted strings
13
+ string: /"([^"\\]|\\.)*"|'([^'\\]|\\.)*'/,
14
+ # Vim9 comments use #
15
+ comment: /#.*/,
16
+ whitespace: /\s+/,
17
+ brace_open: /\{/,
18
+ brace_close: /\}/,
19
+ paren_open: /\(/,
20
+ paren_close: /\)/,
21
+ bracket_open: /\[/,
22
+ bracket_close: /\]/,
23
+ colon: /:/,
24
+ semicolon: /;/,
25
+ comma: /,/,
26
+ }
27
+
28
+ def initialize(input)
29
+ @input = input
30
+ @tokens = []
31
+ @position = 0
32
+ @line_num = 1
33
+ @column = 1
34
+ end
35
+
36
+ def tokenize
37
+ until @position >= @input.length
38
+ chunk = @input[@position..-1]
39
+
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
47
+ }
48
+ @column += match[0].length
49
+ @position += match[0].length
50
+ next
51
+ end
52
+
53
+ # Handle ellipsis for variable args
54
+ if chunk.start_with?('...')
55
+ @tokens << {
56
+ type: :ellipsis,
57
+ value: '...',
58
+ line: @line_num,
59
+ column: @column
60
+ }
61
+ @column += 3
62
+ @position += 3
63
+ next
64
+ end
65
+
66
+ # Skip whitespace but track position
67
+ if match = chunk.match(/\A(\s+)/)
68
+ whitespace = match[0]
69
+ whitespace.each_char do |c|
70
+ if c == "\n"
71
+ @line_num += 1
72
+ @column = 1
73
+ else
74
+ @column += 1
75
+ end
76
+ end
77
+ @position += whitespace.length
78
+ next
79
+ end
80
+
81
+ match_found = false
82
+
83
+ TOKEN_TYPES.each do |type, pattern|
84
+ if match = chunk.match(/\A(#{pattern})/)
85
+ value = match[0]
86
+ token = {
87
+ type: type,
88
+ value: value,
89
+ line: @line_num,
90
+ column: @column
91
+ }
92
+ @tokens << token unless type == :whitespace
93
+
94
+ # Update position
95
+ if value.include?("\n")
96
+ lines = value.split("\n")
97
+ @line_num += lines.size - 1
98
+ if lines.size > 1
99
+ @column = lines.last.length + 1
100
+ else
101
+ @column += value.length
102
+ end
103
+ else
104
+ @column += value.length
105
+ end
106
+
107
+ @position += value.length
108
+ match_found = true
109
+ break
110
+ end
111
+ end
112
+
113
+ unless match_found
114
+ # Try to handle unknown characters
115
+ @tokens << {
116
+ type: :unknown,
117
+ value: chunk[0],
118
+ line: @line_num,
119
+ column: @column
120
+ }
121
+
122
+ if chunk[0] == "\n"
123
+ @line_num += 1
124
+ @column = 1
125
+ else
126
+ @column += 1
127
+ end
128
+
129
+ @position += 1
130
+ end
131
+ end
132
+
133
+ @tokens
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,153 @@
1
+ module Vinter
2
+ class Linter
3
+ def initialize
4
+ @rules = []
5
+ register_default_rules
6
+ end
7
+
8
+ def register_rule(rule)
9
+ @rules << rule
10
+ end
11
+
12
+ def register_default_rules
13
+ # Rule: Vim9 script files should start with vim9script declaration
14
+ register_rule(Rule.new("missing-vim9script-declaration", "Script does not start with vim9script declaration") do |ast|
15
+ if ast[:type] == :program && (ast[:body].empty? || ast[:body][0][:type] != :vim9script_declaration)
16
+ [{ message: "File should start with vim9script declaration", line: 1, column: 1 }]
17
+ else
18
+ []
19
+ end
20
+ end)
21
+
22
+ # Rule: Prefer def over function in Vim9 script
23
+ register_rule(Rule.new("prefer-def-over-function", "Use def instead of function in Vim9 script") do |ast|
24
+ issues = []
25
+
26
+ traverse_ast(ast) do |node|
27
+ if node[:type] == :legacy_function
28
+ issues << { message: "Use def instead of function for #{node[:name]}", line: node[:line] || 0, column: node[:column] || 0 }
29
+ end
30
+ end
31
+
32
+ issues
33
+ end)
34
+
35
+ # Rule: Variables should have type annotations
36
+ register_rule(Rule.new("missing-type-annotation", "Variable declaration is missing type annotation") do |ast|
37
+ issues = []
38
+
39
+ traverse_ast(ast) do |node|
40
+ if node[:type] == :variable_declaration && node[:var_type_annotation].nil? && node[:var_type] != 'const'
41
+ issues << { message: "Variable #{node[:name]} should have a type annotation", line: node[:line] || 0, column: node[:column] || 0 }
42
+ end
43
+ end
44
+
45
+ issues
46
+ end)
47
+
48
+ # Rule: Functions should have return type annotations
49
+ register_rule(Rule.new("missing-return-type", "Function is missing return type annotation") do |ast|
50
+ issues = []
51
+
52
+ traverse_ast(ast) do |node|
53
+ if node[:type] == :def_function && node[:return_type].nil?
54
+ issues << { message: "Function #{node[:name]} should have a return type annotation", line: node[:line] || 0, column: node[:column] || 0 }
55
+ end
56
+ end
57
+
58
+ issues
59
+ end)
60
+
61
+ # Rule: Function parameters should have type annotations
62
+ register_rule(Rule.new("missing-param-type", "Function parameter is missing type annotation") do |ast|
63
+ issues = []
64
+
65
+ traverse_ast(ast) do |node|
66
+ if node[:type] == :def_function
67
+ node[:params].each do |param|
68
+ if param[:type] == :parameter && param[:param_type].nil?
69
+ issues << { message: "Parameter #{param[:name]} should have a type annotation", line: param[:line] || 0, column: param[:column] || 0 }
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ issues
76
+ end)
77
+ end
78
+
79
+ def traverse_ast(node, &block)
80
+ return unless node.is_a?(Hash)
81
+
82
+ yield node
83
+
84
+ node.each do |key, value|
85
+ if value.is_a?(Array)
86
+ value.each { |item| traverse_ast(item, &block) if item.is_a?(Hash) }
87
+ elsif value.is_a?(Hash)
88
+ traverse_ast(value, &block)
89
+ end
90
+ end
91
+ end
92
+
93
+ def lint(content)
94
+ lexer = Lexer.new(content)
95
+ tokens = lexer.tokenize
96
+
97
+ parser = Parser.new(tokens)
98
+ result = parser.parse
99
+
100
+ issues = []
101
+
102
+ # Add parser errors
103
+ result[:errors].each do |error|
104
+ issues << {
105
+ type: :error,
106
+ message: error[:message],
107
+ position: error[:position],
108
+ line: error[:line] || 0,
109
+ column: error[:column] || 0
110
+ }
111
+ end
112
+
113
+ # Add parser warnings
114
+ result[:warnings].each do |warning|
115
+ issues << {
116
+ type: :warning,
117
+ message: warning[:message],
118
+ position: warning[:position],
119
+ line: warning[:line] || 0,
120
+ column: warning[:column] || 0
121
+ }
122
+ end
123
+
124
+ # Apply rules
125
+ @rules.each do |rule|
126
+ rule_issues = rule.apply(result[:ast])
127
+ issues.concat(rule_issues.map { |i| {
128
+ type: :rule,
129
+ rule: rule.id,
130
+ message: i[:message],
131
+ line: i[:line] || 0,
132
+ column: i[:column] || 0
133
+ }})
134
+ end
135
+
136
+ issues
137
+ end
138
+ end
139
+
140
+ class Rule
141
+ attr_reader :id, :description
142
+
143
+ def initialize(id, description, &block)
144
+ @id = id
145
+ @description = description
146
+ @check = block
147
+ end
148
+
149
+ def apply(ast)
150
+ @check.call(ast)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,789 @@
1
+ module Vinter
2
+ class Parser
3
+ def initialize(tokens)
4
+ @tokens = tokens
5
+ @position = 0
6
+ @errors = []
7
+ @warnings = []
8
+ end
9
+
10
+ def parse
11
+ result = parse_program
12
+ {
13
+ ast: result,
14
+ errors: @errors,
15
+ warnings: @warnings
16
+ }
17
+ end
18
+
19
+ private
20
+
21
+ def current_token
22
+ @tokens[@position]
23
+ end
24
+
25
+ def peek_token(offset = 1)
26
+ @tokens[@position + offset]
27
+ end
28
+
29
+ def advance
30
+ token = current_token
31
+ @position += 1 if @position < @tokens.length
32
+ token
33
+ end
34
+
35
+ def expect(type)
36
+ if current_token && current_token[:type] == type
37
+ token = current_token
38
+ advance
39
+ return token
40
+ else
41
+ expected = type
42
+ found = current_token ? current_token[:type] : "end of input"
43
+ line = current_token ? current_token[:line] : 0
44
+ column = current_token ? current_token[:column] : 0
45
+ error = "Expected #{expected} but found #{found}"
46
+ @errors << { message: error, position: @position, line: line, column: column }
47
+ nil
48
+ end
49
+ end
50
+
51
+ def parse_program
52
+ statements = []
53
+
54
+ # Check for vim9script declaration
55
+ if current_token && current_token[:type] == :keyword && current_token[:value] == 'vim9script'
56
+ statements << { type: :vim9script_declaration }
57
+ advance
58
+ end
59
+
60
+ while @position < @tokens.length
61
+ stmt = parse_statement
62
+ statements << stmt if stmt
63
+ end
64
+
65
+ { type: :program, body: statements }
66
+ end
67
+
68
+ def parse_statement
69
+ if !current_token
70
+ return nil
71
+ end
72
+
73
+ if current_token[:type] == :keyword
74
+ case current_token[:value]
75
+ when 'if'
76
+ parse_if_statement
77
+ when 'while'
78
+ parse_while_statement
79
+ when 'for'
80
+ parse_for_statement
81
+ when 'def'
82
+ parse_def_function
83
+ when 'function'
84
+ parse_legacy_function
85
+ when 'return'
86
+ parse_return_statement
87
+ when 'var', 'const', 'final'
88
+ parse_variable_declaration
89
+ when 'import'
90
+ parse_import_statement
91
+ when 'export'
92
+ parse_export_statement
93
+ when 'vim9script'
94
+ token = advance # Skip 'vim9script'
95
+ { type: :vim9script_declaration, line: token[:line], column: token[:column] }
96
+ else
97
+ @warnings << {
98
+ message: "Unexpected keyword: #{current_token[:value]}",
99
+ position: @position,
100
+ line: current_token[:line],
101
+ column: current_token[:column]
102
+ }
103
+ advance
104
+ nil
105
+ end
106
+ elsif current_token[:type] == :identifier
107
+ parse_expression_statement
108
+ elsif current_token[:type] == :comment
109
+ parse_comment
110
+ else
111
+ @warnings << {
112
+ message: "Unexpected token type: #{current_token[:type]}",
113
+ position: @position,
114
+ line: current_token[:line],
115
+ column: current_token[:column]
116
+ }
117
+ advance
118
+ nil
119
+ end
120
+ end
121
+
122
+ def parse_comment
123
+ comment = current_token[:value]
124
+ line = current_token[:line]
125
+ column = current_token[:column]
126
+ advance
127
+ { type: :comment, value: comment, line: line, column: column }
128
+ end
129
+
130
+ def parse_if_statement
131
+ token = advance # Skip 'if'
132
+ line = token[:line]
133
+ column = token[:column]
134
+ condition = parse_expression
135
+
136
+ then_branch = []
137
+ else_branch = []
138
+
139
+ # Parse statements until we hit 'else', 'elseif', or 'endif'
140
+ while @position < @tokens.length
141
+ if current_token[:type] == :keyword &&
142
+ ['else', 'elseif', 'endif'].include?(current_token[:value])
143
+ break
144
+ end
145
+
146
+ stmt = parse_statement
147
+ then_branch << stmt if stmt
148
+ end
149
+
150
+ # Check for else/elseif
151
+ if current_token && current_token[:type] == :keyword
152
+ if current_token[:value] == 'else'
153
+ advance # Skip 'else'
154
+
155
+ # Parse statements until 'endif'
156
+ while @position < @tokens.length
157
+ if current_token[:type] == :keyword && current_token[:value] == 'endif'
158
+ break
159
+ end
160
+
161
+ stmt = parse_statement
162
+ else_branch << stmt if stmt
163
+ end
164
+ elsif current_token[:value] == 'elseif'
165
+ # This is a simplified handling - elseif should be treated as a nested if
166
+ else_branch << parse_if_statement
167
+ end
168
+ end
169
+
170
+ # Expect endif
171
+ expect(:keyword) # This should be 'endif'
172
+
173
+ {
174
+ type: :if_statement,
175
+ condition: condition,
176
+ then_branch: then_branch,
177
+ else_branch: else_branch,
178
+ line: line,
179
+ column: column
180
+ }
181
+ end
182
+
183
+ def parse_def_function
184
+ token = advance # Skip 'def'
185
+ line = token[:line]
186
+ column = token[:column]
187
+
188
+ name = expect(:identifier)
189
+
190
+ # Parse parameter list
191
+ expect(:paren_open)
192
+ params = parse_parameter_list
193
+ expect(:paren_close)
194
+
195
+ # Parse optional return type
196
+ return_type = nil
197
+ if current_token && current_token[:type] == :colon
198
+ advance # Skip ':'
199
+ return_type = parse_type
200
+ end
201
+
202
+ # Parse function body
203
+ body = []
204
+ while @position < @tokens.length
205
+ if current_token[:type] == :keyword && current_token[:value] == 'enddef'
206
+ break
207
+ end
208
+
209
+ stmt = parse_statement
210
+ body << stmt if stmt
211
+ end
212
+
213
+ # Expect enddef
214
+ expect(:keyword) # This should be 'enddef'
215
+
216
+ {
217
+ type: :def_function,
218
+ name: name ? name[:value] : nil,
219
+ params: params,
220
+ return_type: return_type,
221
+ body: body,
222
+ line: line,
223
+ column: column
224
+ }
225
+ end
226
+
227
+
228
+
229
+ def parse_parameter_list
230
+ params = []
231
+
232
+ # Empty parameter list
233
+ if current_token && current_token[:type] == :paren_close
234
+ return params
235
+ end
236
+
237
+ loop do
238
+ # Check for variable args
239
+ if current_token && current_token[:type] == :ellipsis
240
+ ellipsis_token = advance
241
+
242
+ # Parse type for variable args if present
243
+ param_type = nil
244
+ if current_token && current_token[:type] == :colon
245
+ advance # Skip ':'
246
+ param_type = parse_type
247
+ end
248
+
249
+ params << {
250
+ type: :var_args,
251
+ param_type: param_type,
252
+ line: ellipsis_token[:line],
253
+ column: ellipsis_token[:column]
254
+ }
255
+ break
256
+ end
257
+
258
+ # Get parameter name
259
+ if !current_token || current_token[:type] != :identifier
260
+ @errors << {
261
+ message: "Expected parameter name",
262
+ position: @position,
263
+ line: current_token ? current_token[:line] : 0,
264
+ column: current_token ? current_token[:column] : 0
265
+ }
266
+ break
267
+ end
268
+
269
+ param_name = advance
270
+
271
+ # Check for type annotation
272
+ param_type = nil
273
+ if current_token && current_token[:type] == :colon
274
+ advance # Skip ':'
275
+ param_type = parse_type
276
+ end
277
+
278
+ # Check for default value
279
+ default_value = nil
280
+ if current_token && current_token[:type] == :operator && current_token[:value] == '='
281
+ advance # Skip '='
282
+ default_value = parse_expression
283
+ end
284
+
285
+ params << {
286
+ type: :parameter,
287
+ name: param_name[:value],
288
+ param_type: param_type,
289
+ optional: false, # Set this based on default value
290
+ default_value: default_value,
291
+ line: param_name[:line],
292
+ column: param_name[:column]
293
+ }
294
+
295
+ if current_token && current_token[:type] == :comma
296
+ advance
297
+ else
298
+ break
299
+ end
300
+ end
301
+
302
+ params
303
+ end
304
+
305
+ def parse_type
306
+ if current_token && current_token[:type] == :identifier
307
+ type_name = advance
308
+
309
+ # Handle generic types like list<string>
310
+ if current_token && current_token[:type] == :operator && current_token[:value] == '<'
311
+ advance # Skip '<'
312
+ inner_type = parse_type
313
+ expect(:operator) # This should be '>'
314
+
315
+ return {
316
+ type: :generic_type,
317
+ base_type: type_name[:value],
318
+ inner_type: inner_type,
319
+ line: type_name[:line],
320
+ column: type_name[:column]
321
+ }
322
+ end
323
+
324
+ return type_name[:value]
325
+ else
326
+ @errors << {
327
+ message: "Expected type identifier",
328
+ position: @position,
329
+ line: current_token ? current_token[:line] : 0,
330
+ column: current_token ? current_token[:column] : 0
331
+ }
332
+ advance
333
+ return "unknown"
334
+ end
335
+ end
336
+
337
+ def parse_variable_declaration
338
+ var_type_token = advance # Skip 'var', 'const', or 'final'
339
+ var_type = var_type_token[:value]
340
+ line = var_type_token[:line]
341
+ column = var_type_token[:column]
342
+
343
+ if !current_token || current_token[:type] != :identifier
344
+ @errors << {
345
+ message: "Expected variable name",
346
+ position: @position,
347
+ line: current_token ? current_token[:line] : 0,
348
+ column: current_token ? current_token[:column] : 0
349
+ }
350
+ return nil
351
+ end
352
+
353
+ name_token = advance
354
+ name = name_token[:value]
355
+
356
+ # Parse optional type annotation
357
+ var_type_annotation = nil
358
+ if current_token && current_token[:type] == :colon
359
+ advance # Skip ':'
360
+ var_type_annotation = parse_type
361
+ end
362
+
363
+ # Parse initializer if present
364
+ initializer = nil
365
+ if current_token && current_token[:type] == :operator && current_token[:value] == '='
366
+ advance # Skip '='
367
+ initializer = parse_expression
368
+ end
369
+
370
+ {
371
+ type: :variable_declaration,
372
+ var_type: var_type,
373
+ name: name,
374
+ var_type_annotation: var_type_annotation,
375
+ initializer: initializer,
376
+ line: line,
377
+ column: column
378
+ }
379
+ end
380
+
381
+ def parse_return_statement
382
+ token = advance # Skip 'return'
383
+ line = token[:line]
384
+ column = token[:column]
385
+
386
+ value = nil
387
+ if @position < @tokens.length && current_token[:type] != :semicolon
388
+ value = parse_expression
389
+ end
390
+
391
+ {
392
+ type: :return_statement,
393
+ value: value,
394
+ line: line,
395
+ column: column
396
+ }
397
+ end
398
+
399
+ def parse_expression_statement
400
+ expr = parse_expression
401
+ {
402
+ type: :expression_statement,
403
+ expression: expr,
404
+ line: expr ? expr[:line] : 0,
405
+ column: expr ? expr[:column] : 0
406
+ }
407
+ end
408
+
409
+ def parse_expression
410
+ return parse_binary_expression
411
+ end
412
+
413
+ def parse_binary_expression(precedence = 0)
414
+ left = parse_primary_expression
415
+
416
+ while current_token && current_token[:type] == :operator &&
417
+ operator_precedence(current_token[:value]) >= precedence
418
+ op_token = advance
419
+ op = op_token[:value]
420
+ op_precedence = operator_precedence(op)
421
+
422
+ right = parse_binary_expression(op_precedence + 1)
423
+
424
+ left = {
425
+ type: :binary_expression,
426
+ operator: op,
427
+ left: left,
428
+ right: right,
429
+ line: op_token[:line],
430
+ column: op_token[:column]
431
+ }
432
+ end
433
+
434
+ return left
435
+ end
436
+
437
+ def operator_precedence(op)
438
+ case op
439
+ when '..' # String concatenation
440
+ 1
441
+ when '||' # Logical OR
442
+ 2
443
+ when '&&' # Logical AND
444
+ 3
445
+ when '==', '!=', '>', '<', '>=', '<=' # Comparison
446
+ 4
447
+ when '+', '-' # Addition, subtraction
448
+ 5
449
+ when '*', '/', '%' # Multiplication, division, modulo
450
+ 6
451
+ else
452
+ 0
453
+ end
454
+ end
455
+
456
+ def parse_primary_expression
457
+ return nil unless current_token
458
+
459
+ token = current_token
460
+ line = token[:line]
461
+ column = token[:column]
462
+
463
+ case token[:type]
464
+ when :number
465
+ advance
466
+ {
467
+ type: :literal,
468
+ value: token[:value],
469
+ token_type: :number,
470
+ line: line,
471
+ column: column
472
+ }
473
+ when :string
474
+ advance
475
+ {
476
+ type: :literal,
477
+ value: token[:value],
478
+ token_type: :string,
479
+ line: line,
480
+ column: column
481
+ }
482
+ when :identifier
483
+ advance
484
+
485
+ # Check if this is a function call
486
+ if current_token && current_token[:type] == :paren_open
487
+ return parse_function_call(token[:value], line, column)
488
+ end
489
+
490
+ {
491
+ type: :identifier,
492
+ name: token[:value],
493
+ line: line,
494
+ column: column
495
+ }
496
+ when :paren_open
497
+ advance # Skip '('
498
+ expr = parse_expression
499
+ expect(:paren_close) # Expect and skip ')'
500
+ return expr
501
+ else
502
+ @errors << {
503
+ message: "Unexpected token in expression: #{token[:type]}",
504
+ position: @position,
505
+ line: line,
506
+ column: column
507
+ }
508
+ advance
509
+ return nil
510
+ end
511
+ end
512
+
513
+ def parse_function_call(name, line, column)
514
+ expect(:paren_open)
515
+
516
+ args = []
517
+
518
+ # Parse arguments
519
+ unless current_token && current_token[:type] == :paren_close
520
+ loop do
521
+ arg = parse_expression
522
+ args << arg if arg
523
+
524
+ break unless current_token && current_token[:type] == :comma
525
+ advance # Skip comma
526
+ end
527
+ end
528
+
529
+ expect(:paren_close)
530
+
531
+ {
532
+ type: :function_call,
533
+ name: name,
534
+ arguments: args,
535
+ line: line,
536
+ column: column
537
+ }
538
+ end
539
+
540
+ def parse_import_statement
541
+ token = advance # Skip 'import'
542
+ line = token[:line]
543
+ column = token[:column]
544
+
545
+ # Handle 'import autoload'
546
+ is_autoload = false
547
+ module_name = nil
548
+ path = nil
549
+
550
+ if current_token && current_token[:type] == :identifier && current_token[:value] == 'autoload'
551
+ is_autoload = true
552
+ module_name = advance[:value] # Store "autoload" as the module name
553
+
554
+ # After "autoload" keyword, expect a string path
555
+ if current_token && current_token[:type] == :string
556
+ path = current_token[:value]
557
+ advance
558
+ else
559
+ @errors << {
560
+ message: "Expected string path after 'autoload'",
561
+ position: @position,
562
+ line: current_token ? current_token[:line] : 0,
563
+ column: current_token ? current_token[:column] : 0
564
+ }
565
+ end
566
+ else
567
+ # Regular import with a string path
568
+ if current_token && current_token[:type] == :string
569
+ path = current_token[:value]
570
+
571
+ # Extract module name from the path
572
+ # This is simplified logic - you might need more complex extraction
573
+ module_name = path.gsub(/['"]/, '').split('/').last.split('.').first
574
+
575
+ advance
576
+ else
577
+ # Handle other import formats
578
+ module_expr = parse_expression()
579
+ if module_expr && module_expr[:type] == :literal && module_expr[:token_type] == :string
580
+ path = module_expr[:value]
581
+ module_name = path.gsub(/['"]/, '').split('/').last.split('.').first
582
+ end
583
+ end
584
+ end
585
+
586
+ # Handle 'as name'
587
+ as_name = nil
588
+ if current_token && current_token[:type] == :identifier && current_token[:value] == 'as'
589
+ advance # Skip 'as'
590
+ if current_token && current_token[:type] == :identifier
591
+ as_name = advance[:value]
592
+ else
593
+ @errors << {
594
+ message: "Expected identifier after 'as'",
595
+ position: @position,
596
+ line: current_token ? current_token[:line] : 0,
597
+ column: current_token ? current_token[:column] : 0
598
+ }
599
+ end
600
+ end
601
+
602
+ {
603
+ type: :import_statement,
604
+ module: module_name,
605
+ path: path,
606
+ is_autoload: is_autoload,
607
+ as_name: as_name,
608
+ line: line,
609
+ column: column
610
+ }
611
+ end
612
+ def parse_export_statement
613
+ token = advance # Skip 'export'
614
+ line = token[:line]
615
+ column = token[:column]
616
+
617
+ # Export can be followed by var/const/def/function declarations
618
+ if !current_token
619
+ @errors << {
620
+ message: "Expected declaration after export",
621
+ position: @position,
622
+ line: line,
623
+ column: column
624
+ }
625
+ return nil
626
+ end
627
+
628
+ exported_item = nil
629
+
630
+ if current_token[:type] == :keyword
631
+ case current_token[:value]
632
+ when 'def'
633
+ exported_item = parse_def_function
634
+ when 'function'
635
+ exported_item = parse_legacy_function
636
+ when 'var', 'const', 'final'
637
+ exported_item = parse_variable_declaration
638
+ when 'class'
639
+ # Handle class export when implemented
640
+ @errors << {
641
+ message: "Class export not implemented yet",
642
+ position: @position,
643
+ line: current_token[:line],
644
+ column: current_token[:column]
645
+ }
646
+ advance
647
+ return nil
648
+ else
649
+ @errors << {
650
+ message: "Unexpected keyword after export: #{current_token[:value]}",
651
+ position: @position,
652
+ line: current_token[:line],
653
+ column: current_token[:column]
654
+ }
655
+ advance
656
+ return nil
657
+ end
658
+ else
659
+ @errors << {
660
+ message: "Expected declaration after export",
661
+ position: @position,
662
+ line: current_token[:line],
663
+ column: current_token[:column]
664
+ }
665
+ advance
666
+ return nil
667
+ end
668
+
669
+ {
670
+ type: :export_statement,
671
+ export: exported_item,
672
+ line: line,
673
+ column: column
674
+ }
675
+ end
676
+
677
+ def parse_legacy_function
678
+ token = advance # Skip 'function'
679
+ line = token[:line]
680
+ column = token[:column]
681
+
682
+ # Check for bang (!) in function definition
683
+ has_bang = false
684
+ if current_token && current_token[:type] == :operator && current_token[:value] == '!'
685
+ has_bang = true
686
+ advance # Skip '!'
687
+ end
688
+
689
+ name = expect(:identifier)
690
+
691
+ # Parse parameter list
692
+ expect(:paren_open)
693
+ params = parse_parameter_list_legacy
694
+ expect(:paren_close)
695
+
696
+ # Check for optional attributes (range, dict, abort, closure)
697
+ attributes = []
698
+ while current_token && current_token[:type] == :identifier
699
+ if ['range', 'dict', 'abort', 'closure'].include?(current_token[:value])
700
+ attributes << advance[:value]
701
+ else
702
+ break
703
+ end
704
+ end
705
+
706
+ # Parse function body
707
+ body = []
708
+ while @position < @tokens.length
709
+ if current_token && current_token[:type] == :keyword &&
710
+ ['endfunction', 'endfunc'].include?(current_token[:value])
711
+ break
712
+ end
713
+
714
+ stmt = parse_statement
715
+ body << stmt if stmt
716
+ end
717
+
718
+ # Expect endfunction/endfunc
719
+ end_token = advance # This should be 'endfunction' or 'endfunc'
720
+ if end_token && end_token[:type] != :keyword &&
721
+ !['endfunction', 'endfunc'].include?(end_token[:value])
722
+ @errors << {
723
+ message: "Expected 'endfunction' or 'endfunc'",
724
+ position: @position,
725
+ line: end_token ? end_token[:line] : 0,
726
+ column: end_token ? end_token[:column] : 0
727
+ }
728
+ end
729
+
730
+ {
731
+ type: :legacy_function,
732
+ name: name ? name[:value] : nil,
733
+ params: params,
734
+ has_bang: has_bang,
735
+ attributes: attributes,
736
+ body: body,
737
+ line: line,
738
+ column: column
739
+ }
740
+ end
741
+
742
+ # Legacy function parameters are different - they use a:name syntax
743
+ def parse_parameter_list_legacy
744
+ params = []
745
+
746
+ # Empty parameter list
747
+ if current_token && current_token[:type] == :paren_close
748
+ return params
749
+ end
750
+
751
+ loop do
752
+ # Check for ... (varargs) in legacy function
753
+ if current_token && current_token[:type] == :ellipsis
754
+ advance
755
+ params << { type: :var_args_legacy, name: '...' }
756
+ break
757
+ end
758
+
759
+ if !current_token || current_token[:type] != :identifier
760
+ @errors << {
761
+ message: "Expected parameter name",
762
+ position: @position,
763
+ line: current_token ? current_token[:line] : 0,
764
+ column: current_token ? current_token[:column] : 0
765
+ }
766
+ break
767
+ end
768
+
769
+ param_name = advance
770
+
771
+ params << {
772
+ type: :parameter,
773
+ name: param_name[:value],
774
+ line: param_name[:line],
775
+ column: param_name[:column]
776
+ }
777
+
778
+ if current_token && current_token[:type] == :comma
779
+ advance
780
+ else
781
+ break
782
+ end
783
+ end
784
+
785
+ params
786
+ end
787
+
788
+ end
789
+ end
data/lib/vinter.rb ADDED
@@ -0,0 +1,8 @@
1
+ require "vinter/lexer"
2
+ require "vinter/parser"
3
+ require "vinter/linter"
4
+ require "vinter/cli"
5
+
6
+ module Vinter
7
+ VERSION = "0.1.0"
8
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: vinter
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dan Bradbury
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-04-04 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A linter for the Vim9 script language, helping to identify issues and
14
+ enforce best practices
15
+ email: dan.luckydaisy@gmail.com
16
+ executables:
17
+ - vinter
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - README.md
22
+ - bin/vinter
23
+ - lib/vinter.rb
24
+ - lib/vinter/cli.rb
25
+ - lib/vinter/lexer.rb
26
+ - lib/vinter/linter.rb
27
+ - lib/vinter/parser.rb
28
+ homepage: https://github.com/DanBradbury/vinter
29
+ licenses:
30
+ - MIT
31
+ metadata: {}
32
+ post_install_message:
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 2.5.0
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.5.22
48
+ signing_key:
49
+ specification_version: 4
50
+ summary: A linter for Vim9 script
51
+ test_files: []