Skip to content

Commit 0445b5f

Browse files
committed
(GH-28) Add basic Puppetfile validation
Previously basic validation for Puppetfile's was added. This commit; * Adds more detailed parsing of Puppetfile to extract different types of modules git, local, svn or forge. These types have enough information to identify a module type, and basic name/title/version validation * Adds an internal only module type called invalid which is used to track invalid module definitions. Without this, the validation process would find the first invalid module and ignore subsequent entries. * Due to semantic_puppet not being available in all puppet editions, the version regexes are vendored into the code instead of calling out to Semantic Puppet. * Adds detection of invalid modules and duplicate module definitions * Adds tests for these scenarios
1 parent 90bc4cf commit 0445b5f

File tree

10 files changed

+456
-5
lines changed

10 files changed

+456
-5
lines changed

lib/puppet-languageserver/providers.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44
manifest/definition_provider
55
manifest/validation_provider
66
manifest/hover_provider
7+
puppetfile/r10k/module/base
8+
puppetfile/r10k/module/forge
9+
puppetfile/r10k/module/invalid
10+
puppetfile/r10k/module/local
11+
puppetfile/r10k/module/git
12+
puppetfile/r10k/module/svn
713
puppetfile/r10k/puppetfile
814
puppetfile/validation_provider
915
].each do |lib|
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
module PuppetLanguageServer
2+
module Puppetfile
3+
module R10K
4+
module Module
5+
def self.from_puppetfile(title, args)
6+
return Git.new(title, args) if Git.implements?(title, args)
7+
return Svn.new(title, args) if Svn.implements?(title, args)
8+
return Local.new(title, args) if Local.implements?(title, args)
9+
return Forge.new(title, args) if Forge.implements?(title, args)
10+
11+
Invalid.new(title, args)
12+
end
13+
14+
class Base
15+
# The full title of the module
16+
attr_reader :title
17+
18+
# The name of the module
19+
attr_reader :name
20+
21+
# The line number where this module is first found in Puppetfile
22+
attr_reader :puppetfile_line_number
23+
24+
def initialize(title, args)
25+
@title = title
26+
@args = args
27+
@owner, @name = parse_title(@title)
28+
29+
@puppetfile_line_number = find_load_location
30+
end
31+
32+
# Should be overridden in concrete module classes
33+
def version
34+
nil
35+
end
36+
37+
# Should be overridden in concrete module classes
38+
def properties
39+
{}
40+
end
41+
42+
private
43+
44+
def parse_title(title)
45+
if (match = title.match(/\A(\w+)\Z/))
46+
[nil, match[1]]
47+
elsif (match = title.match(/\A(\w+)[-\/](\w+)\Z/))
48+
[match[1], match[2]]
49+
else
50+
raise ArgumentError, format("Module name (%<title>s) must match either 'modulename' or 'owner/modulename'", title: title)
51+
end
52+
end
53+
54+
def find_load_location
55+
loc = Kernel.caller_locations
56+
.find { |call_loc| call_loc.absolute_path == PuppetLanguageServer::Puppetfile::R10K::PUPPETFILE_MONIKER }
57+
loc.nil? ? 0 : loc.lineno - 1 # Line numbers from ruby are base 1
58+
end
59+
end
60+
end
61+
end
62+
end
63+
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
module PuppetLanguageServer
2+
module Puppetfile
3+
module R10K
4+
module Module
5+
class Forge < PuppetLanguageServer::Puppetfile::R10K::Module::Base
6+
def self.implements?(name, args)
7+
!name.match(/\A(\w+)[-\/](\w+)\Z/).nil? && valid_version?(args)
8+
end
9+
10+
def self.valid_version?(value)
11+
return false unless value.is_a?(String) || value.is_a?(Symbol)
12+
value == :latest || value.nil? || valid_version_string?(value)
13+
end
14+
15+
def properties
16+
{
17+
:type => :forge
18+
}
19+
end
20+
21+
# Version string matching regexes
22+
# From Semantic Puppet gem
23+
REGEX_NUMERIC = '(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)'.freeze # Major . Minor . Patch
24+
REGEX_PRE = '(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'.freeze # Prerelease
25+
REGEX_BUILD = '(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?'.freeze # Build
26+
REGEX_FULL = REGEX_NUMERIC + REGEX_PRE + REGEX_BUILD.freeze
27+
REGEX_FULL_RX = /\A#{REGEX_FULL}\Z/
28+
29+
def self.valid_version_string?(value)
30+
match = value.match(REGEX_FULL_RX)
31+
if match.nil?
32+
false
33+
else
34+
prerelease = match[4]
35+
prerelease.nil? || prerelease.split('.').all? { |x| x !~ /^0\d+$/ }
36+
end
37+
end
38+
end
39+
end
40+
end
41+
end
42+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module PuppetLanguageServer
2+
module Puppetfile
3+
module R10K
4+
module Module
5+
class Git < PuppetLanguageServer::Puppetfile::R10K::Module::Base
6+
def self.implements?(_name, args)
7+
args.is_a?(Hash) && args.key?(:git)
8+
rescue StandardError
9+
false
10+
end
11+
12+
def properties
13+
{
14+
:type => :git
15+
}
16+
end
17+
end
18+
end
19+
end
20+
end
21+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# This is a special module definition. It's the catchall when no other module type can handle it
2+
3+
module PuppetLanguageServer
4+
module Puppetfile
5+
module R10K
6+
module Module
7+
class Invalid < PuppetLanguageServer::Puppetfile::R10K::Module::Base
8+
def self.implements?(_name, _args)
9+
true
10+
end
11+
12+
def initialize(title, args)
13+
super
14+
@error_message = format("Module %<title>s with args %<args>s doesn't have an implementation. (Are you using the right arguments?)", title: title, args: args.inspect)
15+
end
16+
17+
def properties
18+
{
19+
:type => :invalid,
20+
:error_message => @error_message
21+
}
22+
end
23+
end
24+
end
25+
end
26+
end
27+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module PuppetLanguageServer
2+
module Puppetfile
3+
module R10K
4+
module Module
5+
class Local < PuppetLanguageServer::Puppetfile::R10K::Module::Base
6+
def self.implements?(_name, args)
7+
args.is_a?(Hash) && args.key?(:local)
8+
rescue StandardError
9+
false
10+
end
11+
12+
def properties
13+
{
14+
:type => :local
15+
}
16+
end
17+
end
18+
end
19+
end
20+
end
21+
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module PuppetLanguageServer
2+
module Puppetfile
3+
module R10K
4+
module Module
5+
class Svn < PuppetLanguageServer::Puppetfile::R10K::Module::Base
6+
def self.implements?(_name, args)
7+
args.is_a?(Hash) && args.key?(:svn)
8+
rescue StandardError
9+
false
10+
end
11+
12+
def properties
13+
{
14+
:type => :svn
15+
}
16+
end
17+
end
18+
end
19+
end
20+
end
21+
end

