diff --git a/lib/licensed/sources/manifest.rb b/lib/licensed/sources/manifest.rb index 7fa53f1..81660e1 100644 --- a/lib/licensed/sources/manifest.rb +++ b/lib/licensed/sources/manifest.rb @@ -10,10 +10,11 @@ module Licensed def enumerate_dependencies packages.map do |package_name, sources| - Licensed::Sources::Manifest::Dependency.new(sources, - @config.root, - package_license(package_name), - { + Licensed::Sources::Manifest::Dependency.new( + name: package_name, + path: configured_license_path(package_name) || sources_license_path(sources), + sources: sources, + metadata: { "type" => Manifest.type, "name" => package_name, "version" => package_version(sources) @@ -32,7 +33,7 @@ module Licensed end # Returns the license path for a package specified in the configuration. - def package_license(package_name) + def configured_license_path(package_name) license_path = @config.dig("manifest", "licenses", package_name) return unless license_path @@ -41,6 +42,25 @@ module Licensed license_path end + # Returns the top-most directory that is common to all paths in `sources` + def sources_license_path(sources) + # if there is more than one source, try to find a directory common to + # all sources + if sources.size > 1 + common_prefix = Pathname.common_prefix(*sources).to_path + + # don't allow the workspace root to be used as common prefix + # the project this is run for should be excluded from the manifest, + # or ignored in the config. any license in the root should be ignored. + return common_prefix if common_prefix != @config.root + end + + # use the first (or only) sources directory to find license information + source = sources.first + return File.dirname(source) if File.file?(source) + source + end + # Returns a map of package names -> array of full source paths found # in the app manifest def packages @@ -169,87 +189,44 @@ module Licensed ) /imx.freeze - def initialize(sources, root, license_path, metadata = {}) + def initialize(name:, path:, sources:, metadata: {}) @sources = sources - license_path ||= sources_license_path(sources, root) - super license_path, metadata + super(name: name, path: path, metadata: metadata) end - # Detects license information and sets it on this dependency object. - # After calling `detect_license!``, the license is set at - # `dependency["license"]` and legal text is set to `dependency.text` - def detect_license! - # if no license key is found for the project, try to create a - # temporary LICENSE file from unique source file license headers - if license_key == "none" - tmp_license_file = write_license_from_source_licenses(self.path, @sources) - reset_license! - end + def project_files + files = super + files.concat(source_files) if files.empty? + files + end - super - ensure - File.delete(tmp_license_file) if tmp_license_file && File.exist?(tmp_license_file) + def source_files + @source_files ||= begin + @sources.select { |f| File.file?(f) } + .map { |f| File.read(f) } + .flat_map { |content| content.scan(HEADER_LICENSE_REGEX) } + .map { |match| match[0] } + .map { |comment| source_comment_text(comment) } + .compact + .map { |content| Licensee::ProjectFiles::LicenseFile.new(content) } + end end private - # Returns the top-most directory that is common to all paths in `sources` - def sources_license_path(sources, root) - # return the source directory if there is only one source given - return source_directory(sources[0]) if sources.size == 1 + # Returns the comment text with leading * and whitespace stripped + def source_comment_text(comment) + indent = nil + comment.lines.map do |line| + # find the length of the indent as the number of characters + # until the first word character + indent ||= line[/\A([^\w]*)\w/, 1]&.size - common_prefix = Pathname.common_prefix(*sources).to_path + # insert newline for each line until a word character is found + next "\n" unless indent - # don't allow the workspace root to be used as common prefix - # the project this is run for should be excluded from the manifest, - # or ignored in the config. any license in the root should be ignored. - return common_prefix if common_prefix != root - - # use the first source directory as the license path. - source_directory(sources.first) - end - - # Returns the directory for the source. Checks whether the source - # is a file or a directory - def source_directory(source) - return File.dirname(source) if File.file?(source) - source - end - - # Writes any licenses found in source file comments to a LICENSE - # file at `dir` - # Returns the path to the license file - def write_license_from_source_licenses(dir, sources) - license_path = File.join(dir, "LICENSE") - File.open(license_path, "w") do |f| - licenses = source_comment_licenses(sources).uniq - f.puts(licenses.join("\n#{LICENSE_SEPARATOR}\n")) - end - - license_path - end - - # Returns a list of unique licenses parsed from source comments - def source_comment_licenses(sources) - comments = sources.select { |s| File.file?(s) }.flat_map do |source| - content = File.read(source) - content.scan(HEADER_LICENSE_REGEX).map { |match| match[0] } - end - - comments.map do |comment| - # strip leading "*" and whitespace - indent = nil - comment.lines.map do |line| - # find the length of the indent as the number of characters - # until the first word character - indent ||= line[/\A([^\w]*)\w/, 1]&.size - - # insert newline for each line until a word character is found - next "\n" unless indent - - line[/([^\w\r\n]{0,#{indent}})(.*)/m, 2] - end.join - end + line[/([^\w\r\n]{0,#{indent}})(.*)/m, 2] + end.join end end end diff --git a/test/fixtures/manifest/manifest.yml b/test/fixtures/manifest/manifest.yml index 6626d8e..36ae7dd 100644 --- a/test/fixtures/manifest/manifest.yml +++ b/test/fixtures/manifest/manifest.yml @@ -1,6 +1,5 @@ test/fixtures/manifest/test_1.c: manifest_test test/fixtures/manifest/subfolder/test_2.c: manifest_test -script/console: other test/fixtures/manifest/single_license_header/source.c: bsd3_single_header_license test/fixtures/manifest/single_license_header/source_2.c: bsd3_single_header_license test/fixtures/manifest/multiple_license_headers/source.c: bsd3_multi_header_license diff --git a/test/fixtures/manifest/with_notices/COPYING b/test/fixtures/manifest/with_notices/LEGAL similarity index 100% rename from test/fixtures/manifest/with_notices/COPYING rename to test/fixtures/manifest/with_notices/LEGAL diff --git a/test/sources/manifest_test.rb b/test/sources/manifest_test.rb index 34183a3..16a3b42 100644 --- a/test/sources/manifest_test.rb +++ b/test/sources/manifest_test.rb @@ -31,22 +31,10 @@ describe Licensed::Sources::Manifest do describe "dependencies" do it "includes dependencies from the manifest" do - dep = source.dependencies.detect { |d| d["name"] == "manifest_test" } + dep = source.dependencies.detect { |d| d.name == "manifest_test" } assert dep - assert_equal "manifest", dep["type"] - assert dep["version"] # version comes from git, just make sure its there - end - - describe "paths" do - it "finds the common folder path for the dependency" do - dep = source.dependencies.detect { |d| d["name"] == "manifest_test" } - assert_equal fixtures, dep.path - end - - it "uses the first source folder if there is no common path" do - dep = source.dependencies.detect { |d| d["name"] == "other" } - assert dep.path.end_with?("script") - end + assert_equal "manifest", dep.data["type"] + assert dep.data["version"] # version comes from git, just make sure its there end it "uses a license specified in the configuration if provided" do @@ -56,53 +44,39 @@ describe Licensed::Sources::Manifest do } } - dep = source.dependencies.detect { |d| d["name"] == "manifest_test" } + dep = source.dependencies.detect { |d| d.name == "manifest_test" } assert dep - dep.detect_license! - assert_equal "mit", dep["license"] + assert_equal "mit", dep.data["license"] license_path = File.join(config.root, config.dig("manifest", "licenses", "manifest_test")) - assert_equal File.read(license_path).strip, dep.text + assert_includes dep.data.licenses, File.read(license_path) end it "prefers licenses from license files" do - dep = source.dependencies.detect { |d| d["name"] == "mit_license_file" } + dep = source.dependencies.detect { |d| d.name == "mit_license_file" } assert dep - dep.detect_license! - assert_equal "mit", dep["license"] - refute_nil dep.text + assert_equal "mit", dep.data["license"] + refute_empty dep.data.licenses end it "detects license from source header comments if license files are not found" do - dep = source.dependencies.detect { |d| d["name"] == "bsd3_single_header_license" } + dep = source.dependencies.detect { |d| d.name == "bsd3_single_header_license" } assert dep - dep.detect_license! - assert_equal "bsd-3-clause", dep["license"] - refute_nil dep.text - refute dep.text.include?(Licensed::License::LICENSE_SEPARATOR) - - # verify that the license file was removed after evaluation - refute File.exist?(File.join(dep.path, "LICENSE")) + assert_equal "bsd-3-clause", dep.data["license"] + assert_equal 1, dep.data.licenses.uniq.size end it "detects unique license content from multiple headers" do - dep = source.dependencies.detect { |d| d["name"] == "bsd3_multi_header_license" } + dep = source.dependencies.detect { |d| d.name == "bsd3_multi_header_license" } assert dep - dep.detect_license! - # because there are different licenses/copyrights that need to be included - # we aren't able to specify that the actual license content is equivalent - # so we are left with "other" - assert_equal "other", dep["license"] - refute_nil dep.text - assert dep.text.include?(Licensed::License::LICENSE_SEPARATOR) + assert_equal "bsd-3-clause", dep.data["license"] + assert_equal 2, dep.data.licenses.uniq.size end it "preserves legal notices when detecting license content from comments" do - dep = source.dependencies.detect { |d| d["name"] == "notices" } + dep = source.dependencies.detect { |d| d.name == "notices" } assert dep - dep.detect_license! - refute_nil dep.text - assert dep.text.include?(dep.notices.join("\n#{Licensed::License::TEXT_SEPARATOR}\n").strip) + refute_empty dep.data.notices end end