Module: FoodCritic::Api

Includes:
AST, Chef, Notifications, XML
Included in:
Linter, RuleDsl
Defined in:
lib/foodcritic/api.rb

Overview

Helper methods that form part of the Rules DSL.

Defined Under Namespace

Classes: AttFilter, RecursedTooFarError

Instance Method Summary collapse

Methods included from Notifications

#notifications

Methods included from Chef

#chef_dsl_methods, #chef_node_methods, #resource_action?, #resource_attribute?, #valid_query?

Instance Method Details

#ast_cache(size = nil) ⇒ Object

Returns a global LRU cache holding the AST of a file

Parameters:

  • size (Integer) (defaults to: nil)

    the size of the cache (will be resized)

Since:

  • 16.1



260
261
262
263
264
265
266
# File 'lib/foodcritic/api.rb', line 260

def ast_cache(size = nil)
  @@ast_cache ||= Rufus::Lru::Hash.new(size || 5)
  if size && @@ast_cache.maxsize != size
    @@ast_cache.maxsize = size
  end
  @@ast_cache
end

#attribute_access(ast, options = {}) ⇒ Object

Find attribute access by type.



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/foodcritic/api.rb', line 28

def attribute_access(ast, options = {})
  options = { type: :any, ignore_calls: false }.merge!(options)
  return [] unless ast.respond_to?(:xpath)

  unless [:any, :string, :symbol, :vivified].include?(options[:type])
    raise ArgumentError, "Node type not recognised"
  end

  case options[:type]
  when :any then
    vivified_attribute_access(ast, options) +
      standard_attribute_access(ast, options)
  when :vivified then
    vivified_attribute_access(ast, options)
  else
    standard_attribute_access(ast, options)
  end
end

#cookbook_base_path(file) ⇒ String

The absolute path of a cookbook from the specified file.

Parameters:

  • file (String, Pathname)

    relative or absolute path to a file in the cookbook

Returns:

  • (String)

    the absolute path to the base of the cookbook

Author:

Since:

  • 10.1



53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/foodcritic/api.rb', line 53

def cookbook_base_path(file)
  file = File.expand_path(file) # make sure we get an absolute path
  file = File.dirname(file) unless File.directory?(file) # get the dir only

  # get list of items in the dir and intersect with metadata array.
  # until we get an interfact (we have a metadata) walk up the dir structure
  until (Dir.entries(file) & %w{metadata.rb metadata.json}).any?
    file = File.dirname(file)
  end

  file
end

#cookbook_maintainer(file) ⇒ String

Return metadata maintainer property given any file in the cookbook

Parameters:

  • file (String)

    file within a cookbook

Returns:

  • (String)

    the maintainer of the cookbook

Raises:

  • (ArgumentError)


113
114
115
116
117
# File 'lib/foodcritic/api.rb', line 113

def cookbook_maintainer(file)
  raise ArgumentError, "File cannot be nil or empty" if file.to_s.empty?

  (file, "maintainer")
end

#cookbook_maintainer_email(file) ⇒ String

Return metadata maintainer_email property given any file in the cookbook

Parameters:

  • file (String)

    file within a cookbook

Returns:

  • (String)

    email of the maintainer of the cookbook

Raises:

  • (ArgumentError)


123
124
125
126
127
# File 'lib/foodcritic/api.rb', line 123

def cookbook_maintainer_email(file)
  raise ArgumentError, "File cannot be nil or empty" if file.to_s.empty?

  (file, "maintainer_email")
end

#cookbook_name(file) ⇒ String

The name of the cookbook containing the specified file.

Parameters:

  • file (String)

    file within a cookbook

Returns:

  • (String)

    name of the cookbook

Raises:

  • (ArgumentError)


93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/foodcritic/api.rb', line 93

def cookbook_name(file)
  raise ArgumentError, "File cannot be nil or empty" if file.to_s.empty?

  # Name is a special case as we want to fallback to the cookbook directory
  # name if metadata_field fails
  begin
    (file, "name")
  rescue RuntimeError
    until (file.split(File::SEPARATOR) & standard_cookbook_subdirs).empty?
      file = File.absolute_path(File.dirname(file.to_s))
    end
    file = File.dirname(file) unless File.extname(file).empty?
    File.basename(file)
  end
end

#declared_dependencies(ast) ⇒ Object

The dependencies declared in cookbook metadata.



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/foodcritic/api.rb', line 130

def declared_dependencies(ast)
  raise_unless_xpath!(ast)

  deps = []
  # String literals.
  #
  #     depends 'foo'
  deps += field(ast, "depends").xpath("descendant::args_add/descendant::tstring_content[1]")

  # Quoted word arrays are also common.
  #
  #     %w{foo bar baz}.each do |cbk|
  #       depends cbk
  #     end
  deps += word_list_values(field(ast, "depends"))
  deps.uniq!
  deps.map! { |dep| dep["value"].strip }
  deps
