diff --git a/.gitignore b/.gitignore index 1a2bf195c..c7b87c7b8 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ ex_doc-*.tar node_modules/ /test/fixtures/umbrella/_build/ +/test/fixtures/single/_build/ +/test/fixtures/single/doc/ /test/tmp/ /tmp/ /npm-debug.log diff --git a/lib/ex_doc/cli.ex b/lib/ex_doc/cli.ex index 27274e4f9..8dd808dcf 100644 --- a/lib/ex_doc/cli.ex +++ b/lib/ex_doc/cli.ex @@ -35,14 +35,37 @@ defmodule ExDoc.CLI do quiet: :boolean, source_ref: :string, source_url: :string, - version: :boolean + version: :boolean, + warnings_as_errors: :boolean ] ) if List.keymember?(opts, :version, 0) do IO.puts("ExDoc v#{ExDoc.version()}") else - generate(args, opts, generator) + results = generate(args, opts, generator) + error_results = Enum.filter(results, &(elem(&1, 0) == :error)) + + if error_results == [] do + Enum.map(results, fn {:ok, value} -> value end) + else + formatters = Enum.map(error_results, &elem(&1, 1).formatter) + + format_message = + case formatters do + [formatter] -> "#{formatter} format" + _ -> "#{Enum.join(formatters, ", ")} formats" + end + + message = + "Documents have been generated, but generation for #{format_message} failed due to warnings while using the --warnings-as-errors option." + + message_formatted = IO.ANSI.format([:red, message, :reset]) + + IO.puts(:stderr, message_formatted) + + exit({:shutdown, 1}) + end end end @@ -71,7 +94,11 @@ defmodule ExDoc.CLI do quiet? || IO.puts(IO.ANSI.format([:green, "View #{inspect(formatter)} docs at #{inspect(index)}"])) - index + if opts[:warnings_as_errors] == true and ExDoc.Utils.warned?() do + {:error, %{reason: :warnings_as_errors, formatter: formatter}} + else + {:ok, index} + end end end @@ -164,29 +191,30 @@ defmodule ExDoc.CLI do ex_doc "Project" "1.0.0" "_build/dev/lib/project/ebin" -c "docs.exs" Options: - PROJECT Project name - VERSION Version number - BEAMS Path to compiled beam files - --canonical Indicate the preferred URL with rel="canonical" link element - -c, --config Give configuration through a file instead of a command line. - See "Custom config" section below for more information. - -f, --formatter Docs formatter to use (html or epub), default: html and epub - --homepage-url URL to link to for the site name - --language Identify the primary language of the documents, its value must be - a valid [BCP 47](https://tools.ietf.org/html/bcp47) language tag, default: "en" - -l, --logo Path to a logo image for the project. Must be PNG, JPEG or SVG. The image will - be placed in the output "assets" directory. - -m, --main The entry-point page in docs, default: "api-reference" - -o, --output Path to output docs, default: "doc" - --package Hex package name - --paths Prepends the given path to Erlang code path. The path might contain a glob - pattern but in that case, remember to quote it: --paths "_build/dev/lib/*/ebin". - This option can be given multiple times - --proglang The project's programming language, default: "elixir" - -q, --quiet Only output warnings and errors - --source-ref Branch/commit/tag used for source link inference, default: "master" - -u, --source-url URL to the source code - -v, --version Print ExDoc version + PROJECT Project name + VERSION Version number + BEAMS Path to compiled beam files + --canonical Indicate the preferred URL with rel="canonical" link element + -c, --config Give configuration through a file instead of a command line. + See "Custom config" section below for more information. + -f, --formatter Docs formatter to use (html or epub), default: html and epub + --homepage-url URL to link to for the site name + --language Identify the primary language of the documents, its value must be + a valid [BCP 47](https://tools.ietf.org/html/bcp47) language tag, default: "en" + -l, --logo Path to a logo image for the project. Must be PNG, JPEG or SVG. The image will + be placed in the output "assets" directory. + -m, --main The entry-point page in docs, default: "api-reference" + -o, --output Path to output docs, default: "doc" + --package Hex package name + --paths Prepends the given path to Erlang code path. The path might contain a glob + pattern but in that case, remember to quote it: --paths "_build/dev/lib/*/ebin". + This option can be given multiple times. + --proglang The project's programming language, default: "elixir". + -q, --quiet Only output warnings and errors. + --source-ref Branch/commit/tag used for source link inference, default: "master". + -u, --source-url URL to the source code. + -v, --version Print ExDoc version. + --warnings-as-errors Exit with non-zero status if doc generation produces warnings. ## Custom config diff --git a/lib/ex_doc/config.ex b/lib/ex_doc/config.ex index 1646d8b38..670c14b75 100644 --- a/lib/ex_doc/config.ex +++ b/lib/ex_doc/config.ex @@ -49,7 +49,8 @@ defmodule ExDoc.Config do source_url: nil, source_url_pattern: nil, title: nil, - version: nil + version: nil, + warnings_as_errors: false @type t :: %__MODULE__{ annotations_for_docs: (map() -> list()), @@ -88,7 +89,8 @@ defmodule ExDoc.Config do source_url: nil | String.t(), source_url_pattern: nil | String.t(), title: nil | String.t(), - version: nil | String.t() + version: nil | String.t(), + warnings_as_errors: boolean() } @spec build(String.t(), String.t(), Keyword.t()) :: ExDoc.Config.t() diff --git a/lib/ex_doc/formatter/epub.ex b/lib/ex_doc/formatter/epub.ex index 94281d99e..2e8ed2a5d 100644 --- a/lib/ex_doc/formatter/epub.ex +++ b/lib/ex_doc/formatter/epub.ex @@ -5,12 +5,15 @@ defmodule ExDoc.Formatter.EPUB do @assets_dir "OEBPS/assets" alias __MODULE__.{Assets, Templates} alias ExDoc.Formatter.HTML + alias ExDoc.Utils @doc """ - Generate EPUB documentation for the given modules. + Generates EPUB documentation for the given modules. """ @spec run([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], ExDoc.Config.t()) :: String.t() def run(project_nodes, filtered_modules, config) when is_map(config) do + Utils.unset_warned() + config = normalize_config(config) File.rm_rf!(config.output) File.mkdir_p!(Path.join(config.output, "OEBPS")) @@ -66,7 +69,7 @@ defmodule ExDoc.Formatter.EPUB do html = Templates.extra_template(config, title, title_content, content) if File.regular?(output) do - ExDoc.Utils.warn("file #{Path.relative_to_cwd(output)} already exists", []) + Utils.warn("file #{Path.relative_to_cwd(output)} already exists", []) end File.write!(output, html) diff --git a/lib/ex_doc/formatter/html.ex b/lib/ex_doc/formatter/html.ex index e377d10fd..1bbaacad2 100644 --- a/lib/ex_doc/formatter/html.ex +++ b/lib/ex_doc/formatter/html.ex @@ -8,10 +8,12 @@ defmodule ExDoc.Formatter.HTML do @assets_dir "assets" @doc """ - Generate HTML documentation for the given modules. + Generates HTML documentation for the given modules. """ @spec run([ExDoc.ModuleNode.t()], [ExDoc.ModuleNode.t()], ExDoc.Config.t()) :: String.t() def run(project_nodes, filtered_modules, config) when is_map(config) do + Utils.unset_warned() + config = normalize_config(config) config = %{config | output: Path.expand(config.output)} @@ -528,7 +530,7 @@ defmodule ExDoc.Formatter.HTML do defp generate_redirect(filename, config, redirect_to) do unless case_sensitive_file_regular?("#{config.output}/#{redirect_to}") do - ExDoc.Utils.warn("#{filename} redirects to #{redirect_to}, which does not exist", []) + Utils.warn("#{filename} redirects to #{redirect_to}, which does not exist", []) end content = Templates.redirect_template(config, redirect_to) diff --git a/lib/ex_doc/utils.ex b/lib/ex_doc/utils.ex index 7dcea9a73..27dadc849 100644 --- a/lib/ex_doc/utils.ex +++ b/lib/ex_doc/utils.ex @@ -1,19 +1,49 @@ defmodule ExDoc.Utils do @moduledoc false + @elixir_gte_1_14? Version.match?(System.version(), ">= 1.14.0") + @doc """ Emits a warning. """ - if Version.match?(System.version(), ">= 1.14.0") do - def warn(message, stacktrace_info) do + def warn(message, stacktrace_info) do + set_warned() + + # TODO: remove check when we require Elixir v1.14 + if @elixir_gte_1_14? do IO.warn(message, stacktrace_info) - end - else - def warn(message, _stacktrace_info) do + else IO.warn(message, []) end end + @doc """ + Stores that a warning has been generated. + """ + def set_warned() do + unless warned?() do + :persistent_term.put({__MODULE__, :warned?}, true) + end + + true + end + + @doc """ + Removes that a warning has been generated. + """ + def unset_warned() do + if warned?() do + :persistent_term.put({__MODULE__, :warned?}, false) + end + end + + @doc """ + Returns `true` if any warning has been generated during the document building. Otherwise returns `false`. + """ + def warned?() do + :persistent_term.get({__MODULE__, :warned?}, false) + end + @doc """ Runs the `before_closing_head_tag` callback. """ diff --git a/lib/mix/tasks/docs.ex b/lib/mix/tasks/docs.ex index f202e7171..a52e44504 100644 --- a/lib/mix/tasks/docs.ex +++ b/lib/mix/tasks/docs.ex @@ -27,6 +27,8 @@ defmodule Mix.Tasks.Docs do * `--proglang` - Chooses the main programming language: `elixir` or `erlang` + * `--warnings-as-errors` - Exits with non-zero exit code if any warnings are found + The command line options have higher precedence than the options specified in your `mix.exs` file below. @@ -325,7 +327,8 @@ defmodule Mix.Tasks.Docs do language: :string, open: :boolean, output: :string, - proglang: :string + proglang: :string, + warnings_as_errors: :boolean ] @aliases [ @@ -383,17 +386,52 @@ defmodule Mix.Tasks.Docs do |> normalize_formatters() |> put_package(config) + Code.prepend_path(options[:source_beam]) + + for path <- Keyword.get_values(options, :paths), + path <- Path.wildcard(path) do + Code.prepend_path(path) + end + Mix.shell().info("Generating docs...") - for formatter <- options[:formatters] do - index = generator.(project, version, Keyword.put(options, :formatter, formatter)) - Mix.shell().info([:green, "View #{inspect(formatter)} docs at #{inspect(index)}"]) + results = + for formatter <- options[:formatters] do + index = generator.(project, version, Keyword.put(options, :formatter, formatter)) + Mix.shell().info([:green, "View #{inspect(formatter)} docs at #{inspect(index)}"]) - if cli_opts[:open] do - browser_open(index) + if cli_opts[:open] do + browser_open(index) + end + + if options[:warnings_as_errors] == true and ExDoc.Utils.warned?() do + {:error, %{reason: :warnings_as_errors, formatter: formatter}} + else + {:ok, index} + end end - index + error_results = Enum.filter(results, &(elem(&1, 0) == :error)) + + if error_results == [] do + Enum.map(results, fn {:ok, value} -> value end) + else + formatters = Enum.map(error_results, &elem(&1, 1).formatter) + + format_message = + case formatters do + [formatter] -> "#{formatter} format" + _ -> "#{Enum.join(formatters, ", ")} formats" + end + + message = + "Documents have been generated, but generation for #{format_message} failed due to warnings while using the --warnings-as-errors option." + + message_formatted = IO.ANSI.format([:red, message, :reset]) + + IO.puts(:stderr, message_formatted) + + exit({:shutdown, 1}) end end diff --git a/test/ex_doc/formatter/epub_test.exs b/test/ex_doc/formatter/epub_test.exs index 3f6c6e81d..968efa02d 100644 --- a/test/ex_doc/formatter/epub_test.exs +++ b/test/ex_doc/formatter/epub_test.exs @@ -1,5 +1,9 @@ defmodule ExDoc.Formatter.EPUBTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false + + import ExUnit.CaptureIO + + alias ExDoc.Utils @moduletag :tmp_dir @@ -237,4 +241,47 @@ defmodule ExDoc.Formatter.EPUBTest do after File.rm_rf!("test/tmp/epub_assets") end + + describe "warnings" do + @describetag :warnings + + test "multiple warnings are registered when using warnings_as_errors: true", context do + Utils.unset_warned() + + output = + capture_io(:stderr, fn -> + generate_docs( + doc_config(context, + skip_undefined_reference_warnings_on: [], + warnings_as_errors: true + ) + ) + end) + + # TODO: remove check when we require Elixir v1.16 + if Version.match?(System.version(), ">= 1.16.0-rc") do + assert output =~ ~S|moduledoc `Warnings.bar/0`| + assert output =~ ~S|typedoc `Warnings.bar/0`| + assert output =~ ~S|doc callback `Warnings.bar/0`| + assert output =~ ~S|doc `Warnings.bar/0`| + end + + assert Utils.warned?() == true + end + + test "warnings are registered even with warnings_as_errors: false", context do + Utils.unset_warned() + + capture_io(:stderr, fn -> + generate_docs( + doc_config(context, + skip_undefined_reference_warnings_on: [], + warnings_as_errors: false + ) + ) + end) + + assert Utils.warned?() == true + end + end end diff --git a/test/ex_doc/formatter/html_test.exs b/test/ex_doc/formatter/html_test.exs index 213112cd9..e416b8935 100644 --- a/test/ex_doc/formatter/html_test.exs +++ b/test/ex_doc/formatter/html_test.exs @@ -1,8 +1,10 @@ defmodule ExDoc.Formatter.HTMLTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false import ExUnit.CaptureIO + alias ExDoc.Utils + @moduletag :tmp_dir defp read_wildcard!(path) do @@ -117,7 +119,9 @@ defmodule ExDoc.Formatter.HTMLTest do generate_docs(doc_config(context, main: "Randomerror")) end) - assert output =~ "index.html redirects to Randomerror.html, which does not exist\n" + assert output =~ + ~r"warning:(\e\[0m)? .*index.html redirects to Randomerror.html, which does not exist\n" + assert File.regular?(tmp_dir <> "/html/index.html") assert File.regular?(tmp_dir <> "/html/RandomError.html") end @@ -128,7 +132,8 @@ defmodule ExDoc.Formatter.HTMLTest do generate_docs(doc_config(context, skip_undefined_reference_warnings_on: [])) end) - assert out =~ ~s|documentation references function "Warnings.bar/0" but| + assert out =~ + ~s|documentation references function "Warnings.bar/0" but it is undefined or private| # TODO: remove check when we require Elixir v1.16 if Version.match?(System.version(), ">= 1.16.0-rc") do @@ -139,6 +144,63 @@ defmodule ExDoc.Formatter.HTMLTest do end end + describe "warnings" do + @describetag :warnings + + test "single warning is registered when using warnings_as_errors: true", context do + Utils.unset_warned() + + output = + capture_io(:stderr, fn -> + generate_docs(doc_config(context, main: "DoesNotExist", warnings_as_errors: true)) + end) + + assert output =~ + ~r"warning:(\e\[0m)? .*index.html redirects to DoesNotExist.html, which does not exist\n" + + assert Utils.warned?() == true + end + + test "multiple warnings are registered when using warnings_as_errors: true", context do + Utils.unset_warned() + + output = + capture_io(:stderr, fn -> + generate_docs( + doc_config(context, + skip_undefined_reference_warnings_on: [], + warnings_as_errors: true + ) + ) + end) + + # TODO: remove check when we require Elixir v1.16 + if Version.match?(System.version(), ">= 1.16.0-rc") do + assert output =~ ~S|moduledoc `Warnings.bar/0`| + assert output =~ ~S|typedoc `Warnings.bar/0`| + assert output =~ ~S|doc callback `Warnings.bar/0`| + assert output =~ ~S|doc `Warnings.bar/0`| + end + + assert Utils.warned?() == true + end + + test "warnings are registered even with warnings_as_errors: false", context do + Utils.unset_warned() + + capture_io(:stderr, fn -> + generate_docs( + doc_config(context, + skip_undefined_reference_warnings_on: [], + warnings_as_errors: false + ) + ) + end) + + assert Utils.warned?() == true + end + end + test "generates headers for index.html and module pages", %{tmp_dir: tmp_dir} = context do generate_docs(doc_config(context, main: "RandomError")) content_index = File.read!(tmp_dir <> "/html/index.html") diff --git a/test/fixtures/single/lib/single.ex b/test/fixtures/single/lib/single.ex new file mode 100644 index 000000000..65cb4059e --- /dev/null +++ b/test/fixtures/single/lib/single.ex @@ -0,0 +1,10 @@ +defmodule Single do + @moduledoc """ + moduledoc `Single.bar/0` + """ + + @doc """ + doc `Single.bar/0` + """ + def foo(), do: :foo +end diff --git a/test/fixtures/single/mix.exs b/test/fixtures/single/mix.exs new file mode 100644 index 000000000..15c2510e8 --- /dev/null +++ b/test/fixtures/single/mix.exs @@ -0,0 +1,15 @@ +defmodule Single.MixProject do + use Mix.Project + + def project do + [ + app: :single, + version: "0.1.0", + elixir: "~> 1.12", + start_permanent: Mix.env() == :prod, + deps: [] + ] + end + + def application, do: [extra_applications: [:logger]] +end diff --git a/test/mix/tasks/docs_test.exs b/test/mix/tasks/docs_test.exs index 11f9bc511..b7951bf69 100644 --- a/test/mix/tasks/docs_test.exs +++ b/test/mix/tasks/docs_test.exs @@ -5,11 +5,30 @@ defmodule Mix.Tasks.DocsTest do # Cannot run concurrently due to Mix compile/deps calls use ExUnit.Case, async: false + import ExUnit.CaptureIO + + alias ExDoc.Utils + @moduletag :tmp_dir - def run(context, args, opts) do + def run(context, args, opts, generator \\ &{&1, &2, &3}) do opts = Keyword.put_new(opts, :output, context[:tmp_dir]) - Mix.Tasks.Docs.run(args, opts, &{&1, &2, &3}) + Mix.Tasks.Docs.run(args, opts, generator) + end + + def run_with_io(context, args, opts, generator) + when is_list(args) and is_list(opts) and is_function(generator, 3) do + opts = Keyword.put_new(opts, :output, context[:tmp_dir]) + + # TODO: Use with_io on Elixir v1.13 + output = + capture_io(fn -> + send(self(), run(context, args, opts, generator)) + end) + + receive do + response -> {response, output} + end end test "inflects values from app and version", context do @@ -390,4 +409,65 @@ defmodule Mix.Tasks.DocsTest do ] = run(context, [], app: :umbrella, apps_path: "apps/", docs: [ignore_apps: [:foo]]) end) end + + test "accepts warnings_as_errors in :warnings_as_errors", context do + assert [ + {"ex_doc", "dev", + [ + formatter: "html", + formatters: ["html", "epub"], + deps: _, + apps: [:ex_doc], + source_beam: _, + warnings_as_errors: false, + proglang: :elixir + ]}, + {"ex_doc", "dev", + [ + formatter: "epub", + formatters: ["html", "epub"], + deps: _, + apps: [:ex_doc], + source_beam: _, + warnings_as_errors: false, + proglang: :elixir + ]} + ] = run(context, [], app: :ex_doc, docs: [warnings_as_errors: false]) + end + + @tag :tmp_dir + test "exits with 1 due to warnings, with flag --warnings_as_errors", context do + Utils.unset_warned() + + Mix.Project.in_project(:single, "test/fixtures/single", fn _mod -> + source_beam = "_build/test/lib/single/ebin" + + fun = fn -> + run_with_io( + context, + [], + [ + app: :single, + docs: [ + source_beam: source_beam, + warnings_as_errors: true, + formatter: "html", + deps: [] + ], + version: "0.1.0" + ], + &ExDoc.generate_docs/3 + ) + end + + io = + capture_io(:stderr, fn -> + assert catch_exit(fun.()) == {:shutdown, 1} + end) + + assert io =~ + "Documents have been generated, but generation for html format failed due to warnings " <> + "while using the --warnings-as-errors option." + end) + end end diff --git a/test/test_helper.exs b/test/test_helper.exs index a555ba53f..62e1ab099 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -11,7 +11,9 @@ ExUnit.start(exclude: Enum.filter(exclude, &elem(&1, 1))) # Prepare module fixtures File.rm_rf!("test/tmp") +File.rm_rf!("test/fixtures/single/_build/") File.mkdir_p!("test/tmp/beam") +File.mkdir_p!("test/fixtures/single/_build/test/lib/single/ebin") Code.prepend_path("test/tmp/beam") # Compile module fixtures @@ -19,6 +21,21 @@ Code.prepend_path("test/tmp/beam") |> Path.wildcard() |> Kernel.ParallelCompiler.compile_to_path("test/tmp/beam") +# Compile fixture :single app module +"test/fixtures/single/lib/*.ex" +|> Path.wildcard() +|> Kernel.ParallelCompiler.compile_to_path("test/fixtures/single/_build/test/lib/single/ebin") + +File.write!("test/fixtures/single/_build/test/lib/single/ebin/single.app", ~S""" +{application,single, + [{optional_applications,[]}, + {applications,[kernel,stdlib,elixir,logger]}, + {description,"single"}, + {modules,['Elixir.Single']}, + {registered,[]}, + {vsn,"0.1.0"}]}. +""") + defmodule TestHelper do def elixirc(context, filename \\ "nofile", code) do dir = context.tmp_dir