Repository: hrzndhrn/recode
Pull request #93 (2024-07-15 05:53:47)

Task Moduledoc

github-actions[bot]: ## Pull Request Test Coverage Report for [Build 9346fa151efc40d90c8d21f719b17b2eccf3263d](https://coveralls.io/builds/68637354)


### Details

* **83** of **88**   **(94.32%)**  changed or added relevant lines in **7** files are covered.
* **1** unchanged line in **1** file lost coverage.
* Overall coverage increased (+**0.2%**) to **89.892%**

---

|  Changes Missing Coverage | Covered Lines | Changed/Added Lines | % |
| :-----|--------------|--------|---: |
| [lib/recode/task/moduledoc.ex](https://coveralls.io/builds/68637354/source?filename=lib%2Frecode%2Ftask%2Fmoduledoc.ex#L144) | 31 | 32 | 96.88%
| [lib/mix/tasks/recode.ex](https://coveralls.io/builds/68637354/source?filename=lib%2Fmix%2Ftasks%2Frecode.ex#L177) | 10 | 12 | 83.33%
| [lib/recode/ast.ex](https://coveralls.io/builds/68637354/source?filename=lib%2Frecode%2Fast.ex#L603) | 30 | 32 | 93.75%
<!-- | **Total:** | **83** | **88** | **94.32%** | -->

|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [lib/recode/task/tags.ex](https://coveralls.io/builds/68637354/source?filename=lib%2Frecode%2Ftask%2Ftags.ex#L119) | 1 | 93.94% |
<!-- | **Total:** | **1** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/68637354/badge)](https://coveralls.io/builds/68637354) |
| :-- | --: |
| Change from base [Build b3fb166bc3f0d4d8b44481cdc742115a3aa41b17](https://coveralls.io/builds/68498534): |  0.2% |
| Covered Lines: | 996 |
| Relevant Lines: | 1108 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs
index fe51488..17bc92d 100644
--- a/.dialyzer_ignore.exs
+++ b/.dialyzer_ignore.exs
@@ -1,1 +1,3 @@
-[]
+[
+  ~r|lib/mix/tasks/recode.ex:.*:no_return|
+]
diff --git a/README.md b/README.md
index 8cd17bc..69c6b30 100644
--- a/README.md
+++ b/README.md
@@ -20,18 +20,20 @@ on `sourceror`.
 Design tasks:
 TagFIXME            # Checker   - Checks if there are FIXME tags in the sources.
 TagTODO             # Checker   - Checks if there are TODO tags in the sources.
-Readability tasks:  
+Readability tasks:
 AliasExpansion      # Corrector - Exapnds multi aliases to separate aliases.
 AliasOrder          # Corrector - Checks if aliases are sorted alphabetically.
 EnforceLineLength   # Corrector - Forces expressions to one line.
 Format              # Corrector - Does the same as `mix format`.
+LocalsWithoutParens # Corrector - Removes parens from locals without parens.
+Moduledoc           # Checker   - There should be a @moduledoc in any module.
 PipeFunOne          # Corrector - Add parentheses to one-arity functions.
 SinglePipe          # Corrector - Pipes should only be used when piping data through multiple calls.
 Specs               # Checker   - Checks for specs.
+UnnecessaryIfUnless # Corrector - Removes redundant booleans
 Refactor tasks:
 FilterCount         # Corrector - Checks calls like Enum.filter(...) |> Enum.count().
 Nesting             # Checker   - Checks code nesting depth in functions and macros.
-UnnecessaryIfUnless # Corrector - Changes `if expr, do: true, else: false` to `expr`
 Warning tasks:
 Dbg                 # Corrector - There should be no calls to dbg.
 IOInspect           # Corrector - There should be no calls to IO.inspect.
diff --git a/lib/mix/tasks/recode.ex b/lib/mix/tasks/recode.ex
index 1a5163f..c3b5377 100644
--- a/lib/mix/tasks/recode.ex
+++ b/lib/mix/tasks/recode.ex
@@ -165,22 +165,24 @@ defmodule Mix.Tasks.Recode do
   defp validate_task_config!(task, config) do
     keys = Keyword.keys(config) -- @task_config_keys
 
-    unless Enum.empty?(keys) do
-      config =
-        Enum.reduce(keys, config, fn key, config ->
-          {value, config} = Keyword.pop!(config, key)
-
-          Keyword.update(config, :config, [{key, value}], fn task_config ->
-            Keyword.put(task_config, key, value)
-          end)
+    unless Enum.empty?(keys), do: task_config_error!(task, config, keys)
+  end
+
+  defp task_config_error!(task, config, keys) do
+    config =
+      Enum.reduce(keys, config, fn key, config ->
+        {value, config} = Keyword.pop!(config, key)
+
+        Keyword.update(config, :config, [{key, value}], fn task_config ->
+          Keyword.put(task_config, key, value)
         end)
+      end)
 
-      Mix.raise("""
-      Invalid config keys #{inspect(keys)} for #{inspect(task)} found.
-      Did you want to create a task-specific configuration:
-      {#{inspect(task)}, #{inspect(config)}}
-      """)
-    end
+    Mix.raise("""
+    Invalid config keys #{inspect(keys)} for #{inspect(task)} found.
+    Did you want to create a task-specific configuration:
+    {#{inspect(task)}, #{inspect(config)}}
+    """)
   end
 
   defp validate_task!({:error, :nofile}, task) do
@@ -194,18 +196,20 @@ defmodule Mix.Tasks.Recode do
   end
 
   defp update_task_configs!(config) do
-    Keyword.update!(config, :tasks, fn tasks ->
-      Enum.map(tasks, fn {task, config} ->
-        task_config = Keyword.get(config, :config, [])
+    Keyword.update!(config, :tasks, fn tasks -> do_update_task_configs!(tasks) end)
+  end
 
-        case task.init(task_config) do
-          {:ok, task_config} ->
-            {task, Keyword.put(config, :config, task_config)}
+  defp do_update_task_configs!(tasks) do
+    Enum.map(tasks, fn {task, config} ->
+      task_config = Keyword.get(config, :config, [])
 
-          {:error, message} ->
-            Mix.raise("The task #{inspect(task)} has an invalid config:\n#{message}")
-        end
-      end)
+      case task.init(task_config) do
+        {:ok, task_config} ->
+          {task, Keyword.put(config, :config, task_config)}
+
+        {:error, message} ->
+          Mix.raise("The task #{inspect(task)} has an invalid config:\n#{message}")
+      end
     end)
   end
 
diff --git a/lib/recode/ast.ex b/lib/recode/ast.ex
index 3ea3b67..da93d89 100644
--- a/lib/recode/ast.ex
+++ b/lib/recode/ast.ex
@@ -24,6 +24,29 @@ defmodule Recode.AST do
 
   alias Sourceror.Zipper
 
+  @type acc :: any()
+
+  @typedoc """
+  Abstract Syntax Tree (AST)
+  """
+  @type t ::
+          atom
+          | number
+          | binary
+          | [t()]
+          | {t(), t()}
+          | expr
+
+  @typedoc """
+  The metadata of an expression in the AST.
+  """
+  @type metadata :: keyword
+
+  @typedoc """
+  An expression in the AST.
+  """
+  @type expr :: {atom | expr, metadata, atom | [t()]}
+
   @doc """
   Returns `true` if the given AST represents an atom.
 
@@ -32,7 +55,9 @@ defmodule Recode.AST do
       iex> ":atom" |> Code.string_to_quoted!() |> atom?()
       true
 
-      iex> ":atom" |> Sourceror.parse_string!() |> atom?()
+      iex> ast = Sourceror.parse_string!(":atom")
+      {:__block__, [trailing_comments: [], leading_comments: [], line: 1, column: 1], [:atom]}
+      ...> atom?(ast)
       true
 
       iex> "42" |> Sourceror.parse_string!() |> atom?()
@@ -44,7 +69,7 @@ defmodule Recode.AST do
       iex> atom?(ast)
       true
   """
-  @spec atom?(Macro.t()) :: boolean()
+  @spec atom?(t()) :: boolean()
   def atom?(atom) when is_atom(atom), do: true
 
   def atom?({:__block__, _meta, [atom]}) when is_atom(atom), do: true
@@ -72,8 +97,8 @@ defmodule Recode.AST do
   ]
 
   @doc ~S'''
-  Returns `true` when the given `ast` represents an expression that spans over
-  multiple lines.
+  Returns `true` when the given `ast` expression represents an expression that
+  spans over multiple lines.
 
   `multiline?` does not pay attention to do blocks.
 
@@ -85,7 +110,8 @@ defmodule Recode.AST do
       ...>   {:foo, x}
       ...> end
       ...> """
-      ...> |> Sourceror.parse_string!() |> multiline?()
+      ...> |> Sourceror.parse_string!()
+      ...> |> multiline?()
       true
 
       iex> """
@@ -93,7 +119,8 @@ defmodule Recode.AST do
       ...>   {:foo, x}
       ...> end
       ...> """
-      ...> |> Sourceror.parse_string!() |> multiline?()
+      ...> |> Sourceror.parse_string!()
+      ...> |> multiline?()
       false
 
       iex> """
@@ -102,16 +129,18 @@ defmodule Recode.AST do
       ...>   y
       ...> }
       ...> """
-      ...> |> Sourceror.parse_string!() |> multiline?()
+      ...> |> Sourceror.parse_string!()
+      ...> |> multiline?()
       true
 
       iex> """
       ...> {x, y}
       ...> """
-      ...> |> Sourceror.parse_string!() |> multiline?()
+      ...> |> Sourceror.parse_string!()
+      ...> |> multiline?()
       false
   '''
-  @spec multiline?(Macro.t() | Macro.metadata()) :: boolean()
+  @spec multiline?(t() | metadata()) :: boolean()
   def multiline?({op, _meta, [left, right]}) when op in @operators do
     last_line(right) > first_line(left)
   end
@@ -126,6 +155,34 @@ defmodule Recode.AST do
     end
   end
 
+  @doc ~S'''
+  Forces a one line AST expression from a multiline AST expression.
+
+  ## Example
+
+      iex> """
+      ...> x &&
+      ...>   y
+      ...> """
+      ...> |> Sourceror.parse_string!()
+      ...> |> to_same_line()
+      ...> |> Sourceror.to_string()
+      "x && y"
+
+      iex> """
+      ...> def foo,
+      ...>   do:
+      ...>     :foo
+      ...> """
+      ...> |> Sourceror.parse_string!()
+      ...> |> to_same_line()
+      ...> |> Sourceror.to_string()
+      """
+      def foo,
+        do: :foo\
+      """
+  '''
+  @spec to_same_line(t()) :: t()
   def to_same_line({op, _meta, [left, right]} = ast) when op in @operators do
     to_same_line(ast, first_line(left), last_line(right))
   end
@@ -175,10 +232,13 @@ defmodule Recode.AST do
   end
 
   @doc ~S'''
-  Returns the line in which the given AST starts.
+  Returns the first line in which the given AST starts.
 
-  Note: The AST must be constructed by `Sourceror` or with the `Code` module and
-  the options `columns: true, token_metadata: true`.
+  > ### Note {: .info}
+  >
+  > The AST must be constructed by `Sourceror` or with the `Code` module and
+  > the options `columns: true, token_metadata: true,
+  > literal_encoder: &{:ok, {:__block__, &2, [&1]}}`.
 
   ## Examples
 
@@ -187,9 +247,11 @@ defmodule Recode.AST do
       ...>   2 -
       ...>   3
       ...> """
-      iex> code |> Sourceror.parse_string!() |> first_line()
+      ...>
+      ...> ast = Sourceror.parse_string(code)
+      ...> first_line(ast)
       1
-      iex> code |> Sourceror.parse_string!() |> last_line()
+      iex> last_line(ast)
       3
       iex> code
       ...> |> Code.string_to_quoted!(
@@ -198,10 +260,16 @@ defmodule Recode.AST do
       ...>   literal_encoder: &{:ok, {:__block__, &2, [&1]}})
       ...> |> last_line()
       3
-
   '''
+  @spec first_line(t()) :: integer | nil
   def first_line(ast), do: get_line_by(ast, &min/2)
 
+  @doc """
+  Returns the last line in which the given AST ends.
+
+  See `first_line/1` for example and note.
+  """
+  @spec last_line(t()) :: integer | nil
   def last_line(ast), do: get_line_by(ast, &max/2)
 
   defp get_line_by(ast, fun) do
@@ -235,7 +303,7 @@ defmodule Recode.AST do
       iex> ast |> update_definition(args: [{:y, [], nil}]) |> Macro.to_string()
       "def foo(y), do: x"
   '''
-  @spec update_definition(Macro.t(), updates :: keyword()) :: Macro.t()
+  @spec update_definition(t(), updates :: keyword()) :: t()
   def update_definition(
         {:def, meta, [{:when, meta1, [{name, meta2, args}, expr1]}, expr2]},
         updates
@@ -270,7 +338,7 @@ defmodule Recode.AST do
       ...> |> Macro.to_string()
       "@spec bar(integer()) :: term()"
   """
-  @spec update_spec(Macro.t(), updates :: keyword()) :: Macro.t()
+  @spec update_spec(t(), updates :: keyword()) :: t()
   def update_spec(
         {:@, meta,
          [
@@ -349,7 +417,7 @@ defmodule Recode.AST do
       ...> |> Macro.to_string()
       "bar(x)"
   """
-  @spec update_call(Macro.t(), updates :: keyword()) :: Macro.t()
+  @spec update_call(t(), updates :: keyword()) :: t()
   def update_call({name, meta, args}, updates) do
     name = Keyword.get(updates, :name, name)
     meta = Keyword.get(updates, :meta, meta)
@@ -369,7 +437,7 @@ defmodule Recode.AST do
       iex> update_dot_call(ast, name: :bar)
       {{:., [], [{:__aliases__, [alias: false], [:Foo]}, :bar]}, [], [{:x, [], Recode.ASTTest}]}
   """
-  @spec update_dot_call(Macro.t(), updates :: keyword()) :: Macro.t()
+  @spec update_dot_call(t(), updates :: keyword()) :: t()
   def update_dot_call(
         {{:., meta, [{:__aliases__, meta1, module}, name]}, meta2, args},
         updates
@@ -383,8 +451,16 @@ defmodule Recode.AST do
 
   @doc """
   Returns a `mfa`-tuple for the given `.`-call.
+
+  ## Examples
+
+      iex> ast = quote do
+      ...>   Foo.Bar.baz(x)
+      ...> end
+      ...> mfa(ast)
+      {Foo.Bar, :baz, 1}
   """
-  @spec mfa({{:., keyword(), list()}, Macro.metadata(), Macro.t()}) ::
+  @spec mfa({{:., keyword(), list()}, metadata(), t()}) ::
           {module(), atom(), non_neg_integer()}
   def mfa({{:., _meta1, [{:__aliases__, _meta2, aliases}, fun]}, _meta3, args}) do
     {Module.concat(aliases), fun, length(args)}
@@ -394,7 +470,7 @@ defmodule Recode.AST do
   Puts the given value `newlines` under the key `nevlines` in
   `meta[:end_of_expression]`.
   """
-  @spec put_newlines({term(), Macro.metadata(), Macro.t()}, integer()) ::
+  @spec put_newlines({term(), metadata(), t()}, integer()) ::
           {term(), keyword(), list()}
   def put_newlines({name, meta, args}, newlines) do
     meta =
@@ -408,7 +484,7 @@ defmodule Recode.AST do
   @doc """
   Returns the `newlines` value from `meta[:end_of_expression]`, or `nil`.
   """
-  @spec get_newlines(Macro.t()) :: integer()
+  @spec get_newlines(t()) :: integer()
   def get_newlines({_name, meta, _args}) do
     case Keyword.fetch(meta, :end_of_expression) do
       {:ok, end_of_expression} -> Keyword.get(end_of_expression, :newlines)
@@ -454,7 +530,7 @@ defmodule Recode.AST do
       iex> alias_info(ast)
       {:__MODULE__, [], MyModule}
   """
-  @spec alias_info(Macro.t()) :: {module(), [module()], module() | nil}
+  @spec alias_info(t()) :: {module(), [module()], module() | nil}
   def alias_info({:alias, _meta1, [{:__aliases__, _meta2, aliases}]}) do
     aliases =
       Enum.map(aliases, fn
@@ -518,11 +594,26 @@ defmodule Recode.AST do
       iex> aliases_concat({:__aliases__, [], [:Alpha, :Bravo]})
       Alpha.Bravo
   """
-  @spec aliases_concat({:__aliases__, Macro.metadata(), [atom()]}) :: module()
+  @spec aliases_concat({:__aliases__, metadata(), [atom()]} | list) :: module()
   def aliases_concat({:__aliases__, _meta, aliases}) do
     Module.concat(aliases)
   end
 
+  def aliases_concat([{:__aliases__, _meta, aliases} | _]) do
+    Module.concat(aliases)
+  end
+
+  @doc """
+  Returns the module name as an atom for the given `ast`.
+
+  The function accepts `{:defmodule, meta, args}`, the `args` form the
+  `:defmodule` tuple or the same input as `aliases_concat/1`.
+  """
+  @spec module(t()) :: module
+  def module(ast)
+  def module({:defmodule, _metag, [arg | _args]}), do: aliases_concat(arg)
+  def module(args), do: aliases_concat(args)
+
   @doc """
   Converts AST representing a name to a string.
 
@@ -530,11 +621,11 @@ defmodule Recode.AST do
 
   ## Examples
 
-      iex> name([Recode, AST])
-      "Recode.AST"
+      iex> name([Recode, Task])
+      "Recode.Task"
 
-      iex> name(Recode.AST)
-      "Recode.AST"
+      iex> name(Recode.Task)
+      "Recode.Task"
   """
   @spec name(atom() | [atom()]) :: String.t()
   def name(aliases) when is_list(aliases) do
@@ -547,6 +638,70 @@ defmodule Recode.AST do
     end
   end
 
+  @doc ~S'''
+  Returns the `:do` or `:else` block arguments of the given `expr`.
+
+  ## Examples
+
+      iex> ast = Sourceror.parse_string!("""
+      ...> defmodule Foo do
+      ...>   def bar, do: bar
+      ...> end
+      ...> """)
+      ...> block = block(ast)
+      ...> Sourceror.to_string({:__block__,[], block})
+      "def bar, do: bar"
+
+      iex> {:defmodule, _meta, [_aliases, args]} = Sourceror.parse_string!("""
+      ...> defmodule Foo do
+      ...>   def bar, do: bar
+      ...>   def baz, do: baz
+      ...> end
+      ...> """)
+      ...> block = block(args) 
+      ...> Sourceror.to_string({:__block__,[], block})
+      """
+      def bar, do: bar
+      def baz, do: baz\
+      """
+
+      iex> ast = Sourceror.parse_string!("""
+      ...> if x, do: true, else: false
+      ...> """)
+      ...> block(ast, :else)
+      [false]
+      ...> block(ast)
+      [true]
+      ...> block(ast, :do)
+      [true]
+
+      iex> ast = Sourceror.parse_string!("x == y")
+      ...> block(ast)
+      nil
+  '''
+  @spec block(expr(), :do | :else) :: t()
+  def block(expr, key \\ :do)
+
+  def block({_form, _meta, args}, key) when key in [:do, :else] do
+    do_block(args, key, 0)
+  end
+
+  def block(args, key) when is_list(args) and key in [:do, :else] do
+    do_block(args, key, 0)
+    # Enum.find_value(args, fn arg -> do_block(arg, key) end)
+  end
+
+  defp do_block(list, key, depth) when is_list(list) and depth <= 1 do
+    Enum.find_value(list, fn
+      {{:__block__, _meta_key, [^key]}, {:__block__, _meta_block, block}} -> block
+      {{:__block__, _meta_key, [^key]}, block} -> [block]
+      list when is_list(list) -> do_block(list, key, depth + 1)
+      _item -> false
+    end)
+  end
+
+  defp do_block(_list, _key, _depth), do: false
+
   @doc """
   Returns the value from a `:__block__` with a single argument.
 
@@ -558,7 +713,7 @@ defmodule Recode.AST do
       ...> |> Enum.map(&get_value/1)
       [1, 2]
   """
-  @spec get_value(Macro.t()) :: term
+  @spec get_value(t()) :: term
   def get_value({:__block__, _meta, [value]}), do: value
 
   @doc """
@@ -573,13 +728,13 @@ defmodule Recode.AST do
       ...> |> Enum.map(&get_value/1)
       ["0", "0"]
   """
-  @spec put_value(Macro.t(), term()) :: Macro.t()
+  @spec put_value(t(), term()) :: t()
   def put_value({:__block__, meta, [_value]}, value), do: {:__block__, meta, [value]}
 
   @doc """
   Updates the function name of a capture.
   """
-  @spec update_capture(Macro.t(), name: atom()) :: Macro.t()
+  @spec update_capture(t(), name: atom()) :: t()
   def update_capture(
         {:&, meta1, [{:/, meta2, [{_name, meta3, nil}, {:__block__, meta4, [arity]}]}]},
         name: name
@@ -607,4 +762,167 @@ defmodule Recode.AST do
         ]}
      ]}
   end
+
+  @doc """
+  Performs a depth-first, pre-order traversal of quoted expressions and invokes
+  `fun` for each node in the `ast` with the accumulator `acc`.
+
+  The initial value of the accumulator is `acc`. The function is invoked for each
+  node in the `ast` with the accumulator. The result returned by the function is
+  used as the accumulator for the next iteration. The function returns the last
+  accumulator.
+
+  ## Examples
+
+      iex> ast = quote do
+      ...>   def foo(x), {:oof, x}
+      ...>   def bar(x), {:rab, x}
+      ...> end
+      ...> AST.reduce(ast, [], fn
+      ...>   {:def, _, [{name, _, _}|_]}, acc -> [name | acc]
+      ...>   ast, acc when is_atom(ast) -> [ast|acc]
+      ...>   _ast, acc -> acc
+      ...> end)
+      [:rab, :bar, :oof, :foo]
+  """
+  @spec reduce(t(), acc(), (t(), acc() -> acc())) :: acc()
+  def reduce(ast, acc, fun) do
+    acc
+    |> do_reduce_apply(ast, fun)
+    |> do_reduce(ast, fun)
+  end
+
+  defp do_reduce(acc, {form, _meta, args}, fun) when is_atom(form) do
+    do_reduce_args(acc, args, fun)
+  end
+
+  defp do_reduce(acc, {form, _meta, args}, fun) do
+    acc
+    |> do_reduce_apply(form, fun)
+    |> do_reduce(form, fun)
+    |> do_reduce_args(args, fun)
+  end
+
+  defp do_reduce(acc, {left, right}, fun) do
+    acc
+    |> do_reduce_apply(left, fun)
+    |> do_reduce(left, fun)
+    |> do_reduce_apply(right, fun)
+    |> do_reduce(right, fun)
+  end
+
+  defp do_reduce(acc, ast, fun) when is_list(ast) do
+    do_reduce_args(acc, ast, fun)
+  end
+
+  defp do_reduce(acc, _ast, _fun), do: acc
+
+  defp do_reduce_args(acc, [], _fun), do: acc
+
+  defp do_reduce_args(acc, [arg | args], fun) do
+    acc
+    |> do_reduce_apply(arg, fun)
+    |> do_reduce(arg, fun)
+    |> do_reduce_args(args, fun)
+  end
+
+  defp do_reduce_args(acc, _arg, _fun), do: acc
+
+  @compile {:inline, do_reduce_apply: 3}
+  defp do_reduce_apply(acc, ast, fun), do: fun.(ast, acc)
+
+  @doc """
+  Reduces the `ast` until `fun` returns `{:halt, term}`.
+
+  The return value for `fun` is expected to be
+
+    * `{:cont, acc}` to continue the reduction with the next node in the ast and
+       `acc` as the new accumulator
+
+    * `{:skip, acc}` to continue the reduction while skippin the childrens of
+       current node and `acc` as the new accumulator
+
+    * `{:halt, acc}` to halt the reduction or
+
+  If `fun` returns `{:halt, acc}` the reduction is halted and the function
+  returns `acc`. Otherwise, if the AST is exhausted, the function returns the
+  accumulator of the last `{:cont, acc}`.
+
+  ## Examples
+
+      iex> ast = quote do
+      ...>   def foo(x), {:oof, x}
+      ...>   def bar(x, y), {:rab, x, y}
+      ...> end
+      ...>
+      ...> AST.reduce_while(ast, [], fn
+      ...>   {:def, _, [{name, _, args}|_]}, acc
+      ...>     -> acc = [name | acc]
+      ...>        if length(args) == 1 do
+      ...>          {:cont, acc}
+      ...>        else
+      ...>          {:skip, acc}
+      ...>        end
+      ...>   ast, acc when is_atom(ast)
+      ...>     -> {:cont, [ast|acc]}
+      ...>   _ast, acc
+      ...>     -> {:cont, acc}
+      ...> end)
+      [:bar, :oof, :foo]
+  """
+  @spec reduce_while(t(), acc(), (t(), acc() -> result)) :: acc()
+        when result: {:cont, acc()} | {:halt, acc()} | {:skip, acc()}
+  def reduce_while(ast, acc, fun) do
+    acc
+    |> do_reduce_while_apply(ast, fun)
+    |> do_reduce_while(ast, fun)
+    |> elem(1)
+  end
+
+  defp do_reduce_while({:cont, acc}, {form, _meta, args}, fun) when is_atom(form) do
+    do_reduce_while_args(acc, args, fun)
+  end
+
+  defp do_reduce_while({:cont, acc}, {form, _meta, args}, fun) do
+    acc
+    |> do_reduce_while_apply(form, fun)
+    |> do_reduce_while(form, fun)
+    |> elem(1)
+    |> do_reduce_while_args(args, fun)
+  end
+
+  defp do_reduce_while({:cont, acc}, {left, right}, fun) do
+    acc
+    |> do_reduce_while_apply(left, fun)
+    |> do_reduce_while(left, fun)
+    |> elem(1)
+    |> do_reduce_while_apply(right, fun)
+    |> do_reduce_while(right, fun)
+  end
+
+  defp do_reduce_while({_tag, acc}, ast, fun) when is_list(ast) do
+    do_reduce_while_args(acc, ast, fun)
+  end
+
+  defp do_reduce_while({:skip, acc}, _ast, _fun), do: {:cont, acc}
+  defp do_reduce_while(acc, _ast, _fun), do: acc
+
+  defp do_reduce_while_args(acc, [], _fun), do: {:cont, acc}
+
+  defp do_reduce_while_args(acc, [arg | args], fun) do
+    acc
+    |> do_reduce_while_apply(arg, fun)
+    |> do_reduce_while(arg, fun)
+    |> elem(1)
+    |> do_reduce_while_args(args, fun)
+  end
+
+  defp do_reduce_while_args(acc, _arg, _fun), do: {:cont, acc}
+
+  @compile {:inline, do_reduce_while_apply: 3}
+  defp do_reduce_while_apply(acc, ast, fun) do
+    case fun.(ast, acc) do
+      {tag, _data} = acc when tag in [:cont, :halt, :skip] -> acc
+    end
+  end
 end
diff --git a/lib/recode/cli_formatter.ex b/lib/recode/cli_formatter.ex
index a452687..350e642 100644
--- a/lib/recode/cli_formatter.ex
+++ b/lib/recode/cli_formatter.ex
@@ -153,10 +153,7 @@ defmodule Recode.CLIFormatter do
       |> stats_count(:updated, opts[:code_updated?])
       |> stats_count(:moved, opts[:path_updated?])
       |> stats_count(:created, opts[:created?])
-      |> Map.update!(:extname_count, fn extname_count ->
-        extname = Path.extname(source.path)
-        Map.update(extname_count, extname, 1, fn count -> count + 1 end)
-      end)
+      |> extname_count(source)
     end)
   end
 
@@ -209,6 +206,13 @@ defmodule Recode.CLIFormatter do
 
   defp stats_count(stats, _key, false, _add), do: stats
 
+  defp extname_count(stats, source) do
+    Map.update!(stats, :extname_count, fn extname_count ->
+      extname = Path.extname(source.path)
+      Map.update(extname_count, extname, 1, fn count -> count + 1 end)
+    end)
+  end
+
   defp format_result(source, verbose, opts, config) do
     issues? = opts[:issues?]
     code_updated? = opts[:code_updated?] and verbose
diff --git a/lib/recode/config.ex b/lib/recode/config.ex
index 157ce58..471813e 100644
--- a/lib/recode/config.ex
+++ b/lib/recode/config.ex
@@ -34,8 +34,8 @@ defmodule Recode.Config do
       {Recode.Task.EnforceLineLength, [active: false]},
       {Recode.Task.FilterCount, []},
       {Recode.Task.IOInspect, [autocorrect: false]},
-      # {Recode.Task.Moduledoc, []},
       {Recode.Task.LocalsWithoutParens, []},
+      {Recode.Task.Moduledoc, []},
       {Recode.Task.Nesting, []},
       {Recode.Task.PipeFunOne, []},
       {Recode.Task.SinglePipe, []},
@@ -106,9 +106,9 @@ defmodule Recode.Config do
       iex> Recode.Config.merge(new ,old) |> Enum.sort()
       [autocorrect: true, verbose: true, version: "0.0.2"]
   """
+  @spec merge(config, config) :: config
   def merge(left \\ default(), right) do
-    left
-    |> Keyword.merge(right, fn
+    Keyword.merge(left, right, fn
       :version, version, _ -> version
       :tasks, left, right -> merge_tasks(left, right)
       _, _, value -> value
diff --git a/lib/recode/task/filter_count.ex b/lib/recode/task/filter_count.ex
index 802c51b..dc3db06 100644
--- a/lib/recode/task/filter_count.ex
+++ b/lib/recode/task/filter_count.ex
@@ -82,8 +82,8 @@ defmodule Recode.Task.FilterCount do
          autocorrect
        ) do
     if autocorrect do
-      arg = prune_data(arg)
-      fun = prune_data(fun)
+      arg = purne_meta(arg)
+      fun = purne_meta(fun)
 
       quoted =
         case expr do
@@ -91,10 +91,9 @@ defmodule Recode.Task.FilterCount do
             quoted_count_pipe(fun, meta, arg)
 
           _else ->
-            case in_pipe?(zipper) do
-              true -> quoted_count_pipe(fun, meta, arg)
-              false -> quoted_count(fun, arg)
-            end
+            zipper
+            |> in_pipe?()
+            |> quoted_count(fun, meta, arg)
         end
 
       {Zipper.replace(zipper, quoted), issues}
@@ -148,6 +147,9 @@ defmodule Recode.Task.FilterCount do
 
   defp filter_count(zipper, issues, _autocorrect), do: {zipper, issues}
 
+  defp quoted_count(true = _in_pipe?, fun, meta, arg), do: quoted_count_pipe(fun, meta, arg)
+  defp quoted_count(false = _in_pipe?, fun, _meta, arg), do: quoted_count(fun, arg)
+
   defp quoted_count(fun, arg) do
     {{:., [], [{:__aliases__, [], [:Enum]}, :count]}, [], [arg, fun]}
   end
@@ -156,7 +158,7 @@ defmodule Recode.Task.FilterCount do
     {:|>, meta, [arg, {{:., [], [{:__aliases__, [], [:Enum]}, :count]}, [], [fun]}]}
   end
 
-  defp prune_data({expr, _meta, args}), do: {expr, [], args}
+  defp purne_meta({expr, _meta, args}), do: {expr, [], args}
 
   defp in_pipe?(zipper) do
     match?({:|>, _, _}, zipper |> Zipper.up() |> Zipper.node())
diff --git a/lib/recode/task/moduledoc.ex b/lib/recode/task/moduledoc.ex
index f30f35e..984eb36 100644
--- a/lib/recode/task/moduledoc.ex
+++ b/lib/recode/task/moduledoc.ex
@@ -1,32 +1,28 @@
 defmodule Recode.Task.Moduledoc do
-  @moduledoc false
-
-  # @shortdoc "There should be a @moduledoc in the module."
-  #
-  # @moduledoc """
-  # Any module should contain a `@moudledoc` attribute.
-  #
-  # For a public module, comprehensive documentation should be available. The
-  # module documentation helps the user of your package, contributors, and your
-  # future self understand what the module is for.
-  #
-  # For private modules, it is also okay to set `@moduled false`. Modules marked
-  # in this way are not displayed in the documentation.
-  #
-  # ## Options
-  #
-  #   * `ignore_names` - accepts a regex or a list of regexes to recognize modules
-  #     that this task ignores.
-  # """
+  @shortdoc "There should be a @moduledoc in any module."
+
+  @moduledoc """
+  Any module should contain a `@moudledoc` attribute.
+
+  For a public module, comprehensive documentation should be available. The
+  module documentation helps the user of your package, contributors, and your
+  future self understand what the module is for.
+
+  For private modules, it is also okay to set `@moduled false`. Modules marked
+  in this way are not displayed in the documentation.
+
+  ## Options
+
+    * `ignore_names` - accepts a regex or a list of regexes to recognize modules
+      that this task ignores.
+  """
 
   use Recode.Task, corrector: false, category: :readability
 
   alias Recode.AST
   alias Recode.Issue
   alias Rewrite.Source
-  alias Sourceror.Zipper
 
-  @skip_def_kinds [:def, :defp, :defmacro, :defmacrop]
   @default_config [ignore_names: []]
   @error_message """
   The config for the Recode.Task.Moduledoc is wrong. The task excepts the option \
@@ -39,9 +35,8 @@ defmodule Recode.Task.Moduledoc do
 
     source
     |> Source.get(:quoted)
-    |> Zipper.zip()
-    |> Zipper.traverse_while([], module(ignore_names))
-    |> update(source)
+    |> check()
+    |> update(source, ignore_names)
   end
 
   @impl Recode.Task
@@ -60,86 +55,73 @@ defmodule Recode.Task.Moduledoc do
     end
   end
 
-  defp validate_keys(config) do
-    with {:error, unknown} <- Keyword.validate(config, @default_config) do
-      {:error, "#{@error_message}. Unknown keys: #{inspect(unknown)}"}
-    end
-  end
+  defp check(ast) do
+    AST.reduce_while(ast, [], fn
+      {:defmodule, meta, [aliases, args]}, acc ->
+        module = {AST.module(aliases), meta}
+        block = AST.block(args)
+        acc = [check_module([module], block) | acc]
+        {:skip, acc}
 
-  defp validate_config(config) do
-    case Keyword.fetch!(config, :ignore_names) do
-      [_ | _] = list -> Enum.all?(list, fn regex -> is_struct(regex, Regex) end)
-      %Regex{} -> true
-      _invalid -> false
-    end
+      _ast, acc ->
+        {:cont, acc}
+    end)
   end
 
-  defp update({_zipper, []}, source), do: source
-  defp update({_zipper, issues}, source), do: Source.add_issues(source, issues)
-
-  defp module(ignore_names) do
-    fn
-      %Zipper{node: {:defmodule, meta, args}} = zipper, issues ->
-        issues = issues ++ check(zipper, name(args), meta, ignore_names)
+  defp check_module(modules, ast) do
+    AST.reduce_while(ast, modules, fn
+      {:@, _, [{:moduledoc, _, args} | _]}, [module | modules] when not is_nil(module) ->
+        {:cont, [check_moduledoc(args, module) | modules]}
 
-        {:skip, zipper, issues}
+      {:defmodule, meta, [aliases, args]}, acc ->
+        module = {AST.module(aliases), meta}
+        block = AST.block(args)
+        acc = [acc | check_module([module], block)]
+        {:skip, acc}
 
-      zipper, issues ->
-        {:cont, zipper, issues}
-    end
+      _ast, acc ->
+        {:skip, acc}
+    end)
   end
 
-  defp check(zipper, name, meta, ignore_names) do
-    acc = if ignore?(name, ignore_names), do: [:ignore], else: []
-
-    {_zipper, issues} =
-      zipper
-      |> do_block()
-      |> Zipper.traverse_while(acc, moduledoc(ignore_names))
+  defp check_moduledoc([{:__block__, _meta, [text]}], {module, meta}) when is_binary(text) do
+    {module, Keyword.merge(meta, exist: true, empty: String.trim(text) == "")}
+  end
 
-    issues(issues, name, meta)
+  defp check_moduledoc(_args, {module, meta}) do
+    {module, Keyword.merge(meta, exist: true, empty: false)}
   end
 
-  defp moduledoc(ignore_names) do
-    fn
-      %Zipper{node: {def_kind, _, _}} = zipper, issues when def_kind in @skip_def_kinds ->
-        {:skip, zipper, issues}
+  defp update([], source, _ignore_names), do: source
 
-      %Zipper{node: {:defmodule, meta, args}} = zipper, issues ->
-        issues = issues ++ check(zipper, name(args), meta, ignore_names)
+  defp update(result, source, ignore_names) do
+    result
+    |> List.flatten()
+    |> Enum.reduce(source, fn module, source -> issue(module, source, ignore_names) end)
+  end
 
-        {:skip, zipper, issues}
+  defp issue({module, meta}, source, ignore_names) do
+    cond do
+      ignore?(AST.name(module), ignore_names) ->
+        source
 
-      zipper, [:ignore | _issues] = issues ->
-        {:cont, zipper, issues}
+      Keyword.get(meta, :empty, false) ->
+        add_issue(source, meta, """
+        The @moudledoc attribute for moudle #{module} has no content.\
+        """)
 
-      %Zipper{node: {:@, _, [{:moduledoc, _, _} | _]}} = zipper, issues ->
-        {:cont, zipper, [:exist | issues]}
+      Keyword.get(meta, :exist, false) ->
+        source
 
-      zipper, issues ->
-        {:cont, zipper, issues}
+      true ->
+        add_issue(source, meta, """
+        The moudle #{module} is missing @moduledoc.\
+        """)
     end
   end
 
-  defp issues([issue | issues], _name, _meta) when issue in [:exist, :ignore], do: issues
-
-  defp issues(issues, name, meta) do
-    [issue(name, meta) | issues]
-  end
-
-  defp issue(name, meta) do
-    message = "The moudle #{name} is missing @moduledoc."
-    Issue.new(Moduledoc, message, meta)
-  end
-
-  defp do_block(zipper) do
-    Zipper.traverse_while(zipper, fn
-      %Zipper{node: {{:__block__, _, [:do]}, block}} ->
-        {:halt, Zipper.zip(block)}
-
-      zipper ->
-        {:cont, zipper}
-    end)
+  defp add_issue(source, meta, message) do
+    Source.add_issue(source, Issue.new(Moduledoc, message, meta))
   end
 
   defp ignore?(_name, []), do: false
@@ -150,6 +132,17 @@ defmodule Recode.Task.Moduledoc do
     end
   end
 
-  defp name([arg | _args]), do: name(arg)
-  defp name(arg), do: arg |> AST.aliases_concat() |> inspect()
+  defp validate_keys(config) do
+    with {:error, unknown} <- Keyword.validate(config, @default_config) do
+      {:error, "#{@error_message}. Unknown keys: #{inspect(unknown)}"}
+    end
+  end
+
+  defp validate_config(config) do
+    case Keyword.fetch!(config, :ignore_names) do
+      [_ | _] = list -> Enum.all?(list, fn regex -> is_struct(regex, Regex) end)
+      %Regex{} -> true
+      _invalid -> false
+    end
+  end
 end
diff --git a/lib/recode/task/tags.ex b/lib/recode/task/tags.ex
index c45f123..779fe64 100644
--- a/lib/recode/task/tags.ex
+++ b/lib/recode/task/tags.ex
@@ -72,7 +72,7 @@ defmodule Recode.Task.Tags do
 
   defp check_tags(zipper, issues, _opts), do: {zipper, issues}
 
-  defp doc?([{:__block__, _meta, [text]}]), do: text != false
+  defp doc?([{:__block__, _meta, [text]}]), do: is_binary(text)
   defp doc?(_ast), do: false
 
   defp doc([{:__block__, meta, [text]}]) do
diff --git a/mix.exs b/mix.exs
index 7ae7926..25dca04 100644
--- a/mix.exs
+++ b/mix.exs
@@ -56,6 +56,7 @@ defmodule Recode.MixProject do
           Recode.Task.Format,
           Recode.Task.IOInspect,
           Recode.Task.LocalsWithoutParens,
+          Recode.Task.Moduledoc,
           Recode.Task.Nesting,
           Recode.Task.PipeFunOne,
           Recode.Task.SinglePipe,
diff --git a/recode.exs b/recode.exs
index 7570358..e9df0f7 100644
--- a/recode.exs
+++ b/recode.exs
@@ -1,26 +1,35 @@
-alias Recode.Task
-
-# A recode config to run recode with recode. This config excludes the test
-# directory.
-
 [
-  # Can also be set/reset with "--autocorrect"/"--no-autocorrect".
+  version: "0.7.2",
+  # Can also be set/reset with `--autocorrect`/`--no-autocorrect`.
   autocorrect: true,
   # With "--dry" no changes will be written to the files.
-  # Can also be set/reset with "--dry"/"--no-dry".
+  # Can also be set/reset with `--dry`/`--no-dry`.
   # If dry is true then verbose is also active.
   dry: false,
-  # Can also be set/reset with "--verbose"/"--no-verbose".
+  # Enables or disables color in the output.
+  color: true,
+  # Can also be set/reset with `--verbose`/`--no-verbose`.
   verbose: false,
-  inputs: ["{config,lib}/**/*.{ex,exs}"],
-  formatter: {Recode.Formatter, []},
+  # Can be overwritten by calling `mix recode "lib/**/*.ex"`.
+  inputs: ["{mix,.formatter}.exs", "{apps,config,lib}/**/*.{ex,exs}"],
+  formatters: [Recode.CLIFormatter],
   tasks: [
-    {Task.AliasExpansion, []},
-    {Task.AliasOrder, []},
-    {Task.PipeFunOne, []},
-    {Task.SinglePipe, []},
-    {Task.Specs, [only: :visible, exclude: "test/**/*.{ex,exs}"]},
-    {Task.TestFileExt, []},
-    {Task.UnusedVariable, []}
+    # Tasks could be added by a tuple of the tasks module name and an options
+    # keyword list. A task can be deactivated by `active: false`. The execution of
+    # a deactivated task can be forced by calling `mix recode --task ModuleName`.
+    {Recode.Task.AliasExpansion, []},
+    {Recode.Task.AliasOrder, []},
+    {Recode.Task.Dbg, [autocorrect: false]},
+    {Recode.Task.EnforceLineLength, [active: false]},
+    {Recode.Task.FilterCount, []},
+    {Recode.Task.IOInspect, [autocorrect: false]},
+    {Recode.Task.Nesting, []},
+    {Recode.Task.PipeFunOne, []},
+    {Recode.Task.SinglePipe, []},
+    {Recode.Task.Specs, [exclude: ["test/**/*.{ex,exs}", "mix.exs"], config: [only: :visible]]},
+    {Recode.Task.TagFIXME, [exit_code: 2]},
+    {Recode.Task.TagTODO, [exit_code: 4]},
+    {Recode.Task.TestFileExt, []},
+    {Recode.Task.UnusedVariable, [active: false]}
   ]
 ]


Test patch:
diff --git a/test/recode/ast_test.exs b/test/recode/ast_test.exs
index f8f86be..65c8e8e 100644
--- a/test/recode/ast_test.exs
+++ b/test/recode/ast_test.exs
@@ -1,5 +1,117 @@
 defmodule Recode.ASTTest do
   use ExUnit.Case
 
+  alias Recode.AST
+
   doctest Recode.AST, import: true
+
+  describe "reduce/3" do
+    test "reduces the AST" do
+      ast =
+        Code.string_to_quoted!("""
+        defmodule Foo do
+          def x, do: :y
+        end
+        """)
+
+      fun = fn
+        {form, _meta, _args}, acc when is_atom(form) -> [form | acc]
+        _ast, acc -> acc
+      end
+
+      assert AST.reduce(ast, [], fun) == [:x, :def, :__aliases__, :defmodule]
+    end
+
+    test "compares to Macro.prewalk/3" do
+      # Macro.prewalk/3 uses Macro.travers/4
+      ast =
+        Code.string_to_quoted!(~s'''
+        defmodule Foo do
+          @moduledoc """
+          The Foo
+          """
+          @data :y
+
+          def x, do: @data
+
+          def rev(list), do: Enum.reverse(list)
+        end
+        ''')
+
+      collect = fn ast, acc -> {ast, [ast | acc]} end
+      {_ast, prewalk} = Macro.prewalk(ast, [], collect)
+
+      collect = fn ast, acc -> [ast | acc] end
+      assert AST.reduce(ast, [], collect) == prewalk
+    end
+  end
+
+  describe "reduce_while/3" do
+    test "reduces the AST" do
+      ast =
+        Code.string_to_quoted!("""
+        defmodule Foo do
+          def x, do: :y
+        end
+        """)
+
+      fun = fn
+        {form, _meta, _args}, acc when is_atom(form) -> {:cont, [form | acc]}
+        _ast, acc -> {:cont, acc}
+      end
+
+      assert AST.reduce_while(ast, [], fun) == [:x, :def, :__aliases__, :defmodule]
+    end
+
+    test "skips parts of the AST" do
+      ast =
+        Code.string_to_quoted!("""
+        defmodule Foo do
+          def x, do: :y
+        end
+        """)
+
+      fun = fn
+        {:def, _meta, _args}, acc -> {:skip, acc}
+        {form, _meta, _args}, acc when is_atom(form) -> {:cont, [form | acc]}
+        _ast, acc -> {:cont, acc}
+      end
+
+      assert AST.reduce_while(ast, [], fun) == [:__aliases__, :defmodule]
+    end
+
+    test "compares to Macro.prewalk/3" do
+      # Macro.prewalk/3 uses Macro.travers/4
+      ast =
+        Code.string_to_quoted!(~s'''
+        defmodule Foo do
+          @moduledoc """
+          The Foo
+          """
+          @data :y
+
+          def x, do: @data
+
+          def rev(list), do: Enum.reverse(list)
+        end
+        ''')
+
+      collect = fn ast, acc -> {ast, [ast | acc]} end
+      {_ast, prewalk} = Macro.prewalk(ast, [], collect)
+
+      collect = fn ast, acc -> {:cont, [ast | acc]} end
+      assert AST.reduce_while(ast, [], collect) == prewalk
+    end
+  end
+
+  describe "multiline?/1" do
+    test "with operator" do
+      assert "x && y" |> Sourceror.parse_string!() |> AST.multiline?() == false
+      assert "x &&\ny" |> Sourceror.parse_string!() |> AST.multiline?() == true
+    end
+
+    test "with empty list" do
+      assert AST.multiline?([]) == false
+    end
+  end
 end
diff --git a/test/recode/task/enforce_line_length_test.exs b/test/recode/task/enforce_line_length_test.exs
index c7abd3d..f39a5ce 100644
--- a/test/recode/task/enforce_line_length_test.exs
+++ b/test/recode/task/enforce_line_length_test.exs
@@ -502,4 +502,15 @@ defmodule Recode.Task.EnforceLineLengthTest do
     |> run_task(EnforceLineLength)
     |> assert_code(expected)
   end
+
+  test "ignores inline do block" do
+    code = """
+    def foo,
+      do: :foo
+    """
+
+    code
+    |> run_task(EnforceLineLength)
+    |> assert_code(code)
+  end
 end
diff --git a/test/recode/task/moduledoc_test.exs b/test/recode/task/moduledoc_test.exs
index 9e2d851..b234c88 100644
--- a/test/recode/task/moduledoc_test.exs
+++ b/test/recode/task/moduledoc_test.exs
@@ -113,7 +113,7 @@ defmodule Recode.Task.ModuledocTest do
       |> refute_issues()
     end
 
-    test "when @moduldoc in all modules" do
+    test "when @moduldoc is set in all modules" do
       ~s'''
       defmodule Bar.Foo do
         @moduledoc "BarFoo ..."
@@ -162,7 +162,24 @@ defmodule Recode.Task.ModuledocTest do
       end
       """
       |> run_task(Moduledoc)
-      |> assert_issue()
+      |> assert_issue_with(message: "The moudle Elixir.Bar.Foo is missing @moduledoc.")
+    end
+
+    test "when @moduldoc is empty" do
+      ~S'''
+      defmodule Bar.Foo do
+        @moduledoc """
+
+        """
+        def baz(x) do
+          {:ok, x}
+        end
+      end
+      '''
+      |> run_task(Moduledoc)
+      |> assert_issue_with(
+        message: "The @moudledoc attribute for moudle Elixir.Bar.Foo has no content."
+      )
     end
 
     test "when @moduldoc is missing in a module and inner modules" do
@@ -181,7 +198,7 @@ defmodule Recode.Task.ModuledocTest do
       end
       """
       |> run_task(Moduledoc)
-      |> assert_issues_with([[line: 1], [line: 3], [line: 6]])
+      |> assert_issues_with([[line: 6], [line: 3], [line: 1]])
     end
 
     test "when @moduldoc is missing in inner modules" do
@@ -201,7 +218,7 @@ defmodule Recode.Task.ModuledocTest do
       end
       """
       |> run_task(Moduledoc)
-      |> assert_issues_with([[line: 4], [line: 7]])
+      |> assert_issues_with([[line: 7], [line: 4]])
     end
 
     test "when @moduldoc is missing in module and innerst module" do
@@ -221,7 +238,7 @@ defmodule Recode.Task.ModuledocTest do
       end
       """
       |> run_task(Moduledoc)
-      |> assert_issues_with([[line: 1], [line: 7]])
+      |> assert_issues_with([[line: 7], [line: 1]])
     end
 
     test "when @moduldoc is missing in inner module" do
@@ -313,6 +330,24 @@ defmodule Recode.Task.ModuledocTest do
       |> run_task(Moduledoc)
       |> assert_issues_with([[line: 7]])
     end
+
+    test "when modules are optional" do
+      ~s'''
+      if foo do
+        defmodule Bar.Foo do
+          @moduledoc false
+
+          def baz(x), do: {:baz, x}
+        end
+
+        defmodule Baz do
+          def foo(x), do: {:foo, x}
+        end
+      end
+      '''
+      |> run_task(Moduledoc)
+      |> assert_issues_with([[line: 8]])
+    end
   end
 
   describe "init/1" do
diff --git a/test/recode/task/tags_test.exs b/test/recode/task/tags_test.exs
index 91d237f..7d13dd8 100644
--- a/test/recode/task/tags_test.exs
+++ b/test/recode/task/tags_test.exs
@@ -234,6 +234,18 @@ defmodule Recode.Task.TagsTest do
         message: "Found a tag: TODO: add b"
       )
     end
+
+    test "ignores doc function" do
+      ~s'''
+      defmodule Foo do
+        def doc([{:__block__, meta, [text]}]) do
+          text
+        end
+      end
+      '''
+      |> run_task(Tags, tag: "TODO", reporter: Tags)
+      |> refute_issues()
+    end
   end
 
   describe "init/1" do
diff --git a/test/support/recode_case.ex b/test/support/recode_case.ex
index 61b9f51..bd8731d 100644
--- a/test/support/recode_case.ex
+++ b/test/support/recode_case.ex
@@ -33,7 +33,9 @@ defmodule RecodeCase do
 
       Enum.each(keyword, fn {key, value} ->
         got = Map.fetch!(issue, key)
-        error = "Expected #{inspect(value)} for #{inspect(key)} in issue, got: #{inspect(got)}"
+
+        error =
+          "Expected #{inspect(value)} for key #{inspect(key)} in issue, got: #{inspect(got)}"
 
         assert got == value, error
       end)
@@ -48,7 +50,9 @@ defmodule RecodeCase do
       for {{_version, issue}, keyword} <- Enum.zip(source.issues, list),
           {key, value} <- keyword do
         got = Map.fetch!(issue, key)
-        error = "Expected #{inspect(value)} for #{inspect(key)} in issue, got: #{inspect(got)}"
+
+        error =
+          "Expected #{inspect(value)} for key #{inspect(key)} in issue, got: #{inspect(got)}"
 
         assert got == value, error
       end