Class: Licensed::Sources::Cabal

Inherits:
Source
  • Object
show all
Defined in:
lib/licensed/sources/cabal.rb

Constant Summary collapse

DEPENDENCY_REGEX =
/\s*.+?\s*/.freeze
DEFAULT_TARGETS =
%w{executable library}.freeze

Instance Attribute Summary

Attributes inherited from Source

#config

Instance Method Summary collapse

Methods inherited from Source

#dependencies, full_type, #ignored?, inherited, #initialize, register_source, require_matched_dependency_version, #source_config, type, type_and_version

Constructor Details

This class inherits a constructor from Licensed::Sources::Source

Instance Method Details

#cabal_file_dependenciesObject

Returns a set of the top-level dependencies found in cabal files



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/licensed/sources/cabal.rb', line 160

def cabal_file_dependencies
  @cabal_file_dependencies ||= cabal_files.each_with_object(Set.new) do |cabal_file, targets|
    content = File.read(cabal_file)
    next if content.nil? || content.empty?

    # add any dependencies for matched targets from the cabal file.
    # by default this will find executable and library dependencies
    content.scan(cabal_file_regex).each do |match|
      # match[1] is a string of "," separated dependencies.
      # dependency packages might have a version specifier, remove them
      # to get the full id specifier for each package
      dependencies = match[1].split(",").map(&:strip)
      targets.merge(dependencies)
    end
  end
end

#cabal_file_regexObject

Find ‘build-depends` lists from specified targets in a cabal file



190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/licensed/sources/cabal.rb', line 190

def cabal_file_regex
  # this will match 0 or more occurences of
  # match[0] - specifier, e.g. executable, library, etc
  # match[1] - full list of matched dependencies
  # match[2] - first matched dependency (required)
  # match[3] - remainder of matched dependencies (not required)
  @cabal_file_regex ||= /
    # match a specifier, e.g. library or executable
    ^(#{cabal_file_targets.join("|")})
      .*? # stuff

      # match a list of 1 or more dependencies
      build-depends:(#{DEPENDENCY_REGEX}(,#{DEPENDENCY_REGEX})*)\n
  /xmi
end

#cabal_file_targetsObject

Returns the targets to search for ‘build-depends` in a cabal file



207
208
209
210
211
# File 'lib/licensed/sources/cabal.rb', line 207

def cabal_file_targets
  targets = Array(config.dig("cabal", "cabal_file_targets"))
  targets.push(*DEFAULT_TARGETS) if targets.empty?
  targets
end

#cabal_filesObject

Returns an array of the local directory cabal package files



214
215
216
# File 'lib/licensed/sources/cabal.rb', line 214

def cabal_files
  @cabal_files ||= Dir.glob(File.join(config.pwd, "*.cabal"))
end

#cabal_package_id(package_name) ⇒ Object

Returns an installed package id for the package.



178
179
180
181
182
183
184
185
186
187
# File 'lib/licensed/sources/cabal.rb', line 178

def cabal_package_id(package_name)
  # using the first returned id assumes that package resolvers
  # order returned package information in the same order that it would
  # be used during build
  field = ghc_pkg_field_command(package_name, ["id"]).lines.first
  return unless field

  id = field.split(":", 2)[1]
  id.strip if id
end

#enabled?Boolean

Returns:

  • (Boolean)


10
11
12
# File 'lib/licensed/sources/cabal.rb', line 10

def enabled?
  cabal_file_dependencies.any? && ghc?
end

#enumerate_dependenciesObject



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# File 'lib/licensed/sources/cabal.rb', line 14

def enumerate_dependencies
  packages.map do |package|
    path, search_root = package_docs_dirs(package)
    Dependency.new(
      name: package["name"],
      version: package["version"],
      path: path,
      search_root: search_root,
      errors: Array(package["error"]),
      metadata: {
        "type"     => Cabal.type,
        "summary"  => package["synopsis"],
        "homepage" => safe_homepage(package["homepage"])
      }
    )
  end
end

#ghc?Boolean

Returns whether the ghc cli tool is available

Returns:

  • (Boolean)


225
226
227
# File 'lib/licensed/sources/cabal.rb', line 225

def ghc?
  @ghc ||= Licensed::Shell.tool_available?("ghc")
end

#ghc_pkg_field_command(id, fields, *args) ⇒ Object

Runs a ‘ghc-pkg field` command for a given set of fields and arguments Automatically includes ghc package DB locations in the command



136
137
138
# File 'lib/licensed/sources/cabal.rb', line 136

def ghc_pkg_field_command(id, fields, *args)
  Licensed::Shell.execute("ghc-pkg", "field", id, fields.join(","), *args, *package_db_args, allow_failure: true)
end

#ghc_versionObject

Returns the ghc cli tool version



219
220
221
222
# File 'lib/licensed/sources/cabal.rb', line 219

def ghc_version
  return unless ghc?
  @version ||= Licensed::Shell.execute("ghc", "--numeric-version")
end

#missing_package(id) ⇒ Object

Returns a package info structure with an error set



230
231
232
233
# File 'lib/licensed/sources/cabal.rb', line 230

def missing_package(id)
  name, version = package_id_name_version(id)
  { "name" => name, "version" => version, "error" => "package not found" }
end

#package_db_argsObject

Returns an array of ghc package DB locations as specified in the app configuration



142
143
144
145
146
147
148
149
150
151
# File 'lib/licensed/sources/cabal.rb', line 142

def package_db_args
  @package_db_args ||= Array(config.dig("cabal", "ghc_package_db")).map do |path|
    next "--#{path}" if %w(global user).include?(path)
    path = realized_ghc_package_path(path)
    path = File.expand_path(path, config.root)

    next unless File.exist?(path)
    "--package-db=#{path}"
  end.compact
end

#package_dependencies(id) ⇒ Object

Returns an array of dependency package names for the cabal package given by ‘id`



