├── test ├── test_helper.exs ├── doctest_formatter_test.exs ├── fixtures │ ├── escaped_quotes_desired_output.ex │ └── escaped_quotes.ex └── doctest_formatter │ ├── indentation_test.exs │ ├── parser_test.exs │ └── formatter_test.exs ├── .tool-versions ├── .editorconfig ├── smoke_test_data ├── project_with_formatted_code │ ├── expected_diff.diff │ ├── test │ │ ├── test_helper.exs │ │ └── project_with_formatted_code_test.exs │ ├── .formatter.exs │ ├── mix.exs │ ├── README.md │ ├── .gitignore │ └── lib │ │ └── project_with_formatted_code.ex ├── elixir-1-13 │ ├── project_with_formatted_code │ │ ├── expected_diff.diff │ │ ├── test │ │ │ ├── test_helper.exs │ │ │ └── project_with_formatted_code_test.exs │ │ ├── .formatter.exs │ │ ├── mix.exs │ │ ├── README.md │ │ ├── .gitignore │ │ └── lib │ │ │ └── project_with_formatted_code.ex │ ├── .tool-versions │ └── project_with_unformatted_code │ │ ├── test │ │ ├── test_helper.exs │ │ └── project_with_unformatted_code_test.exs │ │ ├── .formatter.exs │ │ ├── mix.exs │ │ ├── README.md │ │ ├── .gitignore │ │ ├── lib │ │ └── project_with_unformatted_code.ex │ │ └── expected_diff.diff └── project_with_unformatted_code │ ├── test │ ├── test_helper.exs │ └── project_with_unformatted_code_test.exs │ ├── .formatter.exs │ ├── mix.exs │ ├── README.md │ ├── .gitignore │ ├── lib │ └── project_with_unformatted_code.ex │ └── expected_diff.diff ├── assets └── mix-format.gif ├── .formatter.exs ├── lib ├── doctest_formatter │ ├── other_content.ex │ ├── doctest_expression.ex │ ├── indentation.ex │ ├── parser.ex │ └── formatter.ex └── doctest_formatter.ex ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── CHANGELOG.md ├── mix.exs ├── bin └── smoke_test.exs ├── mix.lock └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.19.0-otp-28 2 | erlang 28.1 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.diff] 2 | trim_trailing_whitespace=false 3 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_formatted_code/expected_diff.diff: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_formatted_code/expected_diff.diff: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_formatted_code/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.13.2-otp-24 2 | erlang 24.1.7 3 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_unformatted_code/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_formatted_code/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_unformatted_code/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /assets/mix-format.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angelikatyborska/doctest_formatter/HEAD/assets/mix-format.gif -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{bin,config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/doctest_formatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatterTest do 2 | use ExUnit.Case 3 | doctest DoctestFormatter 4 | end 5 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_formatted_code/test/project_with_formatted_code_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithFormattedCodeTest do 2 | use ExUnit.Case 3 | doctest ProjectWithFormattedCode 4 | end 5 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_formatted_code/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | plugins: [DoctestFormatter], 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_unformatted_code/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | plugins: [DoctestFormatter], 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_unformatted_code/test/project_with_unformatted_code_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithUnformattedCodeTest do 2 | use ExUnit.Case 3 | doctest ProjectWithUnformattedCode 4 | end 5 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_formatted_code/test/project_with_formatted_code_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithFormattedCodeTest do 2 | use ExUnit.Case 3 | doctest ProjectWithFormattedCode 4 | end 5 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_formatted_code/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | plugins: [DoctestFormatter], 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_unformatted_code/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | plugins: [DoctestFormatter], 4 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 5 | ] 6 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_unformatted_code/test/project_with_unformatted_code_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithUnformattedCodeTest do 2 | use ExUnit.Case 3 | doctest ProjectWithUnformattedCode 4 | end 5 | -------------------------------------------------------------------------------- /lib/doctest_formatter/other_content.ex: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatter.OtherContent do 2 | @moduledoc false 3 | 4 | defstruct [:lines] 5 | 6 | @type t :: %__MODULE__{ 7 | lines: [String.t()] 8 | } 9 | end 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | day: sunday 8 | time: "16:00" 9 | open-pull-requests-limit: 10 10 | cooldown: 11 | default-days: 7 12 | -------------------------------------------------------------------------------- /lib/doctest_formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatter do 2 | @moduledoc """ 3 | Elixir formatter plugin for formatting Elixir code in Markdown files. 4 | """ 5 | 6 | @behaviour Mix.Tasks.Format 7 | 8 | alias DoctestFormatter.Formatter 9 | 10 | def features(_opts) do 11 | [sigils: [], extensions: [".ex"]] 12 | end 13 | 14 | def format(contents, opts) do 15 | Formatter.format(contents, opts) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/doctest_formatter/doctest_expression.ex: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatter.DoctestExpression do 2 | @moduledoc false 3 | 4 | alias DoctestFormatter.Indentation 5 | 6 | defstruct([:lines, :result, :indentation, :iex_line_number]) 7 | 8 | @type t :: %__MODULE__{ 9 | lines: [String.t()], 10 | result: nil | [String.t()], 11 | indentation: Indentation.t(), 12 | iex_line_number: nil | pos_integer() 13 | } 14 | end 15 | -------------------------------------------------------------------------------- /test/fixtures/escaped_quotes_desired_output.ex: -------------------------------------------------------------------------------- 1 | defmodule EscapedQuotes do 2 | # this doctest will not be fully formatted 3 | @doc """ 4 | iex> %{ 5 | ...> data: "{\\"supply\\": 100}" 6 | ...> } 7 | %{data: 8 | "{\\"supply\\": 100}"} 9 | """ 10 | 11 | # but this one will 12 | @doc ~S""" 13 | iex> %{ 14 | ...> data: "{\"supply\": 100}" 15 | ...> } 16 | %{data: "{\"supply\": 100}"} 17 | """ 18 | end 19 | -------------------------------------------------------------------------------- /test/fixtures/escaped_quotes.ex: -------------------------------------------------------------------------------- 1 | defmodule EscapedQuotes do 2 | # this doctest will not be fully formatted 3 | @doc """ 4 | iex> %{ 5 | iex> data: "{\\"supply\\": 100}" 6 | iex> } 7 | %{data: 8 | "{\\"supply\\": 100}"} 9 | """ 10 | 11 | # but this one will 12 | @doc ~S""" 13 | iex> %{ 14 | ...> data: "{\"supply\": 100}" 15 | ...> } 16 | %{data: 17 | "{\"supply\": 100}"} 18 | """ 19 | end 20 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_formatted_code/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithFormattedCode.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :project_with_formatted_code, 7 | version: "0.1.0", 8 | start_permanent: Mix.env() == :prod, 9 | deps: deps() 10 | ] 11 | end 12 | 13 | # Run "mix help compile.app" to learn about applications. 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | # Run "mix help deps" to learn about dependencies. 21 | defp deps do 22 | [ 23 | {:doctest_formatter, path: "../..", runtime: false} 24 | ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_unformatted_code/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithUnformattedCode.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :project_with_unformatted_code, 7 | version: "0.1.0", 8 | start_permanent: Mix.env() == :prod, 9 | deps: deps() 10 | ] 11 | end 12 | 13 | # Run "mix help compile.app" to learn about applications. 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | # Run "mix help deps" to learn about dependencies. 21 | defp deps do 22 | [ 23 | {:doctest_formatter, path: "../..", runtime: false} 24 | ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_formatted_code/README.md: -------------------------------------------------------------------------------- 1 | # ProjectWithFormattedCode 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `project_with_formatted_code` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:project_with_formatted_code, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_formatted_code/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithFormattedCode.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :project_with_formatted_code, 7 | version: "0.1.0", 8 | start_permanent: Mix.env() == :prod, 9 | deps: deps() 10 | ] 11 | end 12 | 13 | # Run "mix help compile.app" to learn about applications. 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | # Run "mix help deps" to learn about dependencies. 21 | defp deps do 22 | [ 23 | {:doctest_formatter, path: "../../..", runtime: false} 24 | ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_unformatted_code/README.md: -------------------------------------------------------------------------------- 1 | # ProjectWithUnformattedCode 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `project_with_unformatted_code` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | { :project_with_unformatted_code, "~> 0.1.0" } 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_formatted_code/README.md: -------------------------------------------------------------------------------- 1 | # ProjectWithFormattedCode 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `project_with_formatted_code` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:project_with_formatted_code, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_unformatted_code/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithUnformattedCode.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :project_with_unformatted_code, 7 | version: "0.1.0", 8 | start_permanent: Mix.env() == :prod, 9 | deps: deps() 10 | ] 11 | end 12 | 13 | # Run "mix help compile.app" to learn about applications. 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | # Run "mix help deps" to learn about dependencies. 21 | defp deps do 22 | [ 23 | {:doctest_formatter, path: "../../..", runtime: false} 24 | ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_unformatted_code/README.md: -------------------------------------------------------------------------------- 1 | # ProjectWithUnformattedCode 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `project_with_unformatted_code` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | { :project_with_unformatted_code, "~> 0.1.0" } 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at . 21 | 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | doctest_formatter-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_formatted_code/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | project_with_formatted_code-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_unformatted_code/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | project_with_unformatted_code-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_formatted_code/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | project_with_formatted_code-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_unformatted_code/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | project_with_unformatted_code-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /lib/doctest_formatter/indentation.ex: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatter.Indentation do 2 | @moduledoc false 3 | 4 | @type t :: {:tabs | :spaces, integer} 5 | 6 | @spec detect_indentation(String.t()) :: t() 7 | def detect_indentation(line) do 8 | cond do 9 | String.starts_with?(line, " ") -> 10 | indentation = String.length(line) - String.length(String.trim_leading(line, " ")) 11 | {:spaces, indentation} 12 | 13 | String.starts_with?(line, "\t") -> 14 | indentation = String.length(line) - String.length(String.trim_leading(line, "\t")) 15 | {:tabs, indentation} 16 | 17 | true -> 18 | {:spaces, 0} 19 | end 20 | end 21 | 22 | def indent(line, {_, 0}) do 23 | line 24 | end 25 | 26 | def indent("", _), do: "" 27 | 28 | def indent(line, indentation) do 29 | indentation_string = 30 | case indentation do 31 | {:spaces, n} -> String.duplicate(" ", n) 32 | {:tabs, n} -> String.duplicate("\t", n) 33 | end 34 | 35 | indentation_string <> line 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Angelika Tyborska 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.4.1 (2025-09-08) 4 | 5 | - Fix trying to append to iodata (binary or charlist) with `Kernel.++/2` that only works for charlists. 6 | 7 | ## 0.4.0 (2025-05-09) 8 | 9 | - Stop adding a trailing space to empty `iex>` lines. 10 | 11 | ## 0.3.1 (2024-11-24) 12 | 13 | - Respect `line_length` option when formatting the whole `.ex` file. 14 | 15 | ## 0.3.0 (2024-04-01) 16 | 17 | - Support opaque types in doctest results (e.g. `#User`). 18 | - Do not crash when doctests contain double-escaped quotes. Instead, print a warning and leave the code snippet unformatted. 19 | 20 | ## 0.2.1 (2024-03-22) 21 | 22 | - Do not crash if doctest has no expected result. 23 | 24 | ## 0.2.0 (2024-02-27) 25 | 26 | - Support parsing multiline doctests with `iex>` on all lines, but reformat them using `...>` on every line but the first one. 27 | - Fix implementation for multiline results. Multiline results are allowed, and they can be terminated with an empty new line or another doctest. 28 | - Support exception expressions (`** (ModuleName) message`) in results. 29 | - Desired line length for doctest result now accounts for its indentation. 30 | - Support doctests with iex prompts with a line number, e.g.: `iex(1)>`. 31 | 32 | ## 0.1.0 (2024-02-25) 33 | 34 | - Initial release. 35 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_unformatted_code/lib/project_with_unformatted_code.ex: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithUnformattedCode do 2 | @moduledoc """ 3 | Documentation for `ProjectWithUnformattedCode`. 4 | 5 | iex> ProjectWithUnformattedCode.add(5,5) 6 | 10 7 | """ 8 | 9 | @doc """ 10 | Hello world. 11 | 12 | ## Examples 13 | 14 | iex> ProjectWithUnformattedCode.add(1, 2) 15 | 3 16 | 17 | iex> 1 18 | ...> |> ProjectWithUnformattedCode.add(2) 19 | 3 20 | 21 | """ 22 | def add(a, b) do 23 | a + b 24 | end 25 | 26 | @doc """ 27 | iex> ProjectWithUnformattedCode.subtract( 5, 4 ) 28 | 1 29 | 30 | iex> [100_000_000_000, 200_000_000_000, 300_000_000_000, 400_000_000_000, 500_000_000_000, 600_000_000_000, 700_000_000_000] 31 | ...> |> Enum.map(&ProjectWithUnformattedCode.subtract(&1, 100_000_000_000)) 32 | [0, 100_000_000_000, 200_000_000_000, 300_000_000_000, 400_000_000_000, 500_000_000_000, 600_000_000_000] 33 | """ 34 | def subtract(a, b) do 35 | a - b 36 | end 37 | 38 | defmodule User do 39 | @derive {Inspect, only: [:name]} 40 | defstruct [:name, :email] 41 | end 42 | 43 | @doc """ 44 | iex> ProjectWithUnformattedCode.alice() 45 | #ProjectWithUnformattedCode.User 46 | """ 47 | def alice do 48 | %User{name: "Alice", email: "alice99@example.com"} 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_unformatted_code/lib/project_with_unformatted_code.ex: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithUnformattedCode do 2 | @moduledoc """ 3 | Documentation for `ProjectWithUnformattedCode`. 4 | 5 | iex> ProjectWithUnformattedCode.add(5,5) 6 | 10 7 | """ 8 | 9 | @doc """ 10 | Hello world. 11 | 12 | ## Examples 13 | 14 | iex> ProjectWithUnformattedCode.add(1, 2) 15 | 3 16 | 17 | iex> 1 18 | ...> |> ProjectWithUnformattedCode.add(2) 19 | 3 20 | 21 | """ 22 | def add(a, b) do 23 | a + b 24 | end 25 | 26 | @doc """ 27 | iex> ProjectWithUnformattedCode.subtract( 5, 4 ) 28 | 1 29 | 30 | iex> [100_000_000_000, 200_000_000_000, 300_000_000_000, 400_000_000_000, 500_000_000_000, 600_000_000_000, 700_000_000_000] 31 | ...> |> Enum.map(&ProjectWithUnformattedCode.subtract(&1, 100_000_000_000)) 32 | [0, 100_000_000_000, 200_000_000_000, 300_000_000_000, 400_000_000_000, 500_000_000_000, 600_000_000_000] 33 | """ 34 | def subtract(a, b) do 35 | a - b 36 | end 37 | 38 | defmodule User do 39 | @derive {Inspect, only: [:name]} 40 | defstruct [:name, :email] 41 | end 42 | 43 | @doc """ 44 | iex> ProjectWithUnformattedCode.alice() 45 | #ProjectWithUnformattedCode.User 46 | """ 47 | def alice do 48 | %User{name: "Alice", email: "alice99@example.com"} 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatter.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :doctest_formatter, 7 | version: "0.4.1", 8 | start_permanent: Mix.env() == :prod, 9 | deps: deps(), 10 | name: "Doctest Formatter", 11 | source_url: "https://github.com/angelikatyborska/doctest_formatter/", 12 | description: description(), 13 | package: package(), 14 | docs: docs(), 15 | dialyzer: [plt_add_apps: [:mix]] 16 | ] 17 | end 18 | 19 | # Run "mix help compile.app" to learn about applications. 20 | def application do 21 | [ 22 | extra_applications: [:logger] 23 | ] 24 | end 25 | 26 | # Run "mix help deps" to learn about dependencies. 27 | defp deps do 28 | [ 29 | {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, 30 | {:ex_doc, "~> 0.31", only: :dev, runtime: false} 31 | ] 32 | end 33 | 34 | defp description() do 35 | "Elixir formatter plugin for doctests." 36 | end 37 | 38 | defp package() do 39 | [ 40 | name: "doctest_formatter", 41 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* CHANGELOG*), 42 | licenses: ["MIT"], 43 | links: %{ 44 | "GitHub" => "https://github.com/angelikatyborska/doctest_formatter", 45 | "Changelog" => 46 | "https://github.com/angelikatyborska/doctest_formatter/blob/main/CHANGELOG.md" 47 | } 48 | ] 49 | end 50 | 51 | defp docs do 52 | [ 53 | main: "readme", 54 | assets: "assets", 55 | extras: [ 56 | "README.md", 57 | "CHANGELOG.md" 58 | ] 59 | ] 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_formatted_code/lib/project_with_formatted_code.ex: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithFormattedCode do 2 | @moduledoc """ 3 | Documentation for `ProjectWithFormattedCode`. 4 | 5 | iex> ProjectWithFormattedCode.add(5, 5) 6 | 10 7 | """ 8 | 9 | @doc """ 10 | Hello world. 11 | 12 | ## Examples 13 | 14 | iex> ProjectWithFormattedCode.add(1, 2) 15 | 3 16 | 17 | iex> 1 18 | ...> |> ProjectWithFormattedCode.add(2) 19 | 3 20 | 21 | iex> ProjectWithFormattedCode.add(3, "3") 22 | ** (ArithmeticError) bad argument in arithmetic expression 23 | 24 | """ 25 | def add(a, b) do 26 | a + b 27 | end 28 | 29 | @doc """ 30 | iex> ProjectWithFormattedCode.subtract(5, 4) 31 | 1 32 | 33 | iex> [ 34 | ...> 100_000_000_000, 35 | ...> 200_000_000_000, 36 | ...> 300_000_000_000, 37 | ...> 400_000_000_000, 38 | ...> 500_000_000_000, 39 | ...> 600_000_000_000, 40 | ...> 700_000_000_000 41 | ...> ] 42 | ...> |> Enum.map(&ProjectWithFormattedCode.subtract(&1, 100_000_000_000)) 43 | [ 44 | 0, 45 | 100_000_000_000, 46 | 200_000_000_000, 47 | 300_000_000_000, 48 | 400_000_000_000, 49 | 500_000_000_000, 50 | 600_000_000_000 51 | ] 52 | """ 53 | def subtract(a, b) do 54 | a - b 55 | end 56 | 57 | defmodule User do 58 | @derive {Inspect, only: [:name]} 59 | defstruct [:name, :email] 60 | end 61 | 62 | @doc """ 63 | iex> ProjectWithFormattedCode.alice() 64 | #ProjectWithFormattedCode.User 65 | """ 66 | def alice do 67 | %User{name: "Alice", email: "alice99@example.com"} 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_formatted_code/lib/project_with_formatted_code.ex: -------------------------------------------------------------------------------- 1 | defmodule ProjectWithFormattedCode do 2 | @moduledoc """ 3 | Documentation for `ProjectWithFormattedCode`. 4 | 5 | iex> ProjectWithFormattedCode.add(5, 5) 6 | 10 7 | """ 8 | 9 | @doc """ 10 | Hello world. 11 | 12 | ## Examples 13 | 14 | iex> ProjectWithFormattedCode.add(1, 2) 15 | 3 16 | 17 | iex> 1 18 | ...> |> ProjectWithFormattedCode.add(2) 19 | 3 20 | 21 | iex> ProjectWithFormattedCode.add(3, "3") 22 | ** (ArithmeticError) bad argument in arithmetic expression 23 | 24 | """ 25 | def add(a, b) do 26 | a + b 27 | end 28 | 29 | @doc """ 30 | iex> ProjectWithFormattedCode.subtract(5, 4) 31 | 1 32 | 33 | iex> [ 34 | ...> 100_000_000_000, 35 | ...> 200_000_000_000, 36 | ...> 300_000_000_000, 37 | ...> 400_000_000_000, 38 | ...> 500_000_000_000, 39 | ...> 600_000_000_000, 40 | ...> 700_000_000_000 41 | ...> ] 42 | ...> |> Enum.map(&ProjectWithFormattedCode.subtract(&1, 100_000_000_000)) 43 | [ 44 | 0, 45 | 100_000_000_000, 46 | 200_000_000_000, 47 | 300_000_000_000, 48 | 400_000_000_000, 49 | 500_000_000_000, 50 | 600_000_000_000 51 | ] 52 | """ 53 | def subtract(a, b) do 54 | a - b 55 | end 56 | 57 | defmodule User do 58 | @derive {Inspect, only: [:name]} 59 | defstruct [:name, :email] 60 | end 61 | 62 | @doc """ 63 | iex> ProjectWithFormattedCode.alice() 64 | #ProjectWithFormattedCode.User 65 | """ 66 | def alice do 67 | %User{name: "Alice", email: "alice99@example.com"} 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /bin/smoke_test.exs: -------------------------------------------------------------------------------- 1 | IO.puts("Running smoke tests") 2 | IO.puts("\n\n") 3 | 4 | {diff, 0} = System.cmd("git", ["diff"]) 5 | 6 | if diff != "" do 7 | IO.puts( 8 | "There are unstaged changes. Stage them (git add) or remove them for this check to be able to work." 9 | ) 10 | 11 | System.halt(1) 12 | end 13 | 14 | {elixir_version, _} = System.cmd("elixir", ["--version"]) 15 | 16 | [_, major_version, minor_version, _patch_version] = 17 | Regex.run(~r/Elixir (\d+)\.(\d+)\.(\d+) \(compiled with/, elixir_version) 18 | 19 | major_version = String.to_integer(major_version) 20 | minor_version = String.to_integer(minor_version) 21 | 22 | projects = 23 | [ 24 | "project_with_formatted_code", 25 | "project_with_unformatted_code" 26 | ] 27 | 28 | Enum.each(projects, fn project -> 29 | project_path = 30 | cond do 31 | # Elixir 1.13 has separate test data because of significantly different formatting defaults 32 | major_version == 1 && minor_version <= 13 -> 33 | "smoke_test_data/elixir-1-13/#{project}" 34 | 35 | true -> 36 | "smoke_test_data/#{project}" 37 | end 38 | 39 | IO.puts("checking smoke_test_data/#{project}") 40 | 41 | if major_version == 1 && minor_version <= 13 do 42 | {_, 0} = System.cmd("mix", ["compile"], cd: project_path) 43 | end 44 | 45 | {_, 0} = System.cmd("mix", ["test"], cd: project_path) 46 | {_, 0} = System.cmd("mix", ["format"], cd: project_path) 47 | {diff, 0} = System.cmd("git", ["diff"], cd: project_path) 48 | 49 | expected_diff = File.read!("#{project_path}/expected_diff.diff") 50 | 51 | :code.delete(ExpectedDiff) 52 | :code.purge(ExpectedDiff) 53 | 54 | {"", 0} = 55 | System.cmd("git", ["checkout", "--", "."], cd: project_path) 56 | 57 | if diff == expected_diff do 58 | IO.puts("OK") 59 | else 60 | IO.puts( 61 | "Expected #{project} to have specific changes after running `mix format` in it (see `bin/smoke_test.exs`), the actual changes differ. Here's the myers difference between the actual and expected changes:" 62 | ) 63 | 64 | IO.inspect(String.myers_difference(diff, expected_diff)) 65 | 66 | System.halt(2) 67 | end 68 | end) 69 | -------------------------------------------------------------------------------- /smoke_test_data/project_with_unformatted_code/expected_diff.diff: -------------------------------------------------------------------------------- 1 | diff --git a/smoke_test_data/project_with_unformatted_code/lib/project_with_unformatted_code.ex b/smoke_test_data/project_with_unformatted_code/lib/project_with_unformatted_code.ex 2 | index 865085b..95fa7dc 100644 3 | --- a/smoke_test_data/project_with_unformatted_code/lib/project_with_unformatted_code.ex 4 | +++ b/smoke_test_data/project_with_unformatted_code/lib/project_with_unformatted_code.ex 5 | @@ -2,7 +2,7 @@ defmodule ProjectWithUnformattedCode do 6 | @moduledoc """ 7 | Documentation for `ProjectWithUnformattedCode`. 8 | 9 | - iex> ProjectWithUnformattedCode.add(5,5) 10 | + iex> ProjectWithUnformattedCode.add(5, 5) 11 | 10 12 | """ 13 | 14 | @@ -15,7 +15,7 @@ defmodule ProjectWithUnformattedCode do 15 | 3 16 | 17 | iex> 1 18 | - ...> |> ProjectWithUnformattedCode.add(2) 19 | + ...> |> ProjectWithUnformattedCode.add(2) 20 | 3 21 | 22 | """ 23 | @@ -24,12 +24,28 @@ defmodule ProjectWithUnformattedCode do 24 | end 25 | 26 | @doc """ 27 | - iex> ProjectWithUnformattedCode.subtract( 5, 4 ) 28 | + iex> ProjectWithUnformattedCode.subtract(5, 4) 29 | 1 30 | 31 | - iex> [100_000_000_000, 200_000_000_000, 300_000_000_000, 400_000_000_000, 500_000_000_000, 600_000_000_000, 700_000_000_000] 32 | + iex> [ 33 | + ...> 100_000_000_000, 34 | + ...> 200_000_000_000, 35 | + ...> 300_000_000_000, 36 | + ...> 400_000_000_000, 37 | + ...> 500_000_000_000, 38 | + ...> 600_000_000_000, 39 | + ...> 700_000_000_000 40 | + ...> ] 41 | ...> |> Enum.map(&ProjectWithUnformattedCode.subtract(&1, 100_000_000_000)) 42 | - [0, 100_000_000_000, 200_000_000_000, 300_000_000_000, 400_000_000_000, 500_000_000_000, 600_000_000_000] 43 | + [ 44 | + 0, 45 | + 100_000_000_000, 46 | + 200_000_000_000, 47 | + 300_000_000_000, 48 | + 400_000_000_000, 49 | + 500_000_000_000, 50 | + 600_000_000_000 51 | + ] 52 | """ 53 | def subtract(a, b) do 54 | a - b 55 | @@ -41,7 +57,7 @@ defmodule ProjectWithUnformattedCode do 56 | end 57 | 58 | @doc """ 59 | - iex> ProjectWithUnformattedCode.alice() 60 | + iex> ProjectWithUnformattedCode.alice() 61 | #ProjectWithUnformattedCode.User 62 | """ 63 | def alice do 64 | -------------------------------------------------------------------------------- /smoke_test_data/elixir-1-13/project_with_unformatted_code/expected_diff.diff: -------------------------------------------------------------------------------- 1 | diff --git a/smoke_test_data/elixir-1-13/project_with_unformatted_code/lib/project_with_unformatted_code.ex b/smoke_test_data/elixir-1-13/project_with_unformatted_code/lib/project_with_unformatted_code.ex 2 | index 865085b..95fa7dc 100644 3 | --- a/smoke_test_data/elixir-1-13/project_with_unformatted_code/lib/project_with_unformatted_code.ex 4 | +++ b/smoke_test_data/elixir-1-13/project_with_unformatted_code/lib/project_with_unformatted_code.ex 5 | @@ -2,7 +2,7 @@ defmodule ProjectWithUnformattedCode do 6 | @moduledoc """ 7 | Documentation for `ProjectWithUnformattedCode`. 8 | 9 | - iex> ProjectWithUnformattedCode.add(5,5) 10 | + iex> ProjectWithUnformattedCode.add(5, 5) 11 | 10 12 | """ 13 | 14 | @@ -15,7 +15,7 @@ defmodule ProjectWithUnformattedCode do 15 | 3 16 | 17 | iex> 1 18 | - ...> |> ProjectWithUnformattedCode.add(2) 19 | + ...> |> ProjectWithUnformattedCode.add(2) 20 | 3 21 | 22 | """ 23 | @@ -24,12 +24,28 @@ defmodule ProjectWithUnformattedCode do 24 | end 25 | 26 | @doc """ 27 | - iex> ProjectWithUnformattedCode.subtract( 5, 4 ) 28 | + iex> ProjectWithUnformattedCode.subtract(5, 4) 29 | 1 30 | 31 | - iex> [100_000_000_000, 200_000_000_000, 300_000_000_000, 400_000_000_000, 500_000_000_000, 600_000_000_000, 700_000_000_000] 32 | + iex> [ 33 | + ...> 100_000_000_000, 34 | + ...> 200_000_000_000, 35 | + ...> 300_000_000_000, 36 | + ...> 400_000_000_000, 37 | + ...> 500_000_000_000, 38 | + ...> 600_000_000_000, 39 | + ...> 700_000_000_000 40 | + ...> ] 41 | ...> |> Enum.map(&ProjectWithUnformattedCode.subtract(&1, 100_000_000_000)) 42 | - [0, 100_000_000_000, 200_000_000_000, 300_000_000_000, 400_000_000_000, 500_000_000_000, 600_000_000_000] 43 | + [ 44 | + 0, 45 | + 100_000_000_000, 46 | + 200_000_000_000, 47 | + 300_000_000_000, 48 | + 400_000_000_000, 49 | + 500_000_000_000, 50 | + 600_000_000_000 51 | + ] 52 | """ 53 | def subtract(a, b) do 54 | a - b 55 | @@ -41,7 +57,7 @@ defmodule ProjectWithUnformattedCode do 56 | end 57 | 58 | @doc """ 59 | - iex> ProjectWithUnformattedCode.alice() 60 | + iex> ProjectWithUnformattedCode.alice() 61 | #ProjectWithUnformattedCode.User 62 | """ 63 | def alice do 64 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 4 | "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, 5 | "ex_doc": {:hex, :ex_doc, "0.39.2", "da5549bbce34c5fb0811f829f9f6b7a13d5607b222631d9e989447096f295c57", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "62665526a88c207653dbcee2aac66c2c229d7c18a70ca4ffc7f74f9e01324daa"}, 6 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 8 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 10 | } 11 | -------------------------------------------------------------------------------- /test/doctest_formatter/indentation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatter.IndentationTest do 2 | use ExUnit.Case 3 | 4 | import DoctestFormatter.Indentation 5 | 6 | describe "detect_indentation/1" do 7 | test "tells apart tabs from spaces" do 8 | assert detect_indentation("\t") == {:tabs, 1} 9 | assert detect_indentation(" ") == {:spaces, 1} 10 | end 11 | 12 | test "counts tabs" do 13 | assert detect_indentation("\t") == {:tabs, 1} 14 | assert detect_indentation("\t\t") == {:tabs, 2} 15 | assert detect_indentation("\t\t\t") == {:tabs, 3} 16 | assert detect_indentation("\t\t\t\t\t\t\t\t\t\t\t\t\t") == {:tabs, 13} 17 | end 18 | 19 | test "counts spaces" do 20 | assert detect_indentation("") == {:spaces, 0} 21 | assert detect_indentation(" ") == {:spaces, 2} 22 | assert detect_indentation(" ") == {:spaces, 3} 23 | assert detect_indentation(" ") == {:spaces, 7} 24 | end 25 | 26 | test "stops counting after any other character" do 27 | assert detect_indentation("foo\tbar") == {:spaces, 0} 28 | assert detect_indentation("\tfoo\tbar") == {:tabs, 1} 29 | assert detect_indentation("\t\t- one") == {:tabs, 2} 30 | assert detect_indentation(" - one") == {:spaces, 3} 31 | assert detect_indentation(" ```elixir") == {:spaces, 4} 32 | end 33 | 34 | test "stops counting after the other indentation character" do 35 | assert detect_indentation("\t ") == {:tabs, 1} 36 | assert detect_indentation("\t\t ") == {:tabs, 2} 37 | assert detect_indentation("\t\t \t") == {:tabs, 2} 38 | assert detect_indentation(" \t ") == {:spaces, 3} 39 | assert detect_indentation(" \t") == {:spaces, 9} 40 | end 41 | end 42 | 43 | describe "indent/2" do 44 | test "no indentation" do 45 | assert indent("- foo", {:spaces, 0}) == "- foo" 46 | assert indent(" ### Bar", {:spaces, 0}) == " ### Bar" 47 | end 48 | 49 | test "tabs" do 50 | assert indent("- foo", {:tabs, 2}) == "\t\t- foo" 51 | assert indent("\t\t\tBar", {:tabs, 10}) == "\t\t\t\t\t\t\t\t\t\t\t\t\tBar" 52 | end 53 | 54 | test "spaces" do 55 | assert indent("def foo, do: 3", {:spaces, 2}) == " def foo, do: 3" 56 | assert indent(" 40_000", {:spaces, 7}) == " 40_000" 57 | end 58 | 59 | test "does not indent empty lines" do 60 | assert indent("", {:spaces, 1}) == "" 61 | assert indent("", {:tabs, 1}) == "" 62 | assert indent(" ", {:spaces, 1}) == " " 63 | assert indent(" \t\t", {:tabs, 1}) == "\t \t\t" 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Doctest Formatter 2 | 3 | ![GitHub Workflow status](https://github.com/angelikatyborska/doctest_formatter/actions/workflows/test.yml/badge.svg) 4 | ![version on Hex.pm](https://img.shields.io/hexpm/v/doctest_formatter) 5 | ![number of downloads on Hex.pm](https://img.shields.io/hexpm/dt/doctest_formatter) 6 | ![license on Hex.pm](https://img.shields.io/hexpm/l/doctest_formatter) 7 | 8 | An Elixir formatter for doctests. 9 | 10 | ![Running mix format formats your doctests](https://raw.github.com/angelikatyborska/doctest_formatter/main/assets/mix-format.gif) 11 | 12 | ## Installation 13 | 14 | The package can be installed by adding `doctest_formatter` to your list of dependencies in `mix.exs`: 15 | 16 | ```elixir 17 | def deps do 18 | [ 19 | {:doctest_formatter, "~> 0.4.1", runtime: false} 20 | ] 21 | end 22 | ``` 23 | 24 | Then, extend your `.formatter.exs` config file by adding the plugin. 25 | 26 | ```elixir 27 | # .formatter.exs 28 | [ 29 | plugins: [DoctestFormatter], 30 | inputs: [ 31 | # your usual inputs ... 32 | ] 33 | ] 34 | ``` 35 | 36 | Elixir 1.13.2 or up is required. Versions lower than 1.13 do not support formatter plugins, and versions 1.13.0 and 1.13.1 do not support formatter plugins for `.ex` files. 37 | 38 | ## Usage 39 | 40 | Run `mix format`. 41 | 42 | This formatter plugin will not only format the Elixir code inside the doctest, but it will format the test's `iex>` prompt as well. It will always use `iex>` for the first line of the test, and `...>` for each next line. For example: 43 | 44 | ```elixir 45 | @doc """ 46 | iex> "Hello, " <> 47 | iex> "World!" 48 | """ 49 | 50 | # becomes: 51 | 52 | @doc """ 53 | iex> "Hello, " <> 54 | ...> "World!" 55 | """ 56 | ``` 57 | 58 | ## Known limitations 59 | 60 | ### Double-escaped quotes 61 | 62 | This plugin cannot handle doctests with double-escaped quotes like this: 63 | 64 | ```elixir 65 | @doc """ 66 | iex> "\\"" 67 | ~S(") 68 | """ 69 | ``` 70 | 71 | The above is a valid doctest, but this plugin is unable to parse it into an AST and then correctly back into a string. Such cases will produce logger warnings. 72 | 73 | You can ignore the warnings and accept that this doctests won't be formatted, or you can try the below workaround. 74 | 75 | The workaround is to rewrite the whole `@doc`/`@moduledoc` attribute using the `sigil_S`, which does not allow escape characters. This doctest will work exactly the same as the one above, and it will get formatted by this plugin: 76 | 77 | ```elixir 78 | @doc ~S""" 79 | iex> "\"" 80 | ~S(") 81 | """ 82 | ``` 83 | 84 | ### Dynamic value 85 | 86 | This plugin will only format string literals and `s`/`S` sigil literal values of `@doc`/`@moduledoc`. It will not format strings with interpolation or other dynamic values. For example: 87 | 88 | ```elixir 89 | # will not be formatted: 90 | @moduledoc """ 91 | A prank calculator by #{author_name}. Always gives the wrong answer. 92 | 93 | iex> PrankCalculator.add(2,2) 94 | 5 95 | """ 96 | 97 | # will also not be formatted: 98 | @intermediate_module_attr "iex> PrankCalculator.add(2,2)\n5" 99 | @doc @intermediate_module_attr 100 | ``` 101 | 102 | ### Formatting conflicts 103 | 104 | This plugin needs to parse the whole `.ex` file into an AST and back into a string in order to be able to update the values of `@moduledoc` and `@doc`. When changing the AST back to the string, the code inevitably has to be formatted. Your formatter options are used in this process, so it shouldn't make any changes that the base Elixir formatter wouldn't, but it might conflict with other plugins that modify Elixir code too. 105 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-22.04 12 | continue-on-error: false 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - elixir: '1.14.0' 18 | otp: '25.0' 19 | current_version: false 20 | - elixir: '1.15.0' 21 | otp: '26.0' 22 | current_version: true 23 | - elixir: '1.16.0' 24 | otp: '26.2' 25 | current_version: true 26 | - elixir: '1.17.0' 27 | otp: '27.0' 28 | current_version: true 29 | - elixir: '1.18.1' 30 | otp: '27.2' 31 | current_version: true 32 | - elixir: '1.19.0' 33 | otp: '28.1' 34 | current_version: true 35 | 36 | steps: 37 | - name: Checkout code 38 | uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 39 | 40 | - name: Use Elixir 41 | uses: erlef/setup-beam@a34c98fd51e370b4d4981854aba1eb817ce4e483 42 | with: 43 | otp-version: ${{matrix.otp}} 44 | elixir-version: ${{matrix.elixir}} 45 | 46 | - name: Set cache key 47 | id: set_cache_key 48 | run: | 49 | erl -eval '{ok, Version} = file:read_file(filename:join([code:root_dir(), "releases", erlang:system_info(otp_release), "OTP_VERSION"])), io:fwrite(Version), halt().' -noshell > ERLANG_VERSION 50 | cat ERLANG_VERSION 51 | elixir --version | tail -n 1 > ELIXIR_VERSION 52 | cat ELIXIR_VERSION 53 | cache_key="os-${{ runner.os }}-erlang-$( sha256sum ERLANG_VERSION | cut -d ' ' -f 1 )-elixir-$( sha256sum ELIXIR_VERSION | cut -d ' ' -f 1 )-mix-lock-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }}" 54 | echo "::set-output name=cache_key::$cache_key" 55 | 56 | - name: Retrieve Mix Dependencies Cache 57 | uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf 58 | id: mix-cache # id to use in retrieve action 59 | with: 60 | path: deps 61 | key: mix-${{ steps.set_cache_key.outputs.cache_key }}-v1 62 | 63 | - name: Install Mix Dependencies 64 | if: steps.mix-cache.outputs.cache-hit != 'true' 65 | run: mix deps.get 66 | 67 | - name: Build Project 68 | run: mix 69 | 70 | - name: Retrieve PLT Cache 71 | uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf 72 | id: plt-cache 73 | with: 74 | path: priv/plts 75 | key: plts-${{ steps.set_cache_key.outputs.cache_key }}-v1 76 | 77 | - name: Create PLTs 78 | if: steps.plt-cache.outputs.cache-hit != 'true' 79 | run: | 80 | mkdir -p priv/plts 81 | mix dialyzer --plt 82 | 83 | - name: Run tests 84 | run: mix test 85 | 86 | - name: Run smoke tests 87 | run: elixir ./bin/smoke_test.exs 88 | 89 | - name: Check for compilation warnings 90 | run: mix compile --force --no-warnings 91 | 92 | - name: Run Dialyzer 93 | run: mix dialyzer 94 | if: ${{ matrix.current_version }} 95 | 96 | - name: Run format check 97 | run: mix format --check-formatted 98 | if: ${{ matrix.current_version }} 99 | 100 | all_tests_passing: 101 | if: ${{ always() }} 102 | runs-on: ubuntu-latest 103 | name: All tests passing on all versions 104 | needs: [test] 105 | steps: 106 | - run: exit 1 107 | # see https://stackoverflow.com/a/67532120/4907315 108 | if: >- 109 | ${{ 110 | contains(needs.*.result, 'failure') 111 | || contains(needs.*.result, 'cancelled') 112 | }} 113 | -------------------------------------------------------------------------------- /lib/doctest_formatter/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatter.Parser do 2 | @moduledoc false 3 | 4 | alias DoctestFormatter.DoctestExpression 5 | alias DoctestFormatter.OtherContent 6 | alias DoctestFormatter.Indentation 7 | 8 | @spec parse(String.t()) :: [DoctestExpression.t() | OtherContent.t()] 9 | def parse(content) do 10 | lines = String.split(content, "\n") 11 | 12 | parse_lines(lines, %{ 13 | in_doctest: false, 14 | in_doctest_result: false, 15 | chunks: [] 16 | }) 17 | end 18 | 19 | defp parse_lines([], acc) do 20 | acc.chunks 21 | |> Enum.reverse() 22 | |> Enum.map(fn content -> 23 | content = %{content | lines: Enum.reverse(content.lines)} 24 | 25 | case content do 26 | %DoctestExpression{result: result} when is_list(result) -> 27 | %{content | result: Enum.reverse(content.result)} 28 | 29 | content -> 30 | content 31 | end 32 | end) 33 | end 34 | 35 | defp parse_lines([line | rest], acc) do 36 | acc = 37 | cond do 38 | acc.in_doctest && acc.in_doctest_result -> 39 | handle_in_doctest_result(line, acc) 40 | 41 | acc.in_doctest -> 42 | handle_in_doctest(line, acc) 43 | 44 | !acc.in_doctest -> 45 | handle_not_in_doctest(line, acc) 46 | end 47 | 48 | parse_lines(rest, acc) 49 | end 50 | 51 | defp handle_in_doctest_result(line, acc) do 52 | cond do 53 | empty_line?(line) -> 54 | chunks = 55 | append_to_current_or_new_chunk_of_same_type(acc.chunks, %OtherContent{}, line) 56 | 57 | %{acc | in_doctest: false, in_doctest_result: false, chunks: chunks} 58 | 59 | start = doctest_start(line) -> 60 | {:start, code, iex_line_number} = start 61 | 62 | chunks = [ 63 | %DoctestExpression{ 64 | indentation: Indentation.detect_indentation(line), 65 | lines: [code], 66 | iex_line_number: iex_line_number 67 | } 68 | | acc.chunks 69 | ] 70 | 71 | %{acc | in_doctest: true, in_doctest_result: false, chunks: chunks} 72 | 73 | true -> 74 | # not empty line and not new doctest means it's result continuation 75 | chunks = 76 | update_current_chunk(acc.chunks, fn chunk -> 77 | %{chunk | result: [line | chunk.result]} 78 | end) 79 | 80 | %{acc | in_doctest_result: true, chunks: chunks} 81 | end 82 | end 83 | 84 | defp handle_in_doctest(line, acc) do 85 | case doctest_continuation(line) do 86 | nil -> 87 | chunks = 88 | append_to_current_or_new_chunk_of_same_type(acc.chunks, %OtherContent{}, line) 89 | 90 | %{acc | in_doctest: false, chunks: chunks} 91 | 92 | {:result, code} -> 93 | chunks = update_current_chunk(acc.chunks, fn chunk -> %{chunk | result: [code]} end) 94 | %{acc | in_doctest_result: true, chunks: chunks} 95 | 96 | {:continuation, code} -> 97 | chunks = append_to_current_chunk(acc.chunks, code) 98 | %{acc | chunks: chunks} 99 | end 100 | end 101 | 102 | defp handle_not_in_doctest(line, acc) do 103 | case doctest_start(line) do 104 | nil -> 105 | chunks = 106 | append_to_current_or_new_chunk_of_same_type(acc.chunks, %OtherContent{}, line) 107 | 108 | %{acc | chunks: chunks} 109 | 110 | {:start, code, iex_line_number} -> 111 | chunks = [ 112 | %DoctestExpression{ 113 | indentation: Indentation.detect_indentation(line), 114 | lines: [code], 115 | iex_line_number: iex_line_number 116 | } 117 | | acc.chunks 118 | ] 119 | 120 | %{acc | in_doctest: true, chunks: chunks} 121 | end 122 | end 123 | 124 | defp doctest_start(line) do 125 | # example matches: 126 | # iex> foo 127 | # iex>foo 128 | # iex()> foo 129 | # iex(43)> foo 130 | case Regex.run(~r/^(\s|\t)*(?:iex(?:\((\d*)\))*>)\s?(.*)$/, line) do 131 | nil -> 132 | nil 133 | 134 | [_, _indentation, iex_line_number, code | _] -> 135 | iex_line_number = 136 | case iex_line_number do 137 | "" -> 138 | nil 139 | 140 | _ -> 141 | String.to_integer(iex_line_number) 142 | end 143 | 144 | {:start, code, iex_line_number} 145 | end 146 | end 147 | 148 | defp doctest_continuation(line) do 149 | # example matches: 150 | # iex> foo 151 | # iex>foo 152 | # iex()> foo 153 | # iex(43)> foo 154 | # ...> foo 155 | # ...()> foo 156 | # ...(1)> foo 157 | # ...(1)> foo 158 | case Regex.run(~r/^(\s|\t)*(?:(?:(?:\.\.\.)|(?:iex))(?:\(\d*\))*>)\s?(.*)$/, line) do 159 | nil -> 160 | if String.trim(line) === "" do 161 | nil 162 | else 163 | {:result, line} 164 | end 165 | 166 | [_, _indentation, code | _] -> 167 | {:continuation, code} 168 | end 169 | end 170 | 171 | defp empty_line?(line) do 172 | String.trim(line) == "" 173 | end 174 | 175 | defp append_to_current_or_new_chunk_of_same_type([], struct, line) do 176 | [%{struct | lines: [line]}] 177 | end 178 | 179 | defp append_to_current_or_new_chunk_of_same_type( 180 | [%module{} = current_chunk | rest], 181 | %module{}, 182 | line 183 | ) do 184 | [%{current_chunk | lines: [line | current_chunk.lines]} | rest] 185 | end 186 | 187 | defp append_to_current_or_new_chunk_of_same_type(list, struct, line) do 188 | [%{struct | lines: [line]} | list] 189 | end 190 | 191 | defp append_to_current_chunk([current_chunk | rest], line) do 192 | [%{current_chunk | lines: [line | current_chunk.lines]} | rest] 193 | end 194 | 195 | defp update_current_chunk([current_chunk | rest], func) do 196 | [func.(current_chunk) | rest] 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /lib/doctest_formatter/formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatter.Formatter do 2 | @moduledoc false 3 | 4 | alias DoctestFormatter.{Parser, Indentation, OtherContent, DoctestExpression} 5 | require Logger 6 | 7 | defp default_elixir_line_length, do: 98 8 | 9 | @spec format(String.t(), keyword()) :: String.t() 10 | def format(content, opts) do 11 | to_quoted_opts = 12 | [ 13 | unescape: false, 14 | literal_encoder: &{:ok, {:__block__, &2, [&1]}}, 15 | token_metadata: true, 16 | emit_warnings: false 17 | ] ++ opts 18 | 19 | doc_metadata = %{ 20 | file: Keyword.get(opts, :file, "nofile"), 21 | loc_start: 0, 22 | attribute_name: nil 23 | } 24 | 25 | {forms, comments} = Code.string_to_quoted_with_comments!(content, to_quoted_opts) 26 | 27 | to_algebra_opts = [comments: comments, escape: false] ++ opts 28 | 29 | forms = 30 | Macro.prewalk(forms, fn node -> 31 | case node do 32 | {:@, meta1, [{attribute_name, meta2, [{:__block__, meta3, [doc_content]}]}]} 33 | when attribute_name in [:doc, :moduledoc] and is_binary(doc_content) -> 34 | doc_metadata = %{ 35 | doc_metadata 36 | | loc_start: Keyword.get(meta3, :line, 0), 37 | attribute_name: attribute_name 38 | } 39 | 40 | formatted_doc_content = format_doc_content(doc_content, opts, doc_metadata) 41 | {:@, meta1, [{attribute_name, meta2, [{:__block__, meta3, [formatted_doc_content]}]}]} 42 | 43 | {:@, meta1, [{attribute_name, meta2, [doc_content]}]} 44 | when attribute_name in [:doc, :moduledoc] and is_binary(doc_content) -> 45 | doc_metadata = %{ 46 | doc_metadata 47 | | loc_start: Keyword.get(meta2, :line, 0), 48 | attribute_name: attribute_name 49 | } 50 | 51 | formatted_doc_content = format_doc_content(doc_content, opts, doc_metadata) 52 | {:@, meta1, [{attribute_name, meta2, [formatted_doc_content]}]} 53 | 54 | {:@, meta1, 55 | [{attribute_name, meta2, [{sigil, meta3, [{:<<>>, meta4, [doc_content]}, []]}]}]} 56 | when attribute_name in [:doc, :moduledoc] and is_binary(doc_content) and 57 | sigil in [:sigil_S, :sigil_s] -> 58 | doc_metadata = %{ 59 | doc_metadata 60 | | loc_start: Keyword.get(meta4, :line, 0), 61 | attribute_name: attribute_name 62 | } 63 | 64 | formatted_doc_content = format_doc_content(doc_content, opts, doc_metadata) 65 | 66 | {:@, meta1, 67 | [ 68 | {attribute_name, meta2, 69 | [{sigil, meta3, [{:<<>>, meta4, [formatted_doc_content]}, []]}]} 70 | ]} 71 | 72 | node -> 73 | node 74 | end 75 | end) 76 | 77 | desired_line_length = Keyword.get(opts, :line_length, default_elixir_line_length()) 78 | 79 | forms 80 | |> Code.quoted_to_algebra(to_algebra_opts) 81 | |> Inspect.Algebra.format(desired_line_length) 82 | |> IO.iodata_to_binary() 83 | |> Kernel.<>("\n") 84 | end 85 | 86 | defp format_doc_content(doc_content, opts, doc_metadata) do 87 | Parser.parse(doc_content) 88 | |> Enum.flat_map(fn chunk -> 89 | case chunk do 90 | %OtherContent{} -> chunk.lines 91 | %DoctestExpression{} -> do_format_expression(chunk, opts, doc_metadata) 92 | end 93 | end) 94 | |> Enum.join("\n") 95 | end 96 | 97 | def do_format_expression(%DoctestExpression{result: nil} = chunk, opts, doc_metadata) do 98 | format_lines(chunk, opts, doc_metadata) 99 | end 100 | 101 | def do_format_expression(%DoctestExpression{} = chunk, opts, doc_metadata) do 102 | format_lines(chunk, opts, doc_metadata) ++ format_result(chunk, opts, doc_metadata) 103 | end 104 | 105 | defp format_lines(chunk, opts, doc_metadata) do 106 | desired_line_length = Keyword.get(opts, :line_length, default_elixir_line_length()) 107 | 108 | line_length = 109 | desired_line_length - elem(chunk.indentation, 1) - String.length(get_prompt(chunk, 0)) 110 | 111 | opts = Keyword.put(opts, :line_length, line_length) 112 | 113 | string = Enum.join(chunk.lines, "\n") 114 | 115 | case try_format_string(string, opts, doc_metadata) do 116 | {:ok, formatted} -> 117 | formatted 118 | |> IO.iodata_to_binary() 119 | |> String.split("\n") 120 | 121 | :error -> 122 | chunk.lines 123 | end 124 | |> Enum.with_index() 125 | |> Enum.map(fn {line, index} -> 126 | line_with_prompt = 127 | if line == "" do 128 | String.trim(get_prompt(chunk, index)) 129 | else 130 | get_prompt(chunk, index) <> line 131 | end 132 | 133 | Indentation.indent(line_with_prompt, chunk.indentation) 134 | end) 135 | end 136 | 137 | defp format_result(chunk, opts, doc_metadata) do 138 | desired_line_length = Keyword.get(opts, :line_length, default_elixir_line_length()) 139 | 140 | line_length = desired_line_length - elem(chunk.indentation, 1) 141 | opts = Keyword.put(opts, :line_length, line_length) 142 | 143 | string_result = 144 | chunk.result 145 | |> Enum.join("\n") 146 | 147 | if exception_result?(string_result) || opaque_type_result?(string_result) do 148 | string_result 149 | |> String.trim() 150 | |> String.split("\n") 151 | |> Enum.map(fn line -> 152 | Indentation.indent(line, chunk.indentation) 153 | end) 154 | else 155 | case try_format_string(string_result, opts, doc_metadata) do 156 | {:ok, formatted} -> 157 | formatted 158 | |> IO.iodata_to_binary() 159 | |> String.split("\n") 160 | |> Enum.map(fn line -> 161 | Indentation.indent(line, chunk.indentation) 162 | end) 163 | 164 | :error -> 165 | chunk.result 166 | end 167 | end 168 | end 169 | 170 | def exception_result?(string) do 171 | string |> String.trim() |> String.starts_with?("** (") 172 | end 173 | 174 | def opaque_type_result?(string) do 175 | string |> String.trim() |> String.match?(~r/#([A-z0-9\.]*)<(.*)>/) 176 | end 177 | 178 | defp get_prompt(chunk, line_index) do 179 | iex_line_number = 180 | if chunk.iex_line_number do 181 | "(#{chunk.iex_line_number})" 182 | else 183 | "" 184 | end 185 | 186 | prompt_text = 187 | if line_index == 0 do 188 | "iex" 189 | else 190 | "..." 191 | end 192 | 193 | "#{prompt_text}#{iex_line_number}> " 194 | end 195 | 196 | defp try_format_string(string, opts, doc_metadata) do 197 | try do 198 | {:ok, Code.format_string!(string, opts)} 199 | rescue 200 | error in SyntaxError -> 201 | message = 202 | """ 203 | The @#{doc_metadata.attribute_name} attribute on #{Path.relative_to_cwd(doc_metadata.file)}:#{doc_metadata.loc_start} contains a doctest with some code that couldn't be formatted. 204 | 205 | The code: 206 | 207 | #{string} 208 | 209 | The error: 210 | 211 | #{inspect(error, pretty: true)} 212 | 213 | If this doctests compiles and passes when running `mix test`, then the problem lies with the formatter plugin `doctest_formatter`. Please check the list on known limitations of the plugin (https://github.com/angelikatyborska/doctest_formatter/#known-limitations). If none of them apply to your code, please open an issue (https://github.com/angelikatyborska/doctest_formatter/issues). 214 | """ 215 | 216 | Logger.warning(message) 217 | 218 | :error 219 | end 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /test/doctest_formatter/parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatter.ParserTest do 2 | use ExUnit.Case 3 | 4 | import DoctestFormatter.Parser 5 | alias DoctestFormatter.DoctestExpression 6 | alias DoctestFormatter.OtherContent 7 | 8 | describe "parse/1" do 9 | test "one line, no code" do 10 | assert parse("") == [%OtherContent{lines: [""]}] 11 | assert parse("abc") == [%OtherContent{lines: ["abc"]}] 12 | assert parse(" ") == [%OtherContent{lines: [" "]}] 13 | end 14 | 15 | test "few lines, no code" do 16 | assert parse("# Hello, World!\n\nLorem ipsum.") == [ 17 | %OtherContent{lines: ["# Hello, World!", "", "Lorem ipsum."]} 18 | ] 19 | 20 | assert parse(" - one\n - two\n") == [%OtherContent{lines: [" - one", " - two", ""]}] 21 | end 22 | 23 | test "a single single-line doctest with other content in between" do 24 | assert parse("- foo\n- bar\niex> 1 + 2\n3") == [ 25 | %OtherContent{lines: ["- foo", "- bar"]}, 26 | %DoctestExpression{lines: ["1 + 2"], result: ["3"], indentation: {:spaces, 0}} 27 | ] 28 | 29 | assert parse("iex>1 + 2\n3\n\n- foo\n- bar") == [ 30 | %DoctestExpression{lines: ["1 + 2"], result: ["3"], indentation: {:spaces, 0}}, 31 | %OtherContent{lines: ["", "- foo", "- bar"]} 32 | ] 33 | 34 | assert parse("# Hello, world!\n iex> 1 + 2\n 3\n\n## Goodbye, Mars!") == [ 35 | %OtherContent{lines: ["# Hello, world!"]}, 36 | %DoctestExpression{lines: ["1 + 2"], result: [" 3"], indentation: {:spaces, 2}}, 37 | %OtherContent{lines: ["", "## Goodbye, Mars!"]} 38 | ] 39 | end 40 | 41 | test "a single single-line doctest" do 42 | assert parse("iex> 1 + 2\n3") == [ 43 | %DoctestExpression{lines: ["1 + 2"], result: ["3"], indentation: {:spaces, 0}} 44 | ] 45 | 46 | assert parse("iex>1 + 2\n3") == [ 47 | %DoctestExpression{lines: ["1 + 2"], result: ["3"], indentation: {:spaces, 0}} 48 | ] 49 | 50 | assert parse(" iex> 1 + 2\n 3") == [ 51 | %DoctestExpression{lines: [" 1 + 2"], result: [" 3"], indentation: {:spaces, 2}} 52 | ] 53 | 54 | assert parse(" iex> 1 + 2\n3") == [ 55 | %DoctestExpression{lines: ["1 + 2"], result: ["3"], indentation: {:spaces, 4}} 56 | ] 57 | end 58 | 59 | test "a single multi-line doctest" do 60 | assert parse("iex> 1 +\n...> 2 +\n...> 4\n7") == [ 61 | %DoctestExpression{ 62 | lines: ["1 +", "2 +", "4"], 63 | result: ["7"], 64 | indentation: {:spaces, 0} 65 | } 66 | ] 67 | 68 | assert parse("iex>1 +\n...>2 +\n...>4\n7") == [ 69 | %DoctestExpression{ 70 | lines: ["1 +", "2 +", "4"], 71 | result: ["7"], 72 | indentation: {:spaces, 0} 73 | } 74 | ] 75 | 76 | assert parse(" iex> 1 +\n ...> 2 +\n ...> 4\n 7") == [ 77 | %DoctestExpression{ 78 | lines: [" 1 +", " 2 +", " 4"], 79 | result: [" 7"], 80 | indentation: {:spaces, 2} 81 | } 82 | ] 83 | 84 | assert parse(" iex> 1 +\n...> 2 +\n ...> 4\n 7") == [ 85 | %DoctestExpression{ 86 | lines: ["1 +", "2 +", "4"], 87 | result: [" 7"], 88 | indentation: {:spaces, 6} 89 | } 90 | ] 91 | end 92 | 93 | test "a single multi-line doctest with other content in between" do 94 | assert parse("a\nb\niex> 1 +\n...> 2 +\n...> 4\n7") == [ 95 | %OtherContent{ 96 | lines: ["a", "b"] 97 | }, 98 | %DoctestExpression{ 99 | lines: ["1 +", "2 +", "4"], 100 | result: ["7"], 101 | indentation: {:spaces, 0} 102 | } 103 | ] 104 | 105 | assert parse("iex>1 +\n...>2 +\n...>4\n7\n\na\nb\n") == [ 106 | %DoctestExpression{ 107 | lines: ["1 +", "2 +", "4"], 108 | result: ["7"], 109 | indentation: {:spaces, 0} 110 | }, 111 | %OtherContent{ 112 | lines: ["", "a", "b", ""] 113 | } 114 | ] 115 | 116 | assert parse( 117 | "for example `iex>`, like this:\n iex> 1 +\n ...> 2 +\n ...> 4\n 7\n\na\nb\n" 118 | ) == [ 119 | %OtherContent{ 120 | lines: ["for example `iex>`, like this:"] 121 | }, 122 | %DoctestExpression{ 123 | lines: ["1 +", "2 +", "4"], 124 | result: [" 7"], 125 | indentation: {:spaces, 2} 126 | }, 127 | %OtherContent{ 128 | lines: ["", "a", "b", ""] 129 | } 130 | ] 131 | end 132 | 133 | test "a single multi-line doctest with 'iex>' on all lines" do 134 | assert parse("iex> 1 +\niex> 2 +\niex> 4\n7") == [ 135 | %DoctestExpression{ 136 | lines: ["1 +", "2 +", "4"], 137 | result: ["7"], 138 | indentation: {:spaces, 0} 139 | } 140 | ] 141 | 142 | assert parse("iex>1 +\niex>2 +\niex>4\n7") == [ 143 | %DoctestExpression{ 144 | lines: ["1 +", "2 +", "4"], 145 | result: ["7"], 146 | indentation: {:spaces, 0} 147 | } 148 | ] 149 | 150 | assert parse(" iex> 1 +\n iex> 2 +\n iex> 4\n 7") == [ 151 | %DoctestExpression{ 152 | lines: ["1 +", "2 +", "4"], 153 | result: [" 7"], 154 | indentation: {:spaces, 2} 155 | } 156 | ] 157 | 158 | assert parse(" iex> 1 +\niex> 2 +\n iex> 4\n 7") == [ 159 | %DoctestExpression{ 160 | lines: ["1 +", "2 +", "4"], 161 | result: [" 7"], 162 | indentation: {:spaces, 6} 163 | } 164 | ] 165 | end 166 | 167 | test "doctests without results" do 168 | assert parse(" iex> 1 + 2\n") == [ 169 | %DoctestExpression{lines: ["1 + 2"], result: nil, indentation: {:spaces, 4}}, 170 | %DoctestFormatter.OtherContent{lines: [""]} 171 | ] 172 | 173 | assert parse(" iex> 1 + 2\n \n\n# Heading") == [ 174 | %DoctestExpression{lines: ["1 + 2"], result: nil, indentation: {:spaces, 4}}, 175 | %OtherContent{lines: [" ", "", "# Heading"]} 176 | ] 177 | 178 | assert parse(" iex> 1 +\n...> 2 +\n ...> 4\n ") == [ 179 | %DoctestExpression{ 180 | lines: ["1 +", "2 +", "4"], 181 | result: nil, 182 | indentation: {:spaces, 6} 183 | }, 184 | %OtherContent{lines: [" "]} 185 | ] 186 | end 187 | 188 | test "multiple doctests" do 189 | assert parse(" iex> 1 + 2\n3\niex> 4 + 1\n5") == [ 190 | %DoctestExpression{lines: ["1 + 2"], result: ["3"], indentation: {:spaces, 4}}, 191 | %DoctestExpression{lines: ["4 + 1"], result: ["5"], indentation: {:spaces, 0}} 192 | ] 193 | 194 | assert parse(" iex> 1 + 2\n\niex> 4 + 1\n") == [ 195 | %DoctestExpression{lines: ["1 + 2"], result: nil, indentation: {:spaces, 4}}, 196 | %DoctestFormatter.OtherContent{lines: [""]}, 197 | %DoctestExpression{lines: ["4 + 1"], result: nil, indentation: {:spaces, 0}}, 198 | %DoctestFormatter.OtherContent{lines: [""]} 199 | ] 200 | 201 | assert parse(" iex> 1 + 2\n\n\niex> 4 + 1\n") == [ 202 | %DoctestExpression{lines: ["1 + 2"], result: nil, indentation: {:spaces, 4}}, 203 | %OtherContent{lines: ["", ""]}, 204 | %DoctestExpression{lines: ["4 + 1"], result: nil, indentation: {:spaces, 0}}, 205 | %DoctestFormatter.OtherContent{lines: [""]} 206 | ] 207 | 208 | assert parse(" iex> 1 + 2\n\na\nb\niex> 1 +\n...> 2 +\n...> 4\n7") == [ 209 | %DoctestExpression{lines: ["1 + 2"], result: nil, indentation: {:spaces, 4}}, 210 | %OtherContent{ 211 | lines: ["", "a", "b"] 212 | }, 213 | %DoctestExpression{ 214 | lines: ["1 +", "2 +", "4"], 215 | result: ["7"], 216 | indentation: {:spaces, 0} 217 | } 218 | ] 219 | 220 | assert parse("foo\n iex> 1 + 2\n\na\nb\niex> 1 +\n...> 2 +\n...> 4\n7") == [ 221 | %OtherContent{ 222 | lines: ["foo"] 223 | }, 224 | %DoctestExpression{lines: ["1 + 2"], result: nil, indentation: {:spaces, 4}}, 225 | %OtherContent{ 226 | lines: ["", "a", "b"] 227 | }, 228 | %DoctestExpression{ 229 | lines: ["1 +", "2 +", "4"], 230 | result: ["7"], 231 | indentation: {:spaces, 0} 232 | } 233 | ] 234 | 235 | assert parse(" iex> 1 + 2\n\niex> 1 +\n...> 2 +\n...> 4\n7") == [ 236 | %DoctestExpression{lines: ["1 + 2"], result: nil, indentation: {:spaces, 4}}, 237 | %DoctestFormatter.OtherContent{lines: [""]}, 238 | %DoctestExpression{ 239 | lines: ["1 +", "2 +", "4"], 240 | result: ["7"], 241 | indentation: {:spaces, 0} 242 | } 243 | ] 244 | 245 | assert parse(" iex> 1 + 2\n\niex> 1 +\n...> 2 +\n...> 4\n7\niex> 4 +\n...> 1\n5") == [ 246 | %DoctestExpression{lines: ["1 + 2"], result: nil, indentation: {:spaces, 4}}, 247 | %DoctestFormatter.OtherContent{lines: [""]}, 248 | %DoctestExpression{ 249 | lines: ["1 +", "2 +", "4"], 250 | result: ["7"], 251 | indentation: {:spaces, 0} 252 | }, 253 | %DoctestExpression{ 254 | lines: ["4 +", "1"], 255 | result: ["5"], 256 | indentation: {:spaces, 0} 257 | } 258 | ] 259 | end 260 | 261 | test "a single doctest with multiline results" do 262 | assert parse("iex> ~T[01:02:03]\n%Time{\n hour: 1\n minute: 2\n second: 3\n}") == [ 263 | %DoctestExpression{ 264 | lines: ["~T[01:02:03]"], 265 | result: ["%Time{", " hour: 1", " minute: 2", " second: 3", "}"], 266 | indentation: {:spaces, 0} 267 | } 268 | ] 269 | 270 | assert parse("iex> ~T[01:02:03]\n%Time{\n hour: 1\n minute: 2\n second: 3\n}\n") == [ 271 | %DoctestExpression{ 272 | lines: ["~T[01:02:03]"], 273 | result: ["%Time{", " hour: 1", " minute: 2", " second: 3", "}"], 274 | indentation: {:spaces, 0} 275 | }, 276 | %DoctestFormatter.OtherContent{lines: [""]} 277 | ] 278 | end 279 | 280 | test "a single doctest with multiline results with other content in between" do 281 | assert parse( 282 | "## examples\niex> ~T[01:02:03]\n%Time{\n hour: 1\n minute: 2\n second: 3\n}\n\n## something else" 283 | ) == [ 284 | %OtherContent{ 285 | lines: ["## examples"] 286 | }, 287 | %DoctestExpression{ 288 | lines: ["~T[01:02:03]"], 289 | result: ["%Time{", " hour: 1", " minute: 2", " second: 3", "}"], 290 | indentation: {:spaces, 0} 291 | }, 292 | %OtherContent{ 293 | lines: ["", "## something else"] 294 | } 295 | ] 296 | 297 | assert parse( 298 | "## examples\niex> ~T[01:02:03]\n%Time{\n hour: 1\n minute: 2\n second: 3\n}\n \n## something else" 299 | ) == [ 300 | %OtherContent{ 301 | lines: ["## examples"] 302 | }, 303 | %DoctestExpression{ 304 | lines: ["~T[01:02:03]"], 305 | result: ["%Time{", " hour: 1", " minute: 2", " second: 3", "}"], 306 | indentation: {:spaces, 0} 307 | }, 308 | %OtherContent{ 309 | lines: [" ", "## something else"] 310 | } 311 | ] 312 | end 313 | 314 | test "multiple doctests with multiline results" do 315 | assert parse( 316 | " iex> ~T[01:02:03]\n%Time{\n hour: 1\n minute: 2\n second: 3\n}\niex> 4 + 1\n2 +\n3" 317 | ) == [ 318 | %DoctestExpression{ 319 | lines: ["~T[01:02:03]"], 320 | result: ["%Time{", " hour: 1", " minute: 2", " second: 3", "}"], 321 | indentation: {:spaces, 4} 322 | }, 323 | %DoctestExpression{ 324 | lines: ["4 + 1"], 325 | result: ["2 +", "3"], 326 | indentation: {:spaces, 0} 327 | } 328 | ] 329 | end 330 | 331 | test "trailing newline" do 332 | assert parse(" iex> 1 + 2\n3\niex> 4 + 1\n5\n") == [ 333 | %DoctestExpression{lines: ["1 + 2"], result: ["3"], indentation: {:spaces, 4}}, 334 | %DoctestExpression{lines: ["4 + 1"], result: ["5"], indentation: {:spaces, 0}}, 335 | %OtherContent{lines: [""]} 336 | ] 337 | 338 | assert parse("foo\n") == [ 339 | %OtherContent{lines: ["foo", ""]} 340 | ] 341 | end 342 | 343 | test "with iex(n)>" do 344 | assert parse(" iex(3)> 1 + 2\n3") == [ 345 | %DoctestExpression{ 346 | lines: ["1 + 2"], 347 | result: ["3"], 348 | iex_line_number: 3, 349 | indentation: {:spaces, 4} 350 | } 351 | ] 352 | 353 | assert parse("iex(14)> 1 +\niex(2)> 2\n3") == [ 354 | %DoctestExpression{ 355 | lines: ["1 +", "2"], 356 | result: ["3"], 357 | iex_line_number: 14, 358 | indentation: {:spaces, 0} 359 | } 360 | ] 361 | 362 | assert parse("iex(6)> 1 +\n...(6)> 2\n3") == [ 363 | %DoctestExpression{ 364 | lines: ["1 +", "2"], 365 | result: ["3"], 366 | iex_line_number: 6, 367 | indentation: {:spaces, 0} 368 | } 369 | ] 370 | 371 | assert parse(" iex(6)> 1 +\n ...(7)> 2\n3") == [ 372 | %DoctestExpression{ 373 | lines: ["1 +", "2"], 374 | result: ["3"], 375 | iex_line_number: 6, 376 | indentation: {:spaces, 2} 377 | } 378 | ] 379 | end 380 | 381 | test "iex()> counts as no line number" do 382 | assert parse("iex()> 1 + 2\n3") == [ 383 | %DoctestExpression{ 384 | lines: ["1 + 2"], 385 | result: ["3"], 386 | iex_line_number: nil, 387 | indentation: {:spaces, 0} 388 | } 389 | ] 390 | 391 | assert parse(" iex()> 1 +\n iex()> 2\n3") == [ 392 | %DoctestExpression{ 393 | lines: ["1 +", "2"], 394 | result: ["3"], 395 | iex_line_number: nil, 396 | indentation: {:spaces, 2} 397 | } 398 | ] 399 | 400 | assert parse("iex()> 1 +\n...()> 2\n3") == [ 401 | %DoctestExpression{ 402 | lines: ["1 +", "2"], 403 | result: ["3"], 404 | iex_line_number: nil, 405 | indentation: {:spaces, 0} 406 | } 407 | ] 408 | end 409 | end 410 | end 411 | -------------------------------------------------------------------------------- /test/doctest_formatter/formatter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DoctestFormatter.FormatterTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureLog 5 | import DoctestFormatter.Formatter 6 | 7 | test "exception_result?/1" do 8 | assert exception_result?("** (RuntimeError) some error") 9 | assert exception_result?(" ** (RuntimeError) some error ") 10 | assert exception_result?("\t** (SomeError) ") 11 | assert exception_result?(" ** ( ") 12 | 13 | refute exception_result?("**") 14 | refute exception_result?("*") 15 | refute exception_result?("") 16 | refute exception_result?("3 + 4") 17 | end 18 | 19 | test "opaque_type_result?/1" do 20 | assert opaque_type_result?("#User<>") 21 | assert opaque_type_result?(" #User<> ") 22 | assert opaque_type_result?("\t#User<> ") 23 | assert opaque_type_result?("#Accounts.User<>") 24 | assert opaque_type_result?("#Accounts.User") 25 | assert opaque_type_result?("#Accounts.User") 26 | assert opaque_type_result?("#SomeModule345<>") 27 | assert opaque_type_result?("#Foo.SomeModule345<>") 28 | assert opaque_type_result?("#DateTime<2023-06-26 09:30:00+09:00 JST Asia/Tokyo>") 29 | 30 | refute opaque_type_result?("#") 31 | refute opaque_type_result?("# ") 32 | refute opaque_type_result?("# comment") 33 | refute opaque_type_result?("#not_a_module") 34 | refute opaque_type_result?("") 35 | refute opaque_type_result?("3 + 2") 36 | end 37 | 38 | describe "format/2 when no doctests" do 39 | test "works for empty strings" do 40 | assert format("", []) == "\n" 41 | end 42 | 43 | test "doesn't do anything when no docs and already formatted" do 44 | input = 45 | """ 46 | defmodule Foo do 47 | @spec add(a :: integer, b :: integer) :: integer 48 | def add(a, b) do 49 | a + b 50 | end 51 | end 52 | """ 53 | 54 | output = format(input, []) 55 | assert output == input 56 | end 57 | 58 | test "formats all elixir code because I don't know how not to do it" do 59 | input = 60 | """ 61 | defmodule Foo do 62 | @spec add(a :: integer, b :: integer) :: integer 63 | def add(a,b) do 64 | a+b 65 | end 66 | 67 | 68 | end 69 | """ 70 | 71 | desired_output = 72 | """ 73 | defmodule Foo do 74 | @spec add(a :: integer, b :: integer) :: integer 75 | def add(a, b) do 76 | a + b 77 | end 78 | end 79 | """ 80 | 81 | output = format(input, []) 82 | assert output == desired_output 83 | end 84 | 85 | test "does not remove comments" do 86 | assert format("# foo", []) == "# foo\n" 87 | end 88 | 89 | test "keeps only one newline" do 90 | assert format("\n\n", []) == "\n" 91 | end 92 | end 93 | 94 | describe "format/2 on @docs" do 95 | test "formats doctests in docs, multiline string" do 96 | input = 97 | """ 98 | defmodule Foo do 99 | @doc \""" 100 | It adds two numbers together 101 | iex> Foo.add(4,2) 102 | 6 103 | \""" 104 | @spec add(a :: integer, b :: integer) :: integer 105 | def add(a, b) do 106 | a + b 107 | end 108 | end 109 | """ 110 | 111 | desired_output = 112 | """ 113 | defmodule Foo do 114 | @doc \""" 115 | It adds two numbers together 116 | iex> Foo.add(4, 2) 117 | 6 118 | \""" 119 | @spec add(a :: integer, b :: integer) :: integer 120 | def add(a, b) do 121 | a + b 122 | end 123 | end 124 | """ 125 | 126 | output = format(input, []) 127 | assert output == desired_output 128 | end 129 | 130 | test "formats doctests in docs, single line string" do 131 | input = 132 | """ 133 | defmodule Foo do 134 | @doc \"It adds two numbers together\niex> Foo.add(4,2)\n6\" 135 | @spec add(a :: integer, b :: integer) :: integer 136 | def add(a, b) do 137 | a + b 138 | end 139 | end 140 | """ 141 | 142 | desired_output = 143 | """ 144 | defmodule Foo do 145 | @doc \"It adds two numbers together\niex> Foo.add(4, 2)\n6\" 146 | @spec add(a :: integer, b :: integer) :: integer 147 | def add(a, b) do 148 | a + b 149 | end 150 | end 151 | """ 152 | 153 | output = format(input, []) 154 | assert output == desired_output 155 | end 156 | 157 | test "formats doctests in docs, lowercase s sigil string" do 158 | input = 159 | """ 160 | defmodule Foo do 161 | @doc ~s/It adds two numbers together\niex> Foo.add(4,2)\n6/ 162 | @spec add(a :: integer, b :: integer) :: integer 163 | def add(a, b) do 164 | a + b 165 | end 166 | end 167 | """ 168 | 169 | desired_output = 170 | """ 171 | defmodule Foo do 172 | @doc ~s/It adds two numbers together\niex> Foo.add(4, 2)\n6/ 173 | @spec add(a :: integer, b :: integer) :: integer 174 | def add(a, b) do 175 | a + b 176 | end 177 | end 178 | """ 179 | 180 | output = format(input, []) 181 | assert output == desired_output 182 | end 183 | 184 | test "formats doctests in docs, uppercase s sigil string" do 185 | input = 186 | """ 187 | defmodule Foo do 188 | @doc ~S/It adds two numbers together 189 | iex> Foo.add(4,2) 190 | 6 191 | / 192 | @spec add(a :: integer, b :: integer) :: integer 193 | def add(a, b) do 194 | a + b 195 | end 196 | end 197 | """ 198 | 199 | desired_output = 200 | """ 201 | defmodule Foo do 202 | @doc ~S/It adds two numbers together 203 | iex> Foo.add(4, 2) 204 | 6 205 | / 206 | @spec add(a :: integer, b :: integer) :: integer 207 | def add(a, b) do 208 | a + b 209 | end 210 | end 211 | """ 212 | 213 | output = format(input, []) 214 | assert output == desired_output 215 | end 216 | end 217 | 218 | describe "format/2 on @moduledocs" do 219 | test "formats doctests in moduledocs, multiline string" do 220 | input = 221 | """ 222 | defmodule Foo do 223 | @moduledoc \""" 224 | It adds two numbers together 225 | iex> Foo.add(4,2) 226 | 6 227 | \""" 228 | @spec add(a :: integer, b :: integer) :: integer 229 | def add(a, b) do 230 | a + b 231 | end 232 | end 233 | """ 234 | 235 | desired_output = 236 | """ 237 | defmodule Foo do 238 | @moduledoc \""" 239 | It adds two numbers together 240 | iex> Foo.add(4, 2) 241 | 6 242 | \""" 243 | @spec add(a :: integer, b :: integer) :: integer 244 | def add(a, b) do 245 | a + b 246 | end 247 | end 248 | """ 249 | 250 | output = format(input, []) 251 | assert output == desired_output 252 | end 253 | 254 | test "formats doctests in moduledocs, single line string" do 255 | input = 256 | """ 257 | defmodule Foo do 258 | @moduledoc \"It adds two numbers together\niex> Foo.add(4,2)\n6\" 259 | @spec add(a :: integer, b :: integer) :: integer 260 | def add(a, b) do 261 | a + b 262 | end 263 | end 264 | """ 265 | 266 | desired_output = 267 | """ 268 | defmodule Foo do 269 | @moduledoc \"It adds two numbers together\niex> Foo.add(4, 2)\n6\" 270 | @spec add(a :: integer, b :: integer) :: integer 271 | def add(a, b) do 272 | a + b 273 | end 274 | end 275 | """ 276 | 277 | output = format(input, []) 278 | assert output == desired_output 279 | end 280 | 281 | test "formats doctests in moduledocs, lowercase s sigil string" do 282 | input = 283 | """ 284 | defmodule Foo do 285 | @moduledoc ~s/It adds two numbers together\niex> Foo.add(4,2)\n6/ 286 | @spec add(a :: integer, b :: integer) :: integer 287 | def add(a, b) do 288 | a + b 289 | end 290 | end 291 | """ 292 | 293 | desired_output = 294 | """ 295 | defmodule Foo do 296 | @moduledoc ~s/It adds two numbers together\niex> Foo.add(4, 2)\n6/ 297 | @spec add(a :: integer, b :: integer) :: integer 298 | def add(a, b) do 299 | a + b 300 | end 301 | end 302 | """ 303 | 304 | output = format(input, []) 305 | assert output == desired_output 306 | end 307 | 308 | test "formats doctests in moduledocs, uppercase s sigil string" do 309 | input = 310 | """ 311 | defmodule Foo do 312 | @moduledoc ~S/It adds two numbers together 313 | iex> Foo.add(4,2) 314 | 6 315 | / 316 | @spec add(a :: integer, b :: integer) :: integer 317 | def add(a, b) do 318 | a + b 319 | end 320 | end 321 | """ 322 | 323 | desired_output = 324 | """ 325 | defmodule Foo do 326 | @moduledoc ~S/It adds two numbers together 327 | iex> Foo.add(4, 2) 328 | 6 329 | / 330 | @spec add(a :: integer, b :: integer) :: integer 331 | def add(a, b) do 332 | a + b 333 | end 334 | end 335 | """ 336 | 337 | output = format(input, []) 338 | assert output == desired_output 339 | end 340 | end 341 | 342 | describe "format/2 on single line doctests" do 343 | test "respects formatter options" do 344 | opts = [locals_without_parens: [add: 2], force_do_end_blocks: true] 345 | 346 | input = 347 | """ 348 | defmodule Foo do 349 | @doc \""" 350 | It adds two numbers together 351 | iex> add 4,2 352 | 6 353 | \""" 354 | @spec add(a :: integer, b :: integer) :: integer 355 | def add(a, b) do 356 | a + b 357 | end 358 | end 359 | """ 360 | 361 | desired_output = 362 | """ 363 | defmodule Foo do 364 | @doc \""" 365 | It adds two numbers together 366 | iex> add 4, 2 367 | 6 368 | \""" 369 | @spec add(a :: integer, b :: integer) :: integer 370 | def add(a, b) do 371 | a + b 372 | end 373 | end 374 | """ 375 | 376 | output = format(input, opts) 377 | assert output == desired_output 378 | end 379 | 380 | test "different integer formats" do 381 | input = 382 | """ 383 | defmodule IntegerFormats do 384 | @doc \""" 385 | iex> ?A 386 | 0x41 387 | 388 | iex> 0b1000001 389 | 65 390 | 391 | iex> 0x00 392 | 0b00 393 | 394 | iex> 0b1000001 395 | 65 396 | \""" 397 | 398 | def func do 399 | [?A, ?B, ?C] 400 | [0x41, 0x42, 0x43] 401 | [0b1000001, 0b1000010, 0b1000011] 402 | end 403 | end 404 | """ 405 | 406 | desired_output = 407 | """ 408 | defmodule IntegerFormats do 409 | @doc \""" 410 | iex> ?A 411 | 0x41 412 | 413 | iex> 0b1000001 414 | 65 415 | 416 | iex> 0x00 417 | 0b00 418 | 419 | iex> 0b1000001 420 | 65 421 | \""" 422 | 423 | def func do 424 | [?A, ?B, ?C] 425 | [0x41, 0x42, 0x43] 426 | [0b1000001, 0b1000010, 0b1000011] 427 | end 428 | end 429 | """ 430 | 431 | output = format(input, []) 432 | assert output == desired_output 433 | end 434 | 435 | test "keeps line number in iex>() prompt" do 436 | input = 437 | """ 438 | defmodule Foo do 439 | @doc \""" 440 | iex(4)> add 4,2 441 | 6 442 | \""" 443 | end 444 | """ 445 | 446 | desired_output = 447 | """ 448 | defmodule Foo do 449 | @doc \""" 450 | iex(4)> add(4, 2) 451 | 6 452 | \""" 453 | end 454 | """ 455 | 456 | output = format(input, []) 457 | assert output == desired_output 458 | end 459 | 460 | test "doctests with no expected result" do 461 | input = 462 | """ 463 | defmodule Foo do 464 | @doc \""" 465 | It concatenates two strings together 466 | iex> concat("Fizz","Buzz") 467 | \""" 468 | @spec concat(a :: string, b :: string) :: string 469 | def concat(a, b) do 470 | a <> b 471 | end 472 | end 473 | """ 474 | 475 | desired_output = 476 | """ 477 | defmodule Foo do 478 | @doc \""" 479 | It concatenates two strings together 480 | iex> concat("Fizz", "Buzz") 481 | \""" 482 | @spec concat(a :: string, b :: string) :: string 483 | def concat(a, b) do 484 | a <> b 485 | end 486 | end 487 | """ 488 | 489 | output = format(input, []) 490 | assert output == desired_output 491 | end 492 | end 493 | 494 | describe "format/2 on multiline doctests" do 495 | test "multiline doctest" do 496 | input = 497 | """ 498 | defmodule Foo do 499 | @doc \""" 500 | It concatenates two strings together 501 | iex> "Fizz" 502 | ...> |> concat( "Buzz" ) 503 | ...> |> concat("Barr") 504 | "FizzBuzzBarr" 505 | \""" 506 | @spec concat(a :: string, b :: string) :: string 507 | def concat(a, b) do 508 | a <> b 509 | end 510 | end 511 | """ 512 | 513 | desired_output = 514 | """ 515 | defmodule Foo do 516 | @doc \""" 517 | It concatenates two strings together 518 | iex> "Fizz" 519 | ...> |> concat("Buzz") 520 | ...> |> concat("Barr") 521 | "FizzBuzzBarr" 522 | \""" 523 | @spec concat(a :: string, b :: string) :: string 524 | def concat(a, b) do 525 | a <> b 526 | end 527 | end 528 | """ 529 | 530 | output = format(input, []) 531 | assert output == desired_output 532 | end 533 | 534 | test "multiline doctest with no expected result" do 535 | input = 536 | """ 537 | defmodule Foo do 538 | @doc \""" 539 | It concatenates two strings together 540 | iex> "Fizz" 541 | ...> |> concat( "Buzz" ) 542 | ...> |> concat("Barr") 543 | 544 | Bla bla 545 | \""" 546 | @spec concat(a :: string, b :: string) :: string 547 | def concat(a, b) do 548 | a <> b 549 | end 550 | end 551 | """ 552 | 553 | desired_output = 554 | """ 555 | defmodule Foo do 556 | @doc \""" 557 | It concatenates two strings together 558 | iex> "Fizz" 559 | ...> |> concat("Buzz") 560 | ...> |> concat("Barr") 561 | 562 | Bla bla 563 | \""" 564 | @spec concat(a :: string, b :: string) :: string 565 | def concat(a, b) do 566 | a <> b 567 | end 568 | end 569 | """ 570 | 571 | output = format(input, []) 572 | assert output == desired_output 573 | end 574 | 575 | test "multiline doctest with 'iex>' gets changed to '...>'" do 576 | input = 577 | """ 578 | defmodule Foo do 579 | @doc \""" 580 | It concatenates two strings together 581 | iex> "Fizz" 582 | iex> |> concat( "Buzz" ) 583 | iex> |> concat("Barr") 584 | "FizzBuzzBarr" 585 | \""" 586 | @spec concat(a :: string, b :: string) :: string 587 | def concat(a, b) do 588 | a <> b 589 | end 590 | end 591 | """ 592 | 593 | desired_output = 594 | """ 595 | defmodule Foo do 596 | @doc \""" 597 | It concatenates two strings together 598 | iex> "Fizz" 599 | ...> |> concat("Buzz") 600 | ...> |> concat("Barr") 601 | "FizzBuzzBarr" 602 | \""" 603 | @spec concat(a :: string, b :: string) :: string 604 | def concat(a, b) do 605 | a <> b 606 | end 607 | end 608 | """ 609 | 610 | output = format(input, []) 611 | assert output == desired_output 612 | end 613 | 614 | test "multiline doctest with 'iex(n)>' gets changed to '...(n)>'" do 615 | input = 616 | """ 617 | defmodule Foo do 618 | @doc \""" 619 | iex(3)> "Fizz" 620 | iex()> |> concat( "Buzz" ) 621 | iex()> |> concat("Barr") 622 | "FizzBuzzBarr" 623 | \""" 624 | end 625 | """ 626 | 627 | desired_output = 628 | """ 629 | defmodule Foo do 630 | @doc \""" 631 | iex(3)> "Fizz" 632 | ...(3)> |> concat("Buzz") 633 | ...(3)> |> concat("Barr") 634 | "FizzBuzzBarr" 635 | \""" 636 | end 637 | """ 638 | 639 | output = format(input, []) 640 | assert output == desired_output 641 | end 642 | 643 | test "doctest can get split into more lines than originally" do 644 | input = 645 | """ 646 | defmodule Foo do 647 | @doc \""" 648 | It concatenates two strings together 649 | iex> "Fizz" |> concat( "Buzz" ) |> concat("Barr") |> List.duplicate(3) |> Enum.map(fn word -> String.upcase(word) end) 650 | ["FIZZBUZZBARR", "FIZZBUZZBARR", "FIZZBUZZBARR"] 651 | \""" 652 | @spec concat(a :: string, b :: string) :: string 653 | def concat(a, b) do 654 | a <> b 655 | end 656 | end 657 | """ 658 | 659 | desired_output = 660 | """ 661 | defmodule Foo do 662 | @doc \""" 663 | It concatenates two strings together 664 | iex> "Fizz" 665 | ...> |> concat("Buzz") 666 | ...> |> concat("Barr") 667 | ...> |> List.duplicate(3) 668 | ...> |> Enum.map(fn word -> String.upcase(word) end) 669 | ["FIZZBUZZBARR", "FIZZBUZZBARR", "FIZZBUZZBARR"] 670 | \""" 671 | @spec concat(a :: string, b :: string) :: string 672 | def concat(a, b) do 673 | a <> b 674 | end 675 | end 676 | """ 677 | 678 | output = format(input, []) 679 | assert output == desired_output 680 | end 681 | 682 | test "expected result can get split into more lines than originally" do 683 | input = 684 | """ 685 | defmodule Foo do 686 | @doc \""" 687 | It concatenates two strings together 688 | iex> "Fizz" 689 | ...> |> concat( "Buzz" ) 690 | ...> |> concat("Barr") 691 | ...> |> String.duplicate(20) 692 | "FizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarr" <> "FizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarr" <> "FizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarr" <> "FizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarr" 693 | \""" 694 | @spec concat(a :: string, b :: string) :: string 695 | def concat(a, b) do 696 | a <> b 697 | end 698 | end 699 | """ 700 | 701 | desired_output = 702 | """ 703 | defmodule Foo do 704 | @doc \""" 705 | It concatenates two strings together 706 | iex> "Fizz" 707 | ...> |> concat("Buzz") 708 | ...> |> concat("Barr") 709 | ...> |> String.duplicate(20) 710 | "FizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarr" <> 711 | "FizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarr" <> 712 | "FizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarr" <> 713 | "FizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarrFizzBuzzBarr" 714 | \""" 715 | @spec concat(a :: string, b :: string) :: string 716 | def concat(a, b) do 717 | a <> b 718 | end 719 | end 720 | """ 721 | 722 | output = format(input, []) 723 | assert output == desired_output 724 | end 725 | end 726 | 727 | describe "format/2 indentation" do 728 | test "keeps the indent level of the start of the code block" do 729 | input = 730 | """ 731 | defmodule Foo do 732 | @doc \""" 733 | It adds two numbers together 734 | iex> Foo.add 4,2 735 | 6 736 | \""" 737 | @spec add(a :: integer, b :: integer) :: integer 738 | def add(a, b) do 739 | a + b 740 | end 741 | end 742 | """ 743 | 744 | desired_output = 745 | """ 746 | defmodule Foo do 747 | @doc \""" 748 | It adds two numbers together 749 | iex> Foo.add(4, 2) 750 | 6 751 | \""" 752 | @spec add(a :: integer, b :: integer) :: integer 753 | def add(a, b) do 754 | a + b 755 | end 756 | end 757 | """ 758 | 759 | output = format(input, []) 760 | assert output == desired_output 761 | end 762 | 763 | test "multiline expected result indentation" do 764 | input = 765 | """ 766 | defmodule Foo do 767 | @doc \""" 768 | It concatenates two strings together 769 | iex> ~T[01:02:03] 770 | %Time{ 771 | hour: 1, 772 | minute: 2, 773 | second: 3 774 | } 775 | \""" 776 | end 777 | """ 778 | 779 | desired_output = 780 | """ 781 | defmodule Foo do 782 | @doc \""" 783 | It concatenates two strings together 784 | iex> ~T[01:02:03] 785 | %Time{ 786 | hour: 1, 787 | minute: 2, 788 | second: 3 789 | } 790 | \""" 791 | end 792 | """ 793 | 794 | output = format(input, []) 795 | assert output == desired_output 796 | end 797 | 798 | test "multiple tests in single doc" do 799 | input = 800 | """ 801 | defmodule Foo do 802 | @doc \""" 803 | iex> Foo.add(3,4) 804 | 7 805 | 806 | or 807 | 808 | iex> 3 809 | ...> |> Foo.add( 7 ) 810 | 10 811 | \""" 812 | @spec add(a :: integer, b :: integer) :: integer 813 | def add(a, b) do 814 | a + b 815 | end 816 | end 817 | """ 818 | 819 | desired_output = 820 | """ 821 | defmodule Foo do 822 | @doc \""" 823 | iex> Foo.add(3, 4) 824 | 7 825 | 826 | or 827 | 828 | iex> 3 829 | ...> |> Foo.add(7) 830 | 10 831 | \""" 832 | @spec add(a :: integer, b :: integer) :: integer 833 | def add(a, b) do 834 | a + b 835 | end 836 | end 837 | """ 838 | 839 | output = format(input, []) 840 | assert output == desired_output 841 | end 842 | 843 | test "multiple docs in a single module" do 844 | input = 845 | """ 846 | defmodule Foo do 847 | @doc \""" 848 | iex> Foo.add(3,4) 849 | 7 850 | 851 | or 852 | 853 | iex> 3 854 | ...> Foo.add( 7 ) 855 | 10 856 | \""" 857 | @spec add(a :: integer, b :: integer) :: integer 858 | def add(a, b) do 859 | a + b 860 | end 861 | 862 | @doc \""" 863 | iex> Foo.subtract(7,3) 864 | 4 865 | 866 | or 867 | 868 | iex> 10 869 | ...> Foo.subtract( 7 ) 870 | 3 871 | \""" 872 | @spec subtract(a :: integer, b :: integer) :: integer 873 | def subtract(a, b) do 874 | a - b 875 | end 876 | end 877 | """ 878 | 879 | desired_output = 880 | """ 881 | defmodule Foo do 882 | @doc \""" 883 | iex> Foo.add(3, 4) 884 | 7 885 | 886 | or 887 | 888 | iex> 3 889 | ...> Foo.add(7) 890 | 10 891 | \""" 892 | @spec add(a :: integer, b :: integer) :: integer 893 | def add(a, b) do 894 | a + b 895 | end 896 | 897 | @doc \""" 898 | iex> Foo.subtract(7, 3) 899 | 4 900 | 901 | or 902 | 903 | iex> 10 904 | ...> Foo.subtract(7) 905 | 3 906 | \""" 907 | @spec subtract(a :: integer, b :: integer) :: integer 908 | def subtract(a, b) do 909 | a - b 910 | end 911 | end 912 | """ 913 | 914 | output = format(input, []) 915 | assert output == desired_output 916 | end 917 | 918 | test "multiple modules" do 919 | input = 920 | """ 921 | defmodule Foo do 922 | @doc \""" 923 | iex> Foo.add(3,4) 924 | 7 925 | 926 | or 927 | 928 | iex> 3 929 | ...> Foo.add( 7 ) 930 | 10 931 | \""" 932 | @spec add(a :: integer, b :: integer) :: integer 933 | def add(a, b) do 934 | a + b 935 | end 936 | end 937 | 938 | defmodule Bar do 939 | @doc \""" 940 | iex> Bar.subtract(7,3) 941 | 4 942 | 943 | or 944 | 945 | iex> 10 946 | ...> Bar.subtract( 7 ) 947 | 3 948 | \""" 949 | @spec subtract(a :: integer, b :: integer) :: integer 950 | def subtract(a, b) do 951 | a - b 952 | end 953 | end 954 | """ 955 | 956 | desired_output = 957 | """ 958 | defmodule Foo do 959 | @doc \""" 960 | iex> Foo.add(3, 4) 961 | 7 962 | 963 | or 964 | 965 | iex> 3 966 | ...> Foo.add(7) 967 | 10 968 | \""" 969 | @spec add(a :: integer, b :: integer) :: integer 970 | def add(a, b) do 971 | a + b 972 | end 973 | end 974 | 975 | defmodule Bar do 976 | @doc \""" 977 | iex> Bar.subtract(7, 3) 978 | 4 979 | 980 | or 981 | 982 | iex> 10 983 | ...> Bar.subtract(7) 984 | 3 985 | \""" 986 | @spec subtract(a :: integer, b :: integer) :: integer 987 | def subtract(a, b) do 988 | a - b 989 | end 990 | end 991 | """ 992 | 993 | output = format(input, []) 994 | assert output == desired_output 995 | end 996 | 997 | test "adjust desired test code line length to fit the indentation and 'iex> '" do 998 | opts = [line_length: 30] 999 | 1000 | input = 1001 | """ 1002 | defmodule Foo do 1003 | @doc \""" 1004 | iex> "a" <> "a" <> "a" 1005 | "aaa" 1006 | \""" 1007 | end 1008 | """ 1009 | 1010 | desired_output = 1011 | """ 1012 | defmodule Foo do 1013 | @doc \""" 1014 | iex> "a" <> 1015 | ...> "a" <> "a" 1016 | "aaa" 1017 | \""" 1018 | end 1019 | """ 1020 | 1021 | output = format(input, opts) 1022 | assert output == desired_output 1023 | end 1024 | 1025 | test "adjust desired result line length to fit the indentation" do 1026 | opts = [line_length: 30] 1027 | 1028 | input = 1029 | """ 1030 | defmodule Foo do 1031 | @doc \""" 1032 | iex> "aaa" 1033 | "a" <> "a" <> "a" 1034 | \""" 1035 | end 1036 | """ 1037 | 1038 | desired_output = 1039 | """ 1040 | defmodule Foo do 1041 | @doc \""" 1042 | iex> "aaa" 1043 | "a" <> 1044 | "a" <> "a" 1045 | \""" 1046 | end 1047 | """ 1048 | 1049 | output = format(input, opts) 1050 | assert output == desired_output 1051 | end 1052 | 1053 | test "uses the desired line length for the non-doctest code too" do 1054 | # 300 is much longer than the default 98 chars, if the formatter doesn't respect the option of 300 chars, 1055 | # it would try to split the long lines into multiple lines 1056 | opts = [line_length: 300] 1057 | 1058 | input = 1059 | """ 1060 | defmodule Foo do 1061 | @doc \""" 1062 | iex> "aaa" 1063 | "a" <> "a" <> "a" 1064 | \""" 1065 | def my_long_function(argument1, argument2, argument3, argument4, argument5, argument6, argument7, argument8) do 1066 | (1000 + argument1 + argument2 + argument3 + argument4 + argument5 + argument6 + argument7 + argument8) * 2 1067 | end 1068 | end 1069 | """ 1070 | 1071 | desired_output = 1072 | """ 1073 | defmodule Foo do 1074 | @doc \""" 1075 | iex> "aaa" 1076 | "a" <> "a" <> "a" 1077 | \""" 1078 | def my_long_function(argument1, argument2, argument3, argument4, argument5, argument6, argument7, argument8) do 1079 | (1000 + argument1 + argument2 + argument3 + argument4 + argument5 + argument6 + argument7 + argument8) * 2 1080 | end 1081 | end 1082 | """ 1083 | 1084 | output = format(input, opts) 1085 | assert output == desired_output 1086 | end 1087 | 1088 | test "does not create trailing spaces" do 1089 | input = 1090 | """ 1091 | defmodule Foo do 1092 | @doc \""" 1093 | iex> x = 3#{" "} 1094 | iex> #{" "} 1095 | iex> y = 4#{" "} 1096 | iex>#{} 1097 | iex> Foo.add(x,y)#{" "} 1098 | 7 1099 | \""" 1100 | @spec add(a :: integer, b :: integer) :: integer 1101 | def add(a, b) do 1102 | a + b 1103 | end 1104 | end 1105 | """ 1106 | 1107 | desired_output = 1108 | """ 1109 | defmodule Foo do 1110 | @doc \""" 1111 | iex> x = 3 1112 | ...> 1113 | ...> y = 4 1114 | ...> 1115 | ...> Foo.add(x, y) 1116 | 7 1117 | \""" 1118 | @spec add(a :: integer, b :: integer) :: integer 1119 | def add(a, b) do 1120 | a + b 1121 | end 1122 | end 1123 | """ 1124 | 1125 | output = format(input, []) 1126 | assert output == desired_output 1127 | end 1128 | end 1129 | 1130 | describe "format/2 on exceptions" do 1131 | test "can handle exceptions in results" do 1132 | input = 1133 | """ 1134 | defmodule Foo do 1135 | @doc \""" 1136 | iex> "Fizz" 1137 | iex> |> Kernel.<>( "Buzz" ) 1138 | iex> |> Kernel.<>(nil) 1139 | ** (ArgumentError) expected binary argument in <> operator but got: nil 1140 | \""" 1141 | end 1142 | """ 1143 | 1144 | desired_output = 1145 | """ 1146 | defmodule Foo do 1147 | @doc \""" 1148 | iex> "Fizz" 1149 | ...> |> Kernel.<>("Buzz") 1150 | ...> |> Kernel.<>(nil) 1151 | ** (ArgumentError) expected binary argument in <> operator but got: nil 1152 | \""" 1153 | end 1154 | """ 1155 | 1156 | output = format(input, []) 1157 | assert output == desired_output 1158 | end 1159 | end 1160 | 1161 | describe "format/2 on opaque types" do 1162 | test "can handle exceptions in results" do 1163 | input = 1164 | """ 1165 | defmodule Foo do 1166 | defmodule User do 1167 | @derive {Inspect, only: [:name]} 1168 | defstruct [:name, :email] 1169 | end 1170 | 1171 | @doc \""" 1172 | iex> %Foo.User{name: "Bob", email: "bob123@example.com"} 1173 | #Foo.User 1174 | \""" 1175 | end 1176 | """ 1177 | 1178 | desired_output = 1179 | """ 1180 | defmodule Foo do 1181 | defmodule User do 1182 | @derive {Inspect, only: [:name]} 1183 | defstruct [:name, :email] 1184 | end 1185 | 1186 | @doc \""" 1187 | iex> %Foo.User{name: "Bob", email: "bob123@example.com"} 1188 | #Foo.User 1189 | \""" 1190 | end 1191 | """ 1192 | 1193 | output = format(input, []) 1194 | assert output == desired_output 1195 | end 1196 | end 1197 | 1198 | describe "format/2 on content loaded from file" do 1199 | # bring to light bugs that might be hidden because of inline-heredoc-string-code formatting, like in the above test files 1200 | test "double-escaped quotes cannot be helped, prints a warning and leaves unchanged" do 1201 | input = 1202 | File.read!(Path.join(__DIR__, "../fixtures/escaped_quotes.ex")) 1203 | 1204 | desired_output = 1205 | File.read!(Path.join(__DIR__, "../fixtures/escaped_quotes_desired_output.ex")) 1206 | 1207 | io = 1208 | capture_log(fn -> 1209 | output = format(input, []) 1210 | assert output == desired_output 1211 | end) 1212 | 1213 | assert io =~ 1214 | "[warning] The @doc attribute on nofile:3 contains a doctest with some code that couldn't be formatted." 1215 | 1216 | assert io =~ "SyntaxError" 1217 | end 1218 | end 1219 | end 1220 | --------------------------------------------------------------------------------