openvox-lint 1.0.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 +7 -0
- data/CHANGELOG.md +52 -0
- data/DOCUMENTATION.md +481 -0
- data/LICENSE +83 -0
- data/README.md +497 -0
- data/bin/openvox-lint +7 -0
- data/lib/openvox-lint/check_plugin.rb +147 -0
- data/lib/openvox-lint/checks.rb +46 -0
- data/lib/openvox-lint/cli.rb +87 -0
- data/lib/openvox-lint/configuration.rb +59 -0
- data/lib/openvox-lint/lexer.rb +342 -0
- data/lib/openvox-lint/linter.rb +72 -0
- data/lib/openvox-lint/plugins/checks/arrow_alignment.rb +42 -0
- data/lib/openvox-lint/plugins/checks/autoloader_layout.rb +31 -0
- data/lib/openvox-lint/plugins/checks/case_without_default.rb +28 -0
- data/lib/openvox-lint/plugins/checks/class_inherits_params.rb +13 -0
- data/lib/openvox-lint/plugins/checks/documentation.rb +26 -0
- data/lib/openvox-lint/plugins/checks/double_quoted_strings.rb +19 -0
- data/lib/openvox-lint/plugins/checks/duplicate_params.rb +24 -0
- data/lib/openvox-lint/plugins/checks/ensure_first_param.rb +28 -0
- data/lib/openvox-lint/plugins/checks/ensure_not_symlink_target.rb +29 -0
- data/lib/openvox-lint/plugins/checks/file_mode.rb +33 -0
- data/lib/openvox-lint/plugins/checks/hard_tabs.rb +15 -0
- data/lib/openvox-lint/plugins/checks/hiera3_function.rb +16 -0
- data/lib/openvox-lint/plugins/checks/import_statement.rb +13 -0
- data/lib/openvox-lint/plugins/checks/inherits_across_namespaces.rb +27 -0
- data/lib/openvox-lint/plugins/checks/leading_zero.rb +22 -0
- data/lib/openvox-lint/plugins/checks/legacy_facts.rb +47 -0
- data/lib/openvox-lint/plugins/checks/line_length.rb +18 -0
- data/lib/openvox-lint/plugins/checks/nested_classes_or_defines.rb +26 -0
- data/lib/openvox-lint/plugins/checks/node_name_unquoted.rb +18 -0
- data/lib/openvox-lint/plugins/checks/only_variable_string.rb +18 -0
- data/lib/openvox-lint/plugins/checks/parameter_order.rb +25 -0
- data/lib/openvox-lint/plugins/checks/puppet_url_without_modules.rb +17 -0
- data/lib/openvox-lint/plugins/checks/quoted_booleans.rb +16 -0
- data/lib/openvox-lint/plugins/checks/relative_classname_inclusion.rb +24 -0
- data/lib/openvox-lint/plugins/checks/resource_reference_without_title_capital.rb +21 -0
- data/lib/openvox-lint/plugins/checks/selector_inside_resource.rb +15 -0
- data/lib/openvox-lint/plugins/checks/single_quote_string_with_variables.rb +16 -0
- data/lib/openvox-lint/plugins/checks/space_before_arrow.rb +20 -0
- data/lib/openvox-lint/plugins/checks/star_comments.rb +13 -0
- data/lib/openvox-lint/plugins/checks/strict_indent.rb +16 -0
- data/lib/openvox-lint/plugins/checks/top_scope_facts.rb +19 -0
- data/lib/openvox-lint/plugins/checks/trailing_comma.rb +24 -0
- data/lib/openvox-lint/plugins/checks/trailing_whitespace.rb +14 -0
- data/lib/openvox-lint/plugins/checks/unquoted_file_mode.rb +24 -0
- data/lib/openvox-lint/plugins/checks/unquoted_resource_title.rb +13 -0
- data/lib/openvox-lint/plugins/checks/variable_contains_dash.rb +15 -0
- data/lib/openvox-lint/plugins/checks/variable_is_lowercase.rb +16 -0
- data/lib/openvox-lint/plugins/checks/variables_not_enclosed.rb +19 -0
- data/lib/openvox-lint/report.rb +86 -0
- data/lib/openvox-lint/token.rb +38 -0
- data/lib/openvox-lint/version.rb +5 -0
- data/lib/openvox-lint.rb +47 -0
- metadata +145 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module OpenvoxLint
|
|
6
|
+
# Command-line interface for openvox-lint.
|
|
7
|
+
class CLI
|
|
8
|
+
def initialize(args = ARGV)
|
|
9
|
+
@args = args
|
|
10
|
+
@config = OpenvoxLint.configuration
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
parse_options
|
|
15
|
+
load_rc_file
|
|
16
|
+
if @list_checks
|
|
17
|
+
list_checks; return 0
|
|
18
|
+
end
|
|
19
|
+
files = @args.empty? ? ['.'] : @args
|
|
20
|
+
linter = Linter.new(configuration: @config)
|
|
21
|
+
linter.run(*files)
|
|
22
|
+
Report.new(@config).format(linter.problems)
|
|
23
|
+
print_summary(linter) unless @config.log_format == 'json'
|
|
24
|
+
linter.exit_code
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def parse_options # rubocop:disable Metrics/MethodLength
|
|
30
|
+
@parser = OptionParser.new do |opts|
|
|
31
|
+
opts.banner = "Usage: openvox-lint [options] [file|directory ...]"
|
|
32
|
+
opts.separator ''; opts.separator 'Options:'
|
|
33
|
+
opts.on('--version', 'Display version') { puts "openvox-lint #{VERSION}"; exit 0 }
|
|
34
|
+
opts.on('-f', '--format FORMAT', 'Output format: text json csv github codeclimate') { |f| @config.log_format = f }
|
|
35
|
+
opts.on('--log-format FORMAT', 'Custom log format string') { |f| @config.custom_log_format = f; @config.log_format = 'custom' }
|
|
36
|
+
opts.on('--fix', 'Automatically fix problems') { @config.fix = true }
|
|
37
|
+
opts.on('--fail-on-warnings', 'Exit 1 on warnings') { @config.fail_on_warnings = true }
|
|
38
|
+
opts.on('--no-filename', 'Suppress filename') { @config.with_filename = false }
|
|
39
|
+
opts.on('--no-column', 'Suppress column') { @config.column = false }
|
|
40
|
+
opts.on('--relative', 'Relative paths') { @config.relative = true }
|
|
41
|
+
opts.on('--only-checks CHECKS', 'Comma-separated checks') { |c| @config.only_checks = c.split(',').map { |s| s.strip.to_sym } }
|
|
42
|
+
opts.on('--ignore-paths PATHS', 'Comma-separated globs') { |p| @config.ignore_paths = p.split(',').map(&:strip) }
|
|
43
|
+
opts.on('--list-checks', 'List available checks') { @list_checks = true }
|
|
44
|
+
opts.on('-c', '--config FILE', 'Config file path') { |f| @config.config_file = f }
|
|
45
|
+
end
|
|
46
|
+
remaining = []
|
|
47
|
+
begin
|
|
48
|
+
@parser.order!(@args) { |a| remaining << a }
|
|
49
|
+
rescue OptionParser::InvalidOption => e
|
|
50
|
+
flag = e.args.first
|
|
51
|
+
if flag =~ /\A--no-(.+)-check\z/
|
|
52
|
+
@config.disabled_checks << Regexp.last_match(1).tr('-', '_').to_sym
|
|
53
|
+
else
|
|
54
|
+
$stderr.puts e.message; exit 1
|
|
55
|
+
end
|
|
56
|
+
retry
|
|
57
|
+
end
|
|
58
|
+
@args.replace(remaining)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load_rc_file
|
|
62
|
+
['.openvox-lint.rc', @config.config_file, File.expand_path('~/.openvox-lint.rc')].each do |path|
|
|
63
|
+
next unless path && File.exist?(path)
|
|
64
|
+
@config.load_from_rc(path); break
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def list_checks
|
|
69
|
+
puts "Available checks (#{OpenvoxLint.checks.size} total):"; puts ''
|
|
70
|
+
OpenvoxLint.checks.keys.sort.each do |name|
|
|
71
|
+
puts " #{@config.check_enabled?(name) ? '✓' : '✗'} #{name}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print_summary(linter)
|
|
76
|
+
w = linter.problems.count { |p| p[:kind] == :warning }
|
|
77
|
+
e = linter.problems.count { |p| p[:kind] == :error }
|
|
78
|
+
t = linter.problems.size; f = linter.file_count
|
|
79
|
+
$stderr.puts ''
|
|
80
|
+
if t == 0
|
|
81
|
+
$stderr.puts "✓ #{f} file(s) checked — no problems found."
|
|
82
|
+
else
|
|
83
|
+
$stderr.puts "#{f} file(s) checked — #{t} problem(s) found (#{e} error(s), #{w} warning(s))."
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenvoxLint
|
|
4
|
+
# Holds all runtime configuration.
|
|
5
|
+
class Configuration
|
|
6
|
+
DEFAULTS = {
|
|
7
|
+
log_format: 'text', with_filename: true, fail_on_warnings: false,
|
|
8
|
+
fix: false, only_checks: [], disabled_checks: [],
|
|
9
|
+
ignore_paths: %w[vendor/**/*.pp pkg/**/*.pp spec/**/*.pp],
|
|
10
|
+
config_file: '.openvox-lint.rc', relative: false, column: true,
|
|
11
|
+
custom_log_format: nil,
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
attr_accessor :log_format, :with_filename, :fail_on_warnings,
|
|
15
|
+
:fix, :only_checks, :disabled_checks, :ignore_paths,
|
|
16
|
+
:config_file, :relative, :column, :custom_log_format
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
DEFAULTS.each do |k, v|
|
|
20
|
+
send(:"#{k}=", v.is_a?(Array) ? v.dup : v)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def load_from_rc(path)
|
|
25
|
+
return unless File.exist?(path)
|
|
26
|
+
File.readlines(path).each do |line|
|
|
27
|
+
line = line.strip
|
|
28
|
+
next if line.empty? || line.start_with?('#')
|
|
29
|
+
flag, value = line.split(/\s+/, 2)
|
|
30
|
+
apply_flag(flag, value)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def check_enabled?(name)
|
|
35
|
+
name = name.to_sym
|
|
36
|
+
return false if disabled_checks.include?(name)
|
|
37
|
+
return only_checks.include?(name) unless only_checks.empty?
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def apply_flag(flag, value)
|
|
44
|
+
case flag
|
|
45
|
+
when '--fix' then self.fix = true
|
|
46
|
+
when '--no-fix' then self.fix = false
|
|
47
|
+
when '--fail-on-warnings' then self.fail_on_warnings = true
|
|
48
|
+
when /\A--no-(.+)-check\z/
|
|
49
|
+
disabled_checks << Regexp.last_match(1).tr('-', '_').to_sym
|
|
50
|
+
when '--only-checks'
|
|
51
|
+
self.only_checks = (value || '').split(',').map { |c| c.strip.to_sym }
|
|
52
|
+
when '--log-format'
|
|
53
|
+
self.log_format = value&.strip || 'text'
|
|
54
|
+
when '--ignore-paths'
|
|
55
|
+
self.ignore_paths = (value || '').split(',').map(&:strip)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenvoxLint
|
|
4
|
+
# Tokenises a Puppet / OpenVox manifest string into an Array of Token
|
|
5
|
+
# objects. The lexer recognises all Puppet 8 / OpenVox 8.x language
|
|
6
|
+
# constructs including heredocs, EPP tags, Deferred/Sensitive types,
|
|
7
|
+
# type aliases, and the full operator set.
|
|
8
|
+
class Lexer
|
|
9
|
+
KEYWORDS = {
|
|
10
|
+
'and' => :AND, 'application' => :APPLICATION, 'attr' => :ATTR,
|
|
11
|
+
'case' => :CASE, 'class' => :CLASS, 'consumes' => :CONSUMES,
|
|
12
|
+
'default' => :DEFAULT, 'define' => :DEFINE, 'else' => :ELSE,
|
|
13
|
+
'elsif' => :ELSIF, 'false' => :FALSE, 'function' => :FUNCTION,
|
|
14
|
+
'if' => :IF, 'import' => :IMPORT, 'in' => :IN,
|
|
15
|
+
'inherits' => :INHERITS, 'node' => :NODE, 'not' => :NOT,
|
|
16
|
+
'or' => :OR, 'private' => :PRIVATE, 'produces' => :PRODUCES,
|
|
17
|
+
'site' => :SITE, 'true' => :TRUE, 'type' => :TYPE,
|
|
18
|
+
'undef' => :UNDEF, 'unless' => :UNLESS,
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
OPERATORS = [
|
|
22
|
+
['<<|', :LLCOLLECT], ['|>>', :RRCOLLECT],
|
|
23
|
+
['<=', :LESSEQUAL], ['>=', :GREATEREQUAL],
|
|
24
|
+
['==', :ISEQUAL], ['!=', :NOTEQUAL],
|
|
25
|
+
['=~', :MATCH], ['!~', :NOMATCH],
|
|
26
|
+
['=>', :FARROW], ['+=', :APPENDS],
|
|
27
|
+
['+>', :PARROW], ['->', :IN_EDGE],
|
|
28
|
+
['<-', :OUT_EDGE], ['~>', :IN_EDGE_SUB],
|
|
29
|
+
['<~', :OUT_EDGE_SUB], ['<<', :LSHIFT],
|
|
30
|
+
['>>', :RSHIFT], ['<|', :LCOLLECT],
|
|
31
|
+
['|>', :RCOLLECT],
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
SINGLE_CHAR = {
|
|
35
|
+
'{' => :LBRACE, '}' => :RBRACE, '(' => :LPAREN, ')' => :RPAREN,
|
|
36
|
+
'[' => :LBRACK, ']' => :RBRACK, ';' => :SEMIC, ',' => :COMMA,
|
|
37
|
+
'.' => :DOT, '@' => :AT, '|' => :PIPE, '+' => :PLUS,
|
|
38
|
+
'-' => :MINUS, '*' => :TIMES, '%' => :MODULO, '!' => :NOT,
|
|
39
|
+
'?' => :QMARK, '\\' => :BACKSLASH, ':' => :COLON,
|
|
40
|
+
'=' => :EQUALS, '>' => :GREATERTHAN, '<' => :LESSTHAN,
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
REGEX_PREV = %i[
|
|
44
|
+
NODE LBRACE LBRACK LPAREN COMMA EQUALS ISEQUAL NOTEQUAL
|
|
45
|
+
MATCH NOMATCH AND OR NOT IF ELSIF CASE RETURN IN
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
attr_reader :tokens, :manifest_lines
|
|
49
|
+
|
|
50
|
+
def initialize(code)
|
|
51
|
+
@code = code
|
|
52
|
+
@manifest_lines = code.lines.map(&:chomp)
|
|
53
|
+
@tokens = []
|
|
54
|
+
@line = 1
|
|
55
|
+
@column = 1
|
|
56
|
+
@pos = 0
|
|
57
|
+
tokenise
|
|
58
|
+
link_tokens
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def tokenise # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
|
|
64
|
+
until @pos >= @code.length
|
|
65
|
+
case @code[@pos]
|
|
66
|
+
when "\n"
|
|
67
|
+
emit(:NEWLINE, "\n"); @line += 1; @column = 1
|
|
68
|
+
when "\r"
|
|
69
|
+
if @code[@pos + 1] == "\n"
|
|
70
|
+
emit(:NEWLINE, "\r\n", 2); @line += 1; @column = 1
|
|
71
|
+
else
|
|
72
|
+
emit(:NEWLINE, "\r"); @line += 1; @column = 1
|
|
73
|
+
end
|
|
74
|
+
when ' ', "\t" then scan_whitespace
|
|
75
|
+
when '#' then scan_comment
|
|
76
|
+
when '/' then scan_slash
|
|
77
|
+
when "'" then scan_single_quoted_string
|
|
78
|
+
when '"' then scan_double_quoted_string
|
|
79
|
+
when '$' then scan_variable
|
|
80
|
+
when '@'
|
|
81
|
+
if @code[@pos + 1] == '(' || @code[@pos + 1] == '"'
|
|
82
|
+
scan_heredoc
|
|
83
|
+
else
|
|
84
|
+
emit(:AT, '@')
|
|
85
|
+
end
|
|
86
|
+
else
|
|
87
|
+
scan_other
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def scan_whitespace
|
|
93
|
+
start = @pos; start_col = @column
|
|
94
|
+
@pos += 1; @column += 1
|
|
95
|
+
while @pos < @code.length && (@code[@pos] == ' ' || @code[@pos] == "\t")
|
|
96
|
+
@pos += 1; @column += 1
|
|
97
|
+
end
|
|
98
|
+
tok = prev_non_ws_type == :NEWLINE || @tokens.empty? ? :INDENT : :WHITESPACE
|
|
99
|
+
add_token(tok, @code[start...@pos], @line, start_col)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def scan_comment
|
|
103
|
+
start = @pos; start_col = @column
|
|
104
|
+
@pos += 1; @column += 1
|
|
105
|
+
while @pos < @code.length && @code[@pos] != "\n"
|
|
106
|
+
@pos += 1; @column += 1
|
|
107
|
+
end
|
|
108
|
+
add_token(:COMMENT, @code[start...@pos], @line, start_col)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def scan_slash
|
|
112
|
+
if @code[@pos + 1] == '*' then scan_ml_comment
|
|
113
|
+
elsif @code[@pos + 1] == '/' then scan_slash_comment
|
|
114
|
+
elsif regex_possible? then scan_regex
|
|
115
|
+
else emit(:DIV, '/')
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def scan_ml_comment
|
|
120
|
+
start = @pos; start_line = @line; start_col = @column
|
|
121
|
+
@pos += 2; @column += 2
|
|
122
|
+
until @pos >= @code.length
|
|
123
|
+
if @code[@pos] == '*' && @code[@pos + 1] == '/'
|
|
124
|
+
@pos += 2; @column += 2; break
|
|
125
|
+
end
|
|
126
|
+
if @code[@pos] == "\n" then @line += 1; @column = 1
|
|
127
|
+
else @column += 1; end
|
|
128
|
+
@pos += 1
|
|
129
|
+
end
|
|
130
|
+
add_token(:MLCOMMENT, @code[start...@pos], start_line, start_col)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def scan_slash_comment
|
|
134
|
+
start = @pos; start_col = @column
|
|
135
|
+
@pos += 2; @column += 2
|
|
136
|
+
while @pos < @code.length && @code[@pos] != "\n"
|
|
137
|
+
@pos += 1; @column += 1
|
|
138
|
+
end
|
|
139
|
+
add_token(:SLASH_COMMENT, @code[start...@pos], @line, start_col)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def scan_regex
|
|
143
|
+
start = @pos; start_col = @column
|
|
144
|
+
@pos += 1; @column += 1
|
|
145
|
+
while @pos < @code.length && @code[@pos] != '/'
|
|
146
|
+
if @code[@pos] == '\\'
|
|
147
|
+
@pos += 2; @column += 2
|
|
148
|
+
else
|
|
149
|
+
@pos += 1; @column += 1
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
@pos += 1; @column += 1
|
|
153
|
+
add_token(:REGEX, @code[start...@pos], @line, start_col)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def scan_single_quoted_string
|
|
157
|
+
start = @pos; start_col = @column
|
|
158
|
+
@pos += 1; @column += 1
|
|
159
|
+
while @pos < @code.length
|
|
160
|
+
if @code[@pos] == '\\'
|
|
161
|
+
@pos += 2; @column += 2
|
|
162
|
+
elsif @code[@pos] == "'"
|
|
163
|
+
@pos += 1; @column += 1; break
|
|
164
|
+
elsif @code[@pos] == "\n"
|
|
165
|
+
@line += 1; @column = 1; @pos += 1
|
|
166
|
+
else
|
|
167
|
+
@pos += 1; @column += 1
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
add_token(:SSTRING, @code[start...@pos], @line, start_col)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def scan_double_quoted_string
|
|
174
|
+
start = @pos; start_col = @column
|
|
175
|
+
@pos += 1; @column += 1
|
|
176
|
+
has_interp = false
|
|
177
|
+
while @pos < @code.length
|
|
178
|
+
if @code[@pos] == '\\'
|
|
179
|
+
@pos += 2; @column += 2
|
|
180
|
+
elsif @code[@pos] == '$' && @code[@pos + 1] =~ /[a-zA-Z_{:]/
|
|
181
|
+
has_interp = true
|
|
182
|
+
@pos += 1; @column += 1
|
|
183
|
+
while @pos < @code.length && @code[@pos] =~ /[a-zA-Z0-9_:{}]/
|
|
184
|
+
@pos += 1; @column += 1
|
|
185
|
+
end
|
|
186
|
+
elsif @code[@pos] == '"'
|
|
187
|
+
@pos += 1; @column += 1; break
|
|
188
|
+
elsif @code[@pos] == "\n"
|
|
189
|
+
@line += 1; @column = 1; @pos += 1
|
|
190
|
+
else
|
|
191
|
+
@pos += 1; @column += 1
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
type = has_interp ? :DQSTRING : :STRING
|
|
195
|
+
add_token(type, @code[start...@pos], @line, start_col)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def scan_variable
|
|
199
|
+
start = @pos; start_col = @column
|
|
200
|
+
@pos += 1; @column += 1
|
|
201
|
+
while @pos < @code.length && @code[@pos] =~ /[a-zA-Z0-9_:]/
|
|
202
|
+
@pos += 1; @column += 1
|
|
203
|
+
end
|
|
204
|
+
add_token(:VARIABLE, @code[start...@pos], @line, start_col)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def scan_heredoc
|
|
208
|
+
start_col = @column
|
|
209
|
+
tag_match = @code[@pos..].match(/\A@\(("?)(\w+)\1\s*([\/\-|:tsnLru]*)\)/)
|
|
210
|
+
unless tag_match
|
|
211
|
+
emit(:AT, '@'); return
|
|
212
|
+
end
|
|
213
|
+
tag = tag_match[2]; tag_len = tag_match[0].length
|
|
214
|
+
add_token(:HEREDOC_OPEN, @code[@pos, tag_len], @line, start_col)
|
|
215
|
+
@pos += tag_len; @column += tag_len
|
|
216
|
+
# Skip rest of current line
|
|
217
|
+
while @pos < @code.length && @code[@pos] != "\n"
|
|
218
|
+
@pos += 1; @column += 1
|
|
219
|
+
end
|
|
220
|
+
@pos += 1; @line += 1; @column = 1
|
|
221
|
+
# Read heredoc body
|
|
222
|
+
heredoc_content = +''
|
|
223
|
+
until @pos >= @code.length
|
|
224
|
+
line_start = @pos
|
|
225
|
+
while @pos < @code.length && @code[@pos] != "\n"
|
|
226
|
+
@pos += 1; @column += 1
|
|
227
|
+
end
|
|
228
|
+
current_line = @code[line_start...@pos]
|
|
229
|
+
if current_line.strip =~ /\A[-|]?\s*#{Regexp.escape(tag)}\s*\z/
|
|
230
|
+
@pos += 1 if @pos < @code.length; @line += 1; @column = 1; break
|
|
231
|
+
end
|
|
232
|
+
heredoc_content << current_line << "\n"
|
|
233
|
+
@pos += 1; @line += 1; @column = 1
|
|
234
|
+
end
|
|
235
|
+
add_token(:HEREDOC, heredoc_content, @line, start_col)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def scan_other # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength
|
|
239
|
+
OPERATORS.each do |op, type|
|
|
240
|
+
if @code[@pos, op.length] == op
|
|
241
|
+
emit(type, op, op.length); return
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
ch = @code[@pos]
|
|
245
|
+
if SINGLE_CHAR.key?(ch)
|
|
246
|
+
emit(SINGLE_CHAR[ch], ch); return
|
|
247
|
+
end
|
|
248
|
+
if ch =~ /[0-9]/
|
|
249
|
+
scan_number; return
|
|
250
|
+
end
|
|
251
|
+
if ch =~ /[a-zA-Z_]/ || (ch == ':' && @code[@pos + 1] == ':')
|
|
252
|
+
scan_name; return
|
|
253
|
+
end
|
|
254
|
+
emit(:OTHER, ch)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def scan_number
|
|
258
|
+
start = @pos; start_col = @column
|
|
259
|
+
if @code[@pos] == '0' && @code[@pos + 1] =~ /[xX]/
|
|
260
|
+
@pos += 2; @column += 2
|
|
261
|
+
while @pos < @code.length && @code[@pos] =~ /[0-9a-fA-F_]/
|
|
262
|
+
@pos += 1; @column += 1
|
|
263
|
+
end
|
|
264
|
+
elsif @code[@pos] == '0' && @code[@pos + 1] =~ /[0-7]/
|
|
265
|
+
@pos += 1; @column += 1
|
|
266
|
+
while @pos < @code.length && @code[@pos] =~ /[0-7_]/
|
|
267
|
+
@pos += 1; @column += 1
|
|
268
|
+
end
|
|
269
|
+
else
|
|
270
|
+
while @pos < @code.length && @code[@pos] =~ /[0-9_]/
|
|
271
|
+
@pos += 1; @column += 1
|
|
272
|
+
end
|
|
273
|
+
if @pos < @code.length && @code[@pos] == '.' && @code[@pos + 1] =~ /[0-9]/
|
|
274
|
+
@pos += 1; @column += 1
|
|
275
|
+
while @pos < @code.length && @code[@pos] =~ /[0-9_]/
|
|
276
|
+
@pos += 1; @column += 1
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
if @pos < @code.length && @code[@pos] =~ /[eE]/
|
|
280
|
+
@pos += 1; @column += 1
|
|
281
|
+
if @pos < @code.length && @code[@pos] =~ /[+\-]/
|
|
282
|
+
@pos += 1; @column += 1
|
|
283
|
+
end
|
|
284
|
+
while @pos < @code.length && @code[@pos] =~ /[0-9]/
|
|
285
|
+
@pos += 1; @column += 1
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
add_token(:NUMBER, @code[start...@pos], @line, start_col)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def scan_name
|
|
293
|
+
start = @pos; start_col = @column
|
|
294
|
+
if @code[@pos] == ':' && @code[@pos + 1] == ':'
|
|
295
|
+
@pos += 2; @column += 2
|
|
296
|
+
end
|
|
297
|
+
while @pos < @code.length && @code[@pos] =~ /[a-zA-Z0-9_]/
|
|
298
|
+
@pos += 1; @column += 1
|
|
299
|
+
if @pos + 1 < @code.length && @code[@pos] == ':' && @code[@pos + 1] == ':'
|
|
300
|
+
@pos += 2; @column += 2
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
word = @code[start...@pos]
|
|
304
|
+
if KEYWORDS.key?(word)
|
|
305
|
+
add_token(KEYWORDS[word], word, @line, start_col)
|
|
306
|
+
elsif word =~ /\A[A-Z]/
|
|
307
|
+
add_token(:CLASSREF, word, @line, start_col)
|
|
308
|
+
else
|
|
309
|
+
add_token(:NAME, word, @line, start_col)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def emit(type, value, length = nil)
|
|
314
|
+
length ||= value.length
|
|
315
|
+
add_token(type, value, @line, @column)
|
|
316
|
+
@pos += length; @column += length
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def add_token(type, value, line, column)
|
|
320
|
+
@tokens << Token.new(type, value, line, column)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def link_tokens
|
|
324
|
+
@tokens.each_with_index do |tok, i|
|
|
325
|
+
tok.prev_token = i > 0 ? @tokens[i - 1] : nil
|
|
326
|
+
tok.next_token = @tokens[i + 1]
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def prev_non_ws_type
|
|
331
|
+
@tokens.reverse_each do |t|
|
|
332
|
+
return t.type unless t.type == :WHITESPACE || t.type == :INDENT
|
|
333
|
+
end
|
|
334
|
+
nil
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def regex_possible?
|
|
338
|
+
return true if @tokens.empty?
|
|
339
|
+
REGEX_PREV.include?(prev_non_ws_type)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenvoxLint
|
|
4
|
+
# Orchestrates the linting of one or more manifest files.
|
|
5
|
+
class Linter
|
|
6
|
+
attr_reader :problems, :file_count
|
|
7
|
+
|
|
8
|
+
def initialize(configuration: OpenvoxLint.configuration)
|
|
9
|
+
@config = configuration
|
|
10
|
+
@problems = []
|
|
11
|
+
@file_count = 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(*fileargs)
|
|
15
|
+
files = expand_files(fileargs.flatten)
|
|
16
|
+
files.each { |f| lint_file(f) }
|
|
17
|
+
@problems.sort_by! { |p| [p[:path] || '', p[:line] || 0, p[:column] || 0] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def errors?
|
|
21
|
+
@problems.any? { |p| p[:kind] == :error }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def warnings?
|
|
25
|
+
@problems.any? { |p| p[:kind] == :warning }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def exit_code
|
|
29
|
+
return 1 if errors?
|
|
30
|
+
return 1 if warnings? && @config.fail_on_warnings
|
|
31
|
+
0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def lint_file(filepath)
|
|
37
|
+
@file_count += 1
|
|
38
|
+
code = File.read(filepath)
|
|
39
|
+
lexer = Lexer.new(code)
|
|
40
|
+
checker = Checks.new(
|
|
41
|
+
tokens: lexer.tokens, manifest_lines: lexer.manifest_lines,
|
|
42
|
+
fullpath: filepath, configuration: @config,
|
|
43
|
+
)
|
|
44
|
+
@problems.concat(checker.run)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
@problems << {
|
|
47
|
+
path: filepath, line: 0, column: 0, kind: :error,
|
|
48
|
+
check: :syntax, message: "Could not parse file: #{e.message}",
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def expand_files(fileargs)
|
|
53
|
+
files = []
|
|
54
|
+
fileargs.each do |arg|
|
|
55
|
+
if File.directory?(arg)
|
|
56
|
+
files.concat(Dir.glob(File.join(arg, '**', '*.pp')))
|
|
57
|
+
elsif arg.include?('*')
|
|
58
|
+
files.concat(Dir.glob(arg))
|
|
59
|
+
elsif File.file?(arg)
|
|
60
|
+
files << arg
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
files.reject { |f| ignored?(f) }.uniq
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ignored?(filepath)
|
|
67
|
+
@config.ignore_paths.any? do |pat|
|
|
68
|
+
File.fnmatch?(pat, filepath, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Hash rockets (=>) should be aligned within a resource body.
|
|
4
|
+
OpenvoxLint.new_check(:arrow_alignment) do
|
|
5
|
+
def check
|
|
6
|
+
# Group FARROW tokens by resource (consecutive lines)
|
|
7
|
+
arrows = tokens.select { |t| t.type == :FARROW }
|
|
8
|
+
return if arrows.empty?
|
|
9
|
+
|
|
10
|
+
# Group arrows by their enclosing brace block
|
|
11
|
+
groups = group_arrows(arrows)
|
|
12
|
+
groups.each do |group|
|
|
13
|
+
next if group.size <= 1
|
|
14
|
+
columns = group.map(&:column)
|
|
15
|
+
max_col = columns.max
|
|
16
|
+
group.each do |arrow|
|
|
17
|
+
next if arrow.column == max_col
|
|
18
|
+
notify :warning,
|
|
19
|
+
message: "arrow (=>) on line #{arrow.line} not aligned (column #{arrow.column} vs #{max_col})",
|
|
20
|
+
line: arrow.line,
|
|
21
|
+
column: arrow.column
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def group_arrows(arrows)
|
|
29
|
+
groups = []
|
|
30
|
+
current = [arrows.first]
|
|
31
|
+
arrows[1..].each do |arrow|
|
|
32
|
+
if arrow.line - current.last.line <= 2
|
|
33
|
+
current << arrow
|
|
34
|
+
else
|
|
35
|
+
groups << current
|
|
36
|
+
current = [arrow]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
groups << current unless current.empty?
|
|
40
|
+
groups
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Class/define names should match the autoloader file path.
|
|
4
|
+
# e.g. class foo::bar::baz should be in foo/manifests/bar/baz.pp
|
|
5
|
+
OpenvoxLint.new_check(:autoloader_layout) do
|
|
6
|
+
def check
|
|
7
|
+
sem = semantic_tokens
|
|
8
|
+
sem.each_with_index do |tok, i|
|
|
9
|
+
next unless tok.type == :CLASS || tok.type == :DEFINE
|
|
10
|
+
j = i + 1
|
|
11
|
+
next if j >= sem.length
|
|
12
|
+
name_tok = sem[j]
|
|
13
|
+
next unless name_tok.type == :NAME || name_tok.type == :CLASSREF
|
|
14
|
+
name = name_tok.value
|
|
15
|
+
parts = name.split('::')
|
|
16
|
+
next if parts.empty?
|
|
17
|
+
expected_path = if parts.length == 1
|
|
18
|
+
"#{parts[0]}/manifests/init.pp"
|
|
19
|
+
else
|
|
20
|
+
"#{parts[0]}/manifests/#{parts[1..].join('/')}.pp"
|
|
21
|
+
end
|
|
22
|
+
next if fullpath.end_with?(expected_path)
|
|
23
|
+
# Also check with just the filename portion
|
|
24
|
+
expected_file = parts.length == 1 ? 'init.pp' : "#{parts.last}.pp"
|
|
25
|
+
next if filename == expected_file
|
|
26
|
+
notify :warning,
|
|
27
|
+
message: "#{tok.value} '#{name}' not in expected autoloader path '#{expected_path}'",
|
|
28
|
+
line: name_tok.line, column: name_tok.column
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Case statements should have a default case.
|
|
4
|
+
OpenvoxLint.new_check(:case_without_default) do
|
|
5
|
+
def check
|
|
6
|
+
sem = semantic_tokens
|
|
7
|
+
sem.each_with_index do |tok, i|
|
|
8
|
+
next unless tok.type == :CASE
|
|
9
|
+
# Find the matching brace block
|
|
10
|
+
j = i + 1
|
|
11
|
+
j += 1 while j < sem.length && sem[j].type != :LBRACE
|
|
12
|
+
next if j >= sem.length
|
|
13
|
+
depth = 1; k = j + 1; has_default = false
|
|
14
|
+
while k < sem.length && depth > 0
|
|
15
|
+
case sem[k].type
|
|
16
|
+
when :LBRACE then depth += 1
|
|
17
|
+
when :RBRACE then depth -= 1
|
|
18
|
+
when :DEFAULT then has_default = true if depth == 1
|
|
19
|
+
end
|
|
20
|
+
k += 1
|
|
21
|
+
end
|
|
22
|
+
next if has_default
|
|
23
|
+
notify :warning,
|
|
24
|
+
message: 'case statement without a default case',
|
|
25
|
+
line: tok.line, column: tok.column
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Class inheritance is discouraged. Prefer composition (include/contain/require).
|
|
4
|
+
OpenvoxLint.new_check(:class_inherits_params) do
|
|
5
|
+
def check
|
|
6
|
+
tokens.each do |tok|
|
|
7
|
+
next unless tok.type == :INHERITS
|
|
8
|
+
notify :warning,
|
|
9
|
+
message: 'class inheritance is discouraged; use composition (include, contain, require) instead',
|
|
10
|
+
line: tok.line, column: tok.column
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|