103
104
105
# File 'lib/licensed/sources/cabal.rb', line 103

def package_dependencies(id)
  package_dependencies_command(id).gsub("depends:", "").split.map(&:strip)
end

#package_dependencies_command(id) ⇒ Object

Returns the output of running ‘ghc-pkg field depends` for a package id Optionally allows for interpreting the given id as an installed package id (`–ipid`)



110
111
112
113
# File 'lib/licensed/sources/cabal.rb', line 110

def package_dependencies_command(id)
  fields = %w(depends)
  ghc_pkg_field_command(id, fields, "--ipid")
end

#package_docs_dirs(package) ⇒ Object

Returns the packages document directory and search root directory as an array



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/licensed/sources/cabal.rb', line 50

def package_docs_dirs(package)
  return [nil, nil] if package.nil? || package.empty?

  unless package["haddock-html"]
    # default to a local vendor directory if haddock-html property
    # isn't available
    return [File.join(config.pwd, "vendor", package["name"]), nil]
  end

  html_dir = package["haddock-html"]
  data_dir = package["data-dir"]
  return [html_dir, nil] unless data_dir

  # only allow data directories that are ancestors of the html directory
  unless Pathname.new(html_dir).fnmatch?(File.join(data_dir, "**"))
    data_dir = nil
  end

  [html_dir, data_dir]
end

#package_id_name_version(id) ⇒ Object

Parses the name and version pieces from an id or package requirement string



236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/licensed/sources/cabal.rb', line 236

def package_id_name_version(id)
  name, version = id.split(" ", 2)
  return [name, version] if version

  # split by dashes, find the rightmost thing that looks like an
  parts = id.split("-")
  version_start_index = parts.rindex { |part| part.match?(/^[\d\.]+$/) }
  return [id, nil] if version_start_index.nil?

  [
    parts[0...version_start_index].join("-"),
    parts[version_start_index..-1].join("-")
  ]
end

#package_info(id) ⇒ Object

Returns package information as a hash for the given id



116
117
118
119
120
121
122
123
124
125
126
# File 'lib/licensed/sources/cabal.rb', line 116

def package_info(id)
  info = package_info_command(id).strip
  return missing_package(id) if info.empty?

  info.lines.each_with_object({}) do |line, hsh|
    key, value = line.split(":", 2).map(&:strip)
    next unless key && value

    hsh[key] = value
  end
end

#package_info_command(id) ⇒ Object

Returns the output of running ‘ghc-pkg field` to obtain package information



129
130
131
132
# File 'lib/licensed/sources/cabal.rb', line 129

def package_info_command(id)
  fields = %w(name version synopsis homepage haddock-html data-dir)
  ghc_pkg_field_command(id, fields, "--ipid")
end

#packagesObject

Returns a list of all detected packages



33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/licensed/sources/cabal.rb', line 33

def packages
  package_ids = Set.new
  cabal_file_dependencies.each do |target|
    name = target.split(/\s/)[0]
    package_id = cabal_package_id(name)
    if package_id.nil?
      package_ids << target
    else
      recursive_dependencies([package_id], package_ids)
    end
  end

  Parallel.map(package_ids) { |id| package_info(id) }
end

#realized_ghc_package_path(path) ⇒ Object

Returns a ghc package path with template markers replaced by live data



155
156
157
# File 'lib/licensed/sources/cabal.rb', line 155

def realized_ghc_package_path(path)
  path.gsub("<ghc_version>", ghc_version)
end

#recursive_dependencies(package_names, results = Set.new) ⇒ Object

Recursively finds the dependencies for each cabal package. Returns a ‘Set` containing the package names for all dependencies



87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/licensed/sources/cabal.rb', line 87

def recursive_dependencies(package_names, results = Set.new)
  return results if package_names.nil? || package_names.empty?

  new_packages = Set.new(package_names) - results
  return results if new_packages.empty?

  results.merge new_packages

  dependencies = Parallel.map(new_packages, &method(:package_dependencies)).flatten

  recursive_dependencies(dependencies, results)
  results
end

#safe_homepage(homepage) ⇒ Object

Returns a homepage url that enforces https and removes url fragments



72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/licensed/sources/cabal.rb', line 72

def safe_homepage(homepage)
  return unless homepage
  # Ensure there's no denial of service issue with a long homepage
  # 1000 characters is likely enough for any real project homepage
  # See https://github.com/github/licensed/security/code-scanning/1
  if homepage.length > 1000
    raise ArgumentError, "Input too long"
  end
  # use https and remove url fragment
  homepage.gsub(/http:/, "https:")
          .gsub(/#[^?]*\z/, "")
end