end

#ensure_file_exists(basepath, filepath) ⇒ String

Match unless a file exists using the basepath of the cookbook and the filepath

Parameters:

  • basepath (String)

    base path of the cookbook

  • filepath (String)

    path to the file within the cookbook

Returns:

  • (String)

    the absolute path to the base of the cookbook

Author:

Since:

  • 10.3



22
23
24
25
# File 'lib/foodcritic/api.rb', line 22

def ensure_file_exists(basepath, filepath)
  path = File.join(basepath, filepath)
  [file_match(path)] unless File.exist?(path)
end

#field(ast, field_name) ⇒ Nokogiri::XML::NodeSet

Look for a method call with a given name.

Parameters:

  • ast (Nokogiri::XML::Node)

    Document to search under

  • field_name (String)

    Method name to search for

Returns:

  • (Nokogiri::XML::NodeSet)


155
156
157
158
159
160
# File 'lib/foodcritic/api.rb', line 155

def field(ast, field_name)
  if field_name.nil? || field_name.to_s.empty?
    raise ArgumentError, "Field name cannot be nil or empty"
  end
  ast.xpath("(.//command[ident/@value='#{field_name}']|.//fcall[ident/@value='#{field_name}']/..)")
end

#field_value(ast, field_name) ⇒ Object

The value for a specific key in an environment or role ruby file



163
164
165
166
167
# File 'lib/foodcritic/api.rb', line 163