lib/puppet-languageserver/puppetfile/r10k/puppetfile.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,27 @@ module R10K
44
PUPPETFILE_MONIKER ||= 'Puppetfile'.freeze
55

66
class Puppetfile
7+
attr_reader :modules
8+
79
def load!(puppetfile_contents)
810
puppetfile = DSL.new(self)
11+
@modules = []
912
puppetfile.instance_eval(puppetfile_contents, PUPPETFILE_MONIKER)
1013
end
1114

15+
def add_module(name, args)
16+
@modules << Module.from_puppetfile(name, args)
17+
end
18+
1219
class DSL
1320
def initialize(parent)
1421
@parent = parent
1522
end
1623

1724
# @param [String] name
1825
# @param [*Object] args
19-
def mod(_name, _args = nil)
26+
def mod(name, args = nil)
27+
@parent.add_module(name, args)
2028
end
2129

2230
# @param [String] forge
@@ -27,7 +35,7 @@ def forge(_location)
2735
def moduledir(_location)
2836
end
2937

30-
def method_missing(method, *_args) # rubocop:disable Style/MethodMissing
38+
def method_missing(method, *_args) # rubocop:disable Style/MethodMissingSuper, Style/MissingRespondToMissing
3139
raise NoMethodError, format("Unknown method '%<method>s'", method: method)
3240
end
3341
end

lib/puppet-languageserver/puppetfile/validation_provider.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,37 @@ def self.validate(content, _workspace, _max_problems = 100)
3636
end
3737
return result if puppetfile.nil?
3838

39+
# Check for invalid module definitions
40+
puppetfile.modules.each do |mod|
41+
next unless mod.properties[:type] == :invalid
42+
# Note - Ruby doesn't give a character position so just highlight the entire line
43+
result << LanguageServer::Diagnostic.create('severity' => LanguageServer::DIAGNOSTICSEVERITY_ERROR,
44+
'fromline' => mod.puppetfile_line_number,
45+
'toline' => mod.puppetfile_line_number,
46+
'fromchar' => 0,
47+
'tochar' => max_line_length,
48+
'source' => 'Puppet',
49+
'message' => mod.properties[:error_message])
50+
end
51+
52+
# Check for duplicate module definitions
53+
dupes = puppetfile.modules
54+
.group_by { |mod| mod.name }
55+
.select { |_, v| v.size > 1 }
56+
.map(&:first)
57+
dupes.each do |dupe_module_name|
58+
puppetfile.modules.select { |mod| mod.name == dupe_module_name }.each do |puppet_module|
59+
# Note - Ruby doesn't give a character position so just highlight the entire line
60+
result << LanguageServer::Diagnostic.create('severity' => LanguageServer::DIAGNOSTICSEVERITY_ERROR,
61+
'fromline' => puppet_module.puppetfile_line_number,
62+
'toline' => puppet_module.puppetfile_line_number,
63+
'fromchar' => 0,
64+
'tochar' => max_line_length,
65+
'source' => 'Puppet',
66+
'message' => "Duplicate module definition for '#{puppet_module.name}'")
67+
end
68+
end
69+
3970
result
4071
end
4172
end

0 commit comments

Comments
 (0)