From f7dd352b883b1f52268561e6a94ac3908cc9d7e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20M=C3=BCller?= Date: Tue, 14 Apr 2020 21:23:04 +0200 Subject: [PATCH] Add project name and version to API docs (#8792) Introduces Crystal::Docs::ProjectInfo as container for doc generator configuration --- Makefile | 3 +- man/crystal.1 | 6 + .../crystal/tools/doc/project_info_spec.cr | 204 ++++++++++++++++++ src/compiler/crystal/command/docs.cr | 25 ++- src/compiler/crystal/tools/doc/generator.cr | 10 +- .../crystal/tools/doc/html/_sidebar.html | 12 ++ .../crystal/tools/doc/html/css/style.css | 20 +- .../crystal/tools/doc/html/js/_search.js | 2 + src/compiler/crystal/tools/doc/html/main.html | 6 +- src/compiler/crystal/tools/doc/html/type.html | 6 +- .../crystal/tools/doc/project_info.cr | 99 +++++++++ src/compiler/crystal/tools/doc/templates.cr | 6 +- src/compiler/crystal/tools/doc/type.cr | 6 +- 13 files changed, 384 insertions(+), 21 deletions(-) create mode 100644 spec/compiler/crystal/tools/doc/project_info_spec.cr create mode 100644 src/compiler/crystal/tools/doc/project_info.cr diff --git a/Makefile b/Makefile index c86ee5a9d4f7..607dc23c3137 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,7 @@ LIB_CRYSTAL_TARGET = src/ext/libcrystal.a DEPS = $(LLVM_EXT_OBJ) $(LIB_CRYSTAL_TARGET) CFLAGS += -fPIC $(if $(debug),-g -O0) CXXFLAGS += $(if $(debug),-g -O0) +CRYSTAL_VERSION ?= $(shell cat src/VERSION) ifeq ($(shell command -v ld.lld >/dev/null && uname -s),Linux) EXPORT_CC ?= CC="cc -fuse-ld=lld" @@ -90,7 +91,7 @@ compiler_spec: $(O)/compiler_spec ## Run compiler specs .PHONY: docs docs: ## Generate standard library documentation - $(BUILD_PATH) ./bin/crystal docs src/docs_main.cr $(DOCS_OPTIONS) + $(BUILD_PATH) ./bin/crystal docs src/docs_main.cr $(DOCS_OPTIONS) --project-name=Crystal --project-version=$(CRYSTAL_VERSION) .PHONY: crystal crystal: $(O)/crystal ## Build the compiler diff --git a/man/crystal.1 b/man/crystal.1 index 6a8da6203d61..70b7bb5a9e8c 100644 --- a/man/crystal.1 +++ b/man/crystal.1 @@ -174,6 +174,12 @@ Generate documentation from comments using a subset of markdown. The output is s Options: .Bl -tag -width "12345678" -compact .Pp +.It Fl -project-name Ar NAME +Set the project name. The default value is extracted from shard.yml if available. +In case no default can be found, this option is mandatory. +.It Fl -project-version Ar VERSION +Set the project version. The default value is extracted from current git commit or shard.yml if available. +In case no default can be found, this option is mandatory. .It Fl o Ar DIR, Fl -output Ar DIR Set the output directory (default: ./docs). .It Fl b Ar URL, Fl -sitemap-base-url Ar URL diff --git a/spec/compiler/crystal/tools/doc/project_info_spec.cr b/spec/compiler/crystal/tools/doc/project_info_spec.cr new file mode 100644 index 000000000000..40086e1e583b --- /dev/null +++ b/spec/compiler/crystal/tools/doc/project_info_spec.cr @@ -0,0 +1,204 @@ +require "../../../../spec_helper" +require "../../../../support/tempfile" + +private alias ProjectInfo = Crystal::Doc::ProjectInfo + +private def run_git(command) + Process.run(%(git -c user.email="" -c user.name="spec" #{command}), shell: true) +end + +private def assert_with_defaults(initial, expected, *, file = __FILE__, line = __LINE__) + initial.fill_with_defaults + initial.should eq(expected), file: file, line: line +end + +describe Crystal::Doc::ProjectInfo do + around_each do |example| + with_tempfile("docs-project") do |tempdir| + Dir.mkdir tempdir + Dir.cd(tempdir) do + example.run + end + end + end + + describe ".new_with_defaults" do + it "empty folder" do + assert_with_defaults(ProjectInfo.new(nil, nil), ProjectInfo.new(nil, nil)) + assert_with_defaults(ProjectInfo.new("foo", "1.0"), ProjectInfo.new("foo", "1.0")) + end + + context "with shard.yml" do + before_each do + File.write("shard.yml", "name: foo\nversion: 1.0") + end + + it "no git" do + assert_with_defaults(ProjectInfo.new(nil, nil), ProjectInfo.new("foo", "1.0")) + assert_with_defaults(ProjectInfo.new("bar", "2.0"), ProjectInfo.new("bar", "2.0")) + assert_with_defaults(ProjectInfo.new(nil, "2.0"), ProjectInfo.new("foo", "2.0")) + end + + it "git but no commit" do + run_git "init" + + assert_with_defaults(ProjectInfo.new(nil, nil), ProjectInfo.new("foo", nil)) + assert_with_defaults(ProjectInfo.new("bar", "2.0"), ProjectInfo.new("bar", "2.0")) + assert_with_defaults(ProjectInfo.new(nil, "2.0"), ProjectInfo.new("foo", "2.0")) + end + + it "git tagged version" do + run_git "init" + run_git "add shard.yml" + run_git "commit -m 'Initial commit' --no-gpg-sign" + run_git "tag v3.0" + + assert_with_defaults(ProjectInfo.new(nil, nil), ProjectInfo.new("foo", "3.0")) + assert_with_defaults(ProjectInfo.new("bar", "2.0"), ProjectInfo.new("bar", "2.0")) + end + + it "git tagged version dirty" do + run_git "init" + run_git "add shard.yml" + run_git "commit -m 'Initial commit' --no-gpg-sign" + run_git "tag v3.0" + File.write("foo.txt", "bar") + + assert_with_defaults(ProjectInfo.new(nil, nil), ProjectInfo.new("foo", "3.0-dev")) + assert_with_defaults(ProjectInfo.new(nil, "1.1"), ProjectInfo.new("foo", "1.1")) + assert_with_defaults(ProjectInfo.new("bar", "2.0"), ProjectInfo.new("bar", "2.0")) + end + + it "git non-tagged commit" do + run_git "init" + run_git "add shard.yml" + run_git "commit -m 'Initial commit' --no-gpg-sign" + + assert_with_defaults(ProjectInfo.new(nil, nil), ProjectInfo.new("foo", "master")) + assert_with_defaults(ProjectInfo.new(nil, "1.1"), ProjectInfo.new("foo", "1.1")) + assert_with_defaults(ProjectInfo.new("bar", "2.0"), ProjectInfo.new("bar", "2.0")) + end + + it "git non-tagged commit dirty" do + run_git "init" + run_git "add shard.yml" + run_git "commit -m 'Initial commit' --no-gpg-sign" + File.write("foo.txt", "bar") + + assert_with_defaults(ProjectInfo.new(nil, nil), ProjectInfo.new("foo", "master-dev")) + assert_with_defaults(ProjectInfo.new(nil, "1.1"), ProjectInfo.new("foo", "1.1")) + assert_with_defaults(ProjectInfo.new("bar", "2.0"), ProjectInfo.new("bar", "2.0")) + end + end + + it "no shard.yml, but git tagged version" do + File.write("foo.txt", "bar") + run_git "init" + run_git "add foo.txt" + run_git "commit -m 'Remove shard.yml' --no-gpg-sign" + run_git "tag v4.0" + + assert_with_defaults(ProjectInfo.new(nil, nil), ProjectInfo.new(nil, "4.0")) + assert_with_defaults(ProjectInfo.new("foo", nil), ProjectInfo.new("foo", "4.0")) + assert_with_defaults(ProjectInfo.new("bar", "2.0"), ProjectInfo.new("bar", "2.0")) + end + end + + it ".find_git_version" do + # Non-git directory + ProjectInfo.find_git_version.should be_nil + + # Empty git directory + run_git "init" + ProjectInfo.find_git_version.should be_nil + + # Non-tagged commit + File.write("file.txt", "foo") + run_git "add file.txt" + run_git "commit -m 'Initial commit' --no-gpg-sign" + ProjectInfo.find_git_version.should eq "master" + + # Non-tagged commit, dirty workdir + File.write("file.txt", "bar") + ProjectInfo.find_git_version.should eq "master-dev" + + run_git "checkout -- ." + + # Tagged commit + run_git "tag v0.1.0" + ProjectInfo.find_git_version.should eq "0.1.0" + + # Tagged commit, dirty workdir + File.write("file.txt", "bar") + ProjectInfo.find_git_version.should eq "0.1.0-dev" + + # Tagged commit, dirty index + run_git "add file.txt" + ProjectInfo.find_git_version.should eq "0.1.0-dev" + + run_git "reset --hard v0.1.0" + ProjectInfo.find_git_version.should eq "0.1.0" + + # Multiple tags + run_git "tag v0.2.0" + ProjectInfo.find_git_version.should eq "master" + + # Other branch + run_git "checkout -b foo" + ProjectInfo.find_git_version.should eq "foo" + end + + describe ".read_shard_properties" do + it "no shard.yml" do + ProjectInfo.read_shard_properties.should eq({nil, nil}) + end + + it "without name and version properties" do + File.write("shard.yml", "foo: bar\n") + ProjectInfo.read_shard_properties.should eq({nil, nil}) + end + + it "empty properties" do + File.write("shard.yml", "name: \nversion: ") + ProjectInfo.read_shard_properties.should eq({nil, nil}) + end + + it "indented properties" do + File.write("shard.yml", " name: bar\n version: 1.0") + ProjectInfo.read_shard_properties.should eq({nil, nil}) + end + + it "only name" do + File.write("shard.yml", "name: bar\n") + ProjectInfo.read_shard_properties.should eq({"bar", nil}) + end + + it "name and version" do + File.write("shard.yml", "name: bar\nversion: 1.0") + ProjectInfo.read_shard_properties.should eq({"bar", "1.0"}) + end + + it "duplicate properties uses first one" do + File.write("shard.yml", "name: bar\nversion: 1.0\nname: foo\nversion: foo") + ProjectInfo.read_shard_properties.should eq({"bar", "1.0"}) + end + + it "strip whitespace" do + File.write("shard.yml", "name: bar \nversion: 1.0 ") + ProjectInfo.read_shard_properties.should eq({"bar", "1.0"}) + end + + it "strip quotes" do + File.write("shard.yml", "name: 'bar'\nversion: '1.0'") + ProjectInfo.read_shard_properties.should eq({"bar", "1.0"}) + end + + it "ignores comments" do + File.write("shard.yml", "name: bar # comment\nversion: 1.0 # comment") + ProjectInfo.read_shard_properties.should eq({"bar", "1.0"}) + + File.write("shard.yml", "name: # comment\nversion: # comment") + ProjectInfo.read_shard_properties.should eq({nil, nil}) + end + end +end diff --git a/src/compiler/crystal/command/docs.cr b/src/compiler/crystal/command/docs.cr index 2b2f89f11b5d..b24955e54d7f 100644 --- a/src/compiler/crystal/command/docs.cr +++ b/src/compiler/crystal/command/docs.cr @@ -12,6 +12,7 @@ class Crystal::Command sitemap_base_url = nil sitemap_priority = "1.0" sitemap_changefreq = "never" + project_info = Doc::ProjectInfo.new compiler = Compiler.new @@ -24,6 +25,14 @@ class Crystal::Command Options: BANNER + opts.on("--project-name=NAME", "Set project name") do |value| + project_info.name = value + end + + opts.on("--project-version=VERSION", "Set project version") do |value| + project_info.version = value + end + opts.on("--output=DIR", "-o DIR", "Set the output directory (default: #{output_directory})") do |value| output_directory = value end @@ -88,6 +97,20 @@ class Crystal::Command setup_compiler_warning_options(opts, compiler) end + project_info.fill_with_defaults + + unless project_info.name? + STDERR.puts "Couldn't determine name from shard.yml, please provide --project-name option" + end + + unless project_info.version? + STDERR.puts "Couldn't determine version from git or shard.yml, please provide --project-version option" + end + + unless project_info.name? && project_info.version? + abort + end + if options.empty? sources = [Compiler::Source.new("require", %(require "./src/**"))] included_dirs = [] of String @@ -103,7 +126,7 @@ class Crystal::Command compiler.wants_doc = true result = compiler.top_level_semantic sources - Doc::Generator.new(result.program, included_dirs, output_directory, output_format, sitemap_base_url, sitemap_priority, sitemap_changefreq).run + Doc::Generator.new(result.program, included_dirs, output_directory, output_format, sitemap_base_url, sitemap_priority, sitemap_changefreq, project_info).run report_warnings result exit 1 if warnings_fail_on_exit?(result) diff --git a/src/compiler/crystal/tools/doc/generator.cr b/src/compiler/crystal/tools/doc/generator.cr index 34ec7ba7d98e..e402a624d3c9 100644 --- a/src/compiler/crystal/tools/doc/generator.cr +++ b/src/compiler/crystal/tools/doc/generator.cr @@ -5,6 +5,7 @@ class Crystal::Doc::Generator property is_crystal_repo : Bool @repository : String? = nil getter repository_name = "" + getter project_info # Adding a flag and associated css class will add support in parser FLAG_COLORS = { @@ -29,13 +30,14 @@ class Crystal::Doc::Generator } def self.new(program : Program, included_dirs : Array(String)) - new(program, included_dirs, ".", "html", nil, "1.0", "never") + new(program, included_dirs, ".", "html", nil, "1.0", "never", ProjectInfo.new("test", "0.0.0-test")) end def initialize(@program : Program, @included_dirs : Array(String), @output_dir : String, @output_format : String, @sitemap_base_url : String?, - @sitemap_priority : String, @sitemap_changefreq : String) + @sitemap_priority : String, @sitemap_changefreq : String, + @project_info : ProjectInfo) @base_dir = Dir.current.chomp @types = {} of Crystal::Type => Doc::Type @repo_name = "" @@ -95,7 +97,7 @@ class Crystal::Doc::Generator raw_body = read_readme body = doc(program_type, raw_body) - File.write File.join(@output_dir, "index.html"), MainTemplate.new(body, types, repository_name) + File.write File.join(@output_dir, "index.html"), MainTemplate.new(body, types, project_info) main_index = Main.new(raw_body, Type.new(self, @program), repository_name) File.write File.join(@output_dir, "index.json"), main_index @@ -124,7 +126,7 @@ class Crystal::Doc::Generator filename = File.join(dir, "#{type.name}.html") end - File.write filename, TypeTemplate.new(type, all_types) + File.write filename, TypeTemplate.new(type, all_types, project_info) next if type.program? diff --git a/src/compiler/crystal/tools/doc/html/_sidebar.html b/src/compiler/crystal/tools/doc/html/_sidebar.html index bd4005b51978..b255f19856ac 100644 --- a/src/compiler/crystal/tools/doc/html/_sidebar.html +++ b/src/compiler/crystal/tools/doc/html/_sidebar.html @@ -4,6 +4,18 @@ +
+

+ index.html"> + <%= project_info.name %> + +

+ + + <%= project_info.version %> + +
+ diff --git a/src/compiler/crystal/tools/doc/html/css/style.css b/src/compiler/crystal/tools/doc/html/css/style.css index a63baf55e603..e86a43d2c94a 100644 --- a/src/compiler/crystal/tools/doc/html/css/style.css +++ b/src/compiler/crystal/tools/doc/html/css/style.css @@ -59,7 +59,7 @@ body { } .sidebar .search-box { - padding: 8px 9px; + padding: 13px 9px; } .sidebar input { @@ -106,6 +106,24 @@ body { text-indent: 2px; } +.project-summary { + display: inline-block; + padding: 9px 15px 30px 30px; + text-align: right; +} + +.project-name { + font-size: 1.4rem; + margin: 0; + color: #f4f4f4; + font-weight: 600; +} + +.project-version { + margin-top: 5px; + display: block; +} + .sidebar ul { margin: 0; padding: 0; diff --git a/src/compiler/crystal/tools/doc/html/js/_search.js b/src/compiler/crystal/tools/doc/html/js/_search.js index 84fffa4b4d89..193220d500a7 100644 --- a/src/compiler/crystal/tools/doc/html/js/_search.js +++ b/src/compiler/crystal/tools/doc/html/js/_search.js @@ -353,9 +353,11 @@ CrystalDoc.displaySearchResults = function(results, query) { CrystalDoc.toggleResultsList = function(visible) { if (visible) { document.querySelector(".types-list").classList.add("hidden"); + document.querySelector(".repository-links").classList.add("hidden"); document.querySelector(".search-results").classList.remove("hidden"); } else { document.querySelector(".types-list").classList.remove("hidden"); + document.querySelector(".repository-links").classList.remove("hidden"); document.querySelector(".search-results").classList.add("hidden"); } }; diff --git a/src/compiler/crystal/tools/doc/html/main.html b/src/compiler/crystal/tools/doc/html/main.html index 852e89799a2e..1ebf6a935fc5 100644 --- a/src/compiler/crystal/tools/doc/html/main.html +++ b/src/compiler/crystal/tools/doc/html/main.html @@ -2,15 +2,15 @@ <%= HeadTemplate.new("") %> - - README - <%= repository_name %> + + <%= project_info.name %> <%= project_info.version %> -<%= SidebarTemplate.new(repository_name, types, nil) %> +<%= SidebarTemplate.new(project_info, types, nil) %>
<%= body %> diff --git a/src/compiler/crystal/tools/doc/html/type.html b/src/compiler/crystal/tools/doc/html/type.html index 5322d88df5fc..690df9a90872 100644 --- a/src/compiler/crystal/tools/doc/html/type.html +++ b/src/compiler/crystal/tools/doc/html/type.html @@ -2,15 +2,15 @@ <%= HeadTemplate.new(type.path_to "") %> - - <%= type.full_name %> - <%= type.repository_name %> + + <%= type.full_name %> - <%= project_info.name %> <%= project_info.version %> -<%= SidebarTemplate.new(type.repository_name, types, type) %> +<%= SidebarTemplate.new(project_info, types, type) %>

diff --git a/src/compiler/crystal/tools/doc/project_info.cr b/src/compiler/crystal/tools/doc/project_info.cr new file mode 100644 index 000000000000..781bb799350e --- /dev/null +++ b/src/compiler/crystal/tools/doc/project_info.cr @@ -0,0 +1,99 @@ +module Crystal::Doc + class ProjectInfo + property! name : String + property! version : String + + def initialize(@name : String? = nil, @version : String? = nil) + end + + def_equals_and_hash @name, @version + + def fill_with_defaults + unless version? + if git_version = ProjectInfo.find_git_version + self.version = git_version + end + end + + unless name? && version? + shard_name, shard_version = ProjectInfo.read_shard_properties + if shard_name && !name? + self.name = shard_name + end + if shard_version && !version? && !ProjectInfo.git_dir? + self.version = shard_version + end + end + end + + def self.git_dir? + Process.run("git", ["rev-parse", "--is-inside-work-tree"]).success? + end + + def self.find_git_version + if ref = git_ref + case git_clean? + when Nil + when true + ref + when false + "#{ref}-dev" + end + end + end + + def self.git_clean? + # Use git to determine if index and working directory are clean + io = IO::Memory.new + status = Process.run("git", ["status", "--porcelain"], output: io) + # If clean, output of `git status --porcelain` is empty. Still need to check + # the status code, to make sure empty doesn't mean error. + return unless status.success? + io.rewind + io.bytesize == 0 + end + + def self.git_ref + io = IO::Memory.new + # Check if current HEAD is tagged + status = Process.run("git", ["tag", "--points-at", "HEAD"], output: io) + return unless status.success? + io.rewind + tags = io.to_s.lines + versions = tags.select(&.starts_with?("v")) + # Only accept when there's exactly one version tag pointing at HEAD. + if versions.size == 1 + return versions.first.byte_slice(1) + end + + # Otherwise, return current branch name + io.clear + status = Process.run("git", ["rev-parse", "--abbrev-ref", "HEAD"], output: io) + return unless status.success? + + io.to_s.strip.presence + end + + def self.read_shard_properties + return {nil, nil} unless File.readable?("shard.yml") + + name = nil + version = nil + + # Poor man's YAML reader + File.each_line("shard.yml") do |line| + if name.nil? && line.starts_with?("name:") + end_pos = line.byte_index("#") || line.bytesize + name = line.byte_slice(5, end_pos - 5).strip.strip(%("')) + elsif version.nil? && line.starts_with?("version:") + end_pos = line.byte_index("#") || line.bytesize + version = line.byte_slice(8, end_pos - 8).strip.strip(%("')) + elsif version && name + break + end + end + + return name.presence, version.presence + end + end +end diff --git a/src/compiler/crystal/tools/doc/templates.cr b/src/compiler/crystal/tools/doc/templates.cr index 92500b61916a..a757f6f1deaf 100644 --- a/src/compiler/crystal/tools/doc/templates.cr +++ b/src/compiler/crystal/tools/doc/templates.cr @@ -21,7 +21,7 @@ module Crystal::Doc ANCHOR end - record TypeTemplate, type : Type, types : Array(Type) do + record TypeTemplate, type : Type, types : Array(Type), project_info : ProjectInfo do ECR.def_to_s "#{__DIR__}/html/type.html" end @@ -45,7 +45,7 @@ module Crystal::Doc ECR.def_to_s "#{__DIR__}/html/_other_types.html" end - record MainTemplate, body : String, types : Array(Type), repository_name : String do + record MainTemplate, body : String, types : Array(Type), project_info : ProjectInfo do ECR.def_to_s "#{__DIR__}/html/main.html" end @@ -53,7 +53,7 @@ module Crystal::Doc ECR.def_to_s "#{__DIR__}/html/_head.html" end - record SidebarTemplate, repository_name : String, types : Array(Type), current_type : Type? do + record SidebarTemplate, project_info : ProjectInfo, types : Array(Type), current_type : Type? do ECR.def_to_s "#{__DIR__}/html/_sidebar.html" end diff --git a/src/compiler/crystal/tools/doc/type.cr b/src/compiler/crystal/tools/doc/type.cr index ed24cdd91716..faeac5d7a944 100644 --- a/src/compiler/crystal/tools/doc/type.cr +++ b/src/compiler/crystal/tools/doc/type.cr @@ -114,10 +114,6 @@ class Crystal::Doc::Type @generator.relative_locations(@type) end - def repository_name - @generator.repository_name - end - def program? @type.is_a?(Program) end @@ -790,7 +786,7 @@ class Crystal::Doc::Type end end builder.field "locations", locations - builder.field "repository_name", repository_name + builder.field "repository_name", @generator.repository_name builder.field "program", program? builder.field "enum", enum? builder.field "alias", alias?