def field_value(ast, field_name)
  field(ast, field_name).xpath('.//args_add_block//tstring_content
    [count(ancestor::args_add) = 1][count(ancestor::string_add) = 1]
    /@value').map { |a| a.to_s }.last
end

#file_match(file) ⇒ Object

Create a match for a specified file. Use this if the presence of the file triggers the warning rather than content.

Raises:

  • (ArgumentError)


171
172
173
174
# File 'lib/foodcritic/api.rb', line 171

def file_match(file)
  raise ArgumentError, "Filename cannot be nil" if file.nil?
  { filename: file, matched: file, line: 1, column: 1 }
end

#find_resources(ast, options = {}) ⇒ Object

Find Chef resources of the specified type. TODO: Include blockless resources

These are equivalent:

find_resources(ast)
find_resources(ast, type: :any)

Restrict to a specific type of resource(s):

find_resources(ast, type: 'service')
find_resources(ast, type: %w(service file))


189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/foodcritic/api.rb', line 189

def find_resources(ast, options = {})
  options = { type: :any }.merge!(options)
  return [] unless ast.respond_to?(:xpath)
  scope_type = ""
  unless options[:type] == :any
    type_array =  Array(options[:type]).map { |x| "@value='#{x}'" }
    scope_type = "[#{type_array.join(' or ')}]"
  end

  # TODO: Include nested resources (provider actions)
  no_actions = "[command/ident/@value != 'action']"
  ast.xpath("//method_add_block[command/ident#{scope_type}]#{no_actions}")
end

#gem_version(version) ⇒ Object

Helper to return a comparable version for a string.



204
205
206
# File 'lib/foodcritic/api.rb', line 204

def gem_version(version)
  Gem::Version.create(version)
end

#included_recipes(ast, options = { with_partial_names: true }) ⇒ Object

Retrieve the recipes that are included within the given recipe AST.

These two usages are equivalent:

included_recipes(ast)
included_recipes(ast, :with_partial_names => true)


215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/foodcritic/api.rb', line 215

def included_recipes(ast, options = { with_partial_names: true })
  raise_unless_xpath!(ast)

  filter = ["[count(descendant::args_add) = 1]"]

  # If `:with_partial_names` is false then we won't include the string
  # literal portions of any string that has an embedded expression.
  unless options[:with_partial_names]
    filter << "[count(descendant::string_embexpr) = 0]"
  end

  string_desc = '[descendant::args_add/string_literal]/
    descendant::tstring_content'
  included = ast.xpath([
    "//command[ident/@value = 'include_recipe']",
    "//fcall[ident/@value = 'include_recipe']/
     following-sibling::arg_paren",
  ].map do |recipe_include|
    recipe_include + filter.join + string_desc
  end.join(" | "))

  # Hash keyed by recipe name with matched nodes.
  included.inject(Hash.new([])) { |h, i| h[i["value"]] += [i]; h }
end

#json_file_to_hash(filename) ⇒ Hash

Give a filename path it returns the hash of the JSON contents

Parameters:

  • filename (String)

    path to a file in JSON format

Returns:

  • (Hash)

    hash of JSON content

Author:

Since:

  • 11.0



431
432
433
434
435
436
437
438
439
440
# File 'lib/foodcritic/api.rb', line 431

def json_file_to_hash(filename)
  raise "File #{filename} not found" unless File.exist?(filename)

  file = File.read(filename)
  begin
    FFI_Yajl::Parser.parse(file)
  rescue FFI_Yajl::ParseError
    raise "File #{filename} does not appear to contain valid JSON"
  end
end

#literal_searches(ast) ⇒ Object

Searches performed by the specified recipe that are literal strings. Searches with a query formed from a subexpression will be ignored.



242
243
244
245
246
# File 'lib/foodcritic/api.rb', line 242

def literal_searches(ast)
  return [] unless ast.respond_to?(:xpath)
  ast.xpath("//method_add_arg[fcall/ident/@value = 'search' and
    count(descendant::string_embexpr) = 0]/descendant::tstring_content")
end

#match(node) ⇒ Object

Create a match from the specified node.



249
250
251
252
253
254
255
# File 'lib/foodcritic/api.rb', line 249

def match(node)
  raise_unless_xpath!(node)
  pos = node.xpath("descendant::pos").first
  return nil if pos.nil?
  { matched: node.respond_to?(:name) ? node.name : "",
    line: pos["line"].to_i, column: pos["column"].to_i }
end

#metadata_field(file, field) ⇒ String

Retrieves a value of a metadata field.

Returns:

  • (String)

    the value of the metadata field

Author:

  • Miguel Fonseca

Since:

  • 7.0.0



71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/foodcritic/api.rb', line 71

def (file, field)
  until (file.split(File::SEPARATOR) & standard_cookbook_subdirs).empty?
    file = File.absolute_path(File.dirname(file.to_s))
  end
  file = File.dirname(file) unless File.extname(file).empty?

  md_path = File.join(file, "metadata.rb")
  if File.exist?(md_path)
    value = read_ast(md_path).xpath("//stmts_add/
      command[ident/@value='#{field}']/
      descendant::tstring_content/@value").to_s
    raise "Cant read #{field} from #{md_path}" if value.to_s.empty?
    return value
  else
    raise "Cant find #{md_path}"
  end
end

#read_ast(file) ⇒ Object

Read the AST for the given Ruby source file



269
270
271
# File 'lib/foodcritic/api.rb', line 269

def read_ast(file)
  ast_cache[file] ||= uncached_read_ast(file)
end

#resource_attribute(resource, name) ⇒ Object

Retrieve a single-valued attribute from the specified resource.

Raises:

  • (ArgumentError)


274
275
276
277
# File 'lib/foodcritic/api.rb', line 274

def resource_attribute(resource, name)
  raise ArgumentError, "Attribute name cannot be empty" if name.empty?
  resource_attributes(resource)[name.to_s]
end

#resource_attributes(resource, options = {}) ⇒ Object

Retrieve all attributes from the specified resource.



280
281
282
283
284
285
286
287
# File 'lib/foodcritic/api.rb', line 280

def resource_attributes(resource, options = {})
  atts = {}
  name = resource_name(resource, options)
  atts[:name] = name unless name.empty?
  atts.merge!(normal_attributes(resource, options))
  atts.merge!(block_attributes(resource))
  atts
end

#resource_attributes_by_type(ast) ⇒ Object

Resources keyed by type, with an array of matching nodes for each.



290
291
292
293
294
295
296
297
298
# File 'lib/foodcritic/api.rb', line 290

def resource_attributes_by_type(ast)
  result = {}
  resources_by_type(ast).each do |type, resources|
    result[type] = resources.map do |resource|
      resource_attributes(resource)
    end
  end
  result
end

#resource_name(resource, options = {}) ⇒ Object

Retrieve the name attribute associated with the specified resource.



301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
# File 'lib/foodcritic/api.rb', line 301

def resource_name(resource, options = {})
  raise_unless_xpath!(resource)
  options = { return_expressions: false }.merge(options)
  if options[:return_expressions]
    name = resource.xpath("command/args_add_block")
    if name.xpath("descendant::string_add").size == 1 &&
        name.xpath("descendant::string_literal").size == 1 &&
        name.xpath(
          "descendant::*[self::call or self::string_embexpr]").empty?
      name.xpath("descendant::tstring_content/@value").to_s
    else
      name
    end
  else
    # Preserve existing behaviour
    resource.xpath("string(command//tstring_content/@value)")
  end
end

#resource_type(resource) ⇒ Object

Return the type, e.g. ‘package’ for a given resource



331
332
333
334
335
336
337
338
# File 'lib/foodcritic/api.rb', line 331

def resource_type(resource)
  raise_unless_xpath!(resource)
  type = resource.xpath("string(command/ident/@value)")
  if type.empty?
    raise ArgumentError, "Provided AST node is not a resource"
  end
  type
end

#resources_by_type(ast) ⇒ Object

Resources in an AST, keyed by type.



321
322
323
324
325
326
327
328
# File 'lib/foodcritic/api.rb', line 321

def resources_by_type(ast)
  raise_unless_xpath!(ast)
  result = Hash.new { |hash, key| hash[key] = Array.new }
  find_resources(ast).each do |resource|
    result[resource_type(resource)] << resource
  end
  result
end

#ruby_code?(str) ⇒ Boolean

Does the provided string look like ruby code?

Returns:

  • (Boolean)


341
342
343
344
345
346
347
348
# File 'lib/foodcritic/api.rb', line 341

def ruby_code?(str)
  str = str.to_s
  return false if str.empty?

  checker = FoodCritic::ErrorChecker.new(str)
  checker.parse
  !checker.error?
end

#searches(ast) ⇒ Object

Searches performed by the provided AST.



351
352
353
354
# File 'lib/foodcritic/api.rb', line 351

def searches(ast)
  return [] unless ast.respond_to?(:xpath)
  ast.xpath("//fcall/ident[@value = 'search']")
end

#standard_cookbook_subdirsarray

The list of standard cookbook sub-directories.

Returns:

  • (array)

    array of all default sub-directories in a cookbook

Since:

  • 1.0.0



360
361
362
363
# File 'lib/foodcritic/api.rb', line 360

def standard_cookbook_subdirs
  %w{attributes definitions files libraries providers recipes resources
     templates}
end

#supported_platforms(ast) ⇒ Array<Hash>

Platforms declared as supported in cookbook metadata. Returns an array of hashes containing the name and version constraints for each platform.

Parameters:

  • ast (Nokogiri::XML::Node)

    Document to search from.

Returns:

  • (Array<Hash>)


370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/foodcritic/api.rb', line 370

def supported_platforms(ast)
  # Find the supports() method call.
  platforms_ast = field(ast, "supports")
  # Look for the first argument (the node next to the top args_new) and
  # filter out anything with a string_embexpr since that can't be parsed
  # statically. Then grab the static value for both strings and symbols, and
  # finally combine it with the word list (%w{}) analyzer.
  platforms = platforms_ast.xpath("(.//args_new)[1]/../*[not(.//string_embexpr)]").xpath(".//tstring_content|.//symbol/ident") | word_list_values(platforms_ast)
  platforms.map do |platform|
    # For each platform value, look for all arguments after the first, then
    # extract the string literal value.
    versions = platform.xpath("ancestor::args_add[not(args_new)]/*[position()=2]//tstring_content/@value")
    { platform: platform["value"].lstrip, versions: versions.map(&:to_s) }
  end.sort_by { |p| p[:platform] }
end

#template_file(resource) ⇒ Object

Template filename



387
388
389
390
391
392
393
394
395
396
397
# File 'lib/foodcritic/api.rb', line 387

def template_file(resource)
  if resource["source"]
    resource["source"]
  elsif resource[:name]
    if resource[:name].respond_to?(:xpath)
      resource[:name]
    else
      "#{File.basename(resource[:name])}.erb"
    end
  end
end

#template_paths(recipe_path) ⇒ Object

Templates in the current cookbook



416
417
418
419
420
421
422
423
# File 'lib/foodcritic/api.rb', line 416

def template_paths(recipe_path)
  Dir.glob(Pathname.new(recipe_path).dirname.dirname + "templates" +
    "**/*", File::FNM_DOTMATCH).select do |path|
    File.file?(path)
  end.reject do |path|
    File.basename(path) == ".DS_Store" || File.extname(path) == ".swp"
  end
end

#templates_included(all_templates, template_path, depth = 1) ⇒ Object



399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
# File 'lib/foodcritic/api.rb', line 399

def templates_included(all_templates, template_path, depth = 1)
  raise RecursedTooFarError.new(template_path) if depth > 10
  partials = read_ast(template_path).xpath('//*[self::command or
    child::fcall][descendant::ident/@value="render"]//args_add/
    string_literal//tstring_content/@value').map { |p| p.to_s }
  Array(template_path) + partials.map do |included_partial|
    partial_path = Array(all_templates).find do |path|
      (Pathname.new(template_path).dirname + included_partial).to_s == path
    end
    if partial_path
      Array(partial_path) +
        templates_included(all_templates, partial_path, depth + 1)
    end
  end.flatten.uniq.compact
end