defmodule Autumn do @external_resource "README.md" @moduledoc "README.md" |> File.read!() |> String.split("") |> Enum.fetch!(1) require Logger alias Autumn.Theme @default_theme "onedark" @typedoc """ A language name, filename, or path with extension. See `Autumn.available_languages/0` to list all available languages or check out a list of [available languages](https://docs.rs/autumnus/latest/autumnus/#languages-available). ## Examples - "elixir" - ".ex" - "app.ex" - "lib/app.ex" """ @type language :: String.t() | nil @typedoc """ Theme used to apply styles on the highlighted source code. See `Autumn.available_themes/0` to list all available themes or check out a list of [available themes](https://docs.rs/autumnus/latest/autumnus/#themes-available). """ @type theme :: String.t() | Autumn.Theme.t() | nil @typedoc """ Highlight lines options for Inline HTML formatter. """ @type html_inline_highlight_lines :: %{ lines: [pos_integer() | Range.t()], style: :theme | String.t() | nil, class: String.t() | nil } | nil @typedoc """ Highlight lines options for Linked HTML formatter. """ @type html_linked_highlight_lines :: %{ lines: [pos_integer() | Range.t()], class: String.t() } | nil @typedoc """ Wraps the highlighted code with custom open and close HTML tags. """ @type header :: %{ close_tag: String.t(), open_tag: String.t() } | nil @typedoc """ Highlighter formatter and its options. Available formatters: `:html_inline`, `:html_linked`, `:terminal` * `:html_inline` - generates `` tags with inline styles for each token, for example: `Atom`. * `:html_linked` - generates `` tags with `class` representing the token type, for example: `Atom`. Must link an external CSS in order to render colors, see more at [HTML Linked](https://hexdocs.pm/autumn/Autumn.html#module-html-linked). * `:terminal` - generates ANSI escape codes for terminal output. You can either pass the formatter as an atom to use default options or a tuple with the formatter name and options, so both are equivalent: # passing only the formatter name like below: :html_inline # is the same as passing an empty list of options: {:html_inline, []} ## Available Options: * `html_inline`: - `:theme` (`t:theme/0` - default: `nil`) - the theme to apply styles on the highlighted source code. - `:pre_class` (`t:String.t/0` - default: `nil`) - the CSS class to append into the wrapping `
` tag.
      - `:italic` (`t:boolean/0` - default: `false`) - enable italic style for the highlighted code.
      - `:include_highlights` (`t:boolean/0` - default: `false`) - include the highlight scope name in a `data-highlight` attribute. Useful for debugging.
      - `:highlight_lines` (`t:html_inline_highlight_lines/0` - default: `nil`) - highlight specific lines either using the theme `highlighted` style or with custom CSS styling.
      - `:header` (`t:header/0` - default: `nil`) - wrap the highlighted code with custom open and close HTML tags.

  * `html_linked`:

      - `:pre_class` (`t:String.t/0` - default: `nil`) - the CSS class to append into the wrapping `
` tag.
      - `:highlight_lines` (`t:html_linked_highlight_lines/0` - default: `nil`) - highlight specific lines either using the `highlighted` class from themes or with a custom CSS class.
      - `:header` (`t:header/0` - default: `nil`) - wrap the highlighted code with custom open and close HTML tags.

  * `terminal`:

      - `:theme` (`t:theme/0` - default: `nil`) - the theme to apply styles on the highlighted source code.

  ## Examples

  ### Inline HTML formatter with default options

      :html_inline

  ### Inline HTML formatter with custom options

      {:html_inline, theme: "onedark", pre_class: "example-01", include_highlights: true}

  ### HTML Inline: highlight specific lines

      # apply theme's `highlighted` style
      {:html_inline, highlight_lines: %{lines: [2..4, 6], style: :theme}}

      # style: :theme is the default
      {:html_inline, highlight_lines: %{lines: [1, 2, 3]}}

      # explicitly use theme style
      {:html_inline, highlight_lines: %{lines: [1, 2, 3], style: :theme}}

      # overrides default style
      {:html_inline, highlight_lines: %{lines: [1, 3..5, 8], style: "background-color: #fff3cd; border-left: 3px solid #ffc107;"}}

      # with only class and no style
      {:html_inline, highlight_lines: %{lines: [1, 2, 3], style: nil, class: "transition-colors duration-500 w-full inline-block bg-yellow-500"}}

  ### HTML Linked: highlight specific lines

      # use default `highlighted` class (already present in themes)
      {:html_linked, highlight_lines: %{lines: [2..4, 6]}}

      # use custom class
      {:html_linked, highlight_lines: %{lines: [1, 2, 3], class: "error-line"}}

  ### Wrap with custom open and close HTML tags

      header = %{
        open_tag: "
file: app.ex", close_tag: "
" } {:html_inline, header: header} ### Terminal formatter :terminal {:terminal, theme: "github_light"} See https://docs.rs/autumnus/latest/autumnus/enum.FormatterOption.html for more info. """ @type formatter :: :html_inline | {:html_inline, [ theme: theme(), pre_class: String.t(), italic: boolean(), include_highlights: boolean(), highlight_lines: html_inline_highlight_lines(), header: header() ]} | :html_linked | {:html_linked, [ pre_class: String.t(), highlight_lines: html_linked_highlight_lines(), header: header() ]} | :terminal | {:terminal, [ theme: theme() ]} @formatter_schema [ type: {:custom, Autumn, :formatter_type, []}, type_spec: quote(do: Autumn.formatter()), type_doc: "`t:Autumn.formatter/0`", default: {:html_inline, theme: "onedark"}, doc: "Formatter to apply on the highlighted source code. See the type doc for more info." ] @options_schema [ language: [ type: {:or, [:string, nil]}, type_spec: quote(do: Autumn.language()), type_doc: "`t:Autumn.language/0`", default: nil, doc: """ The language used to highlight source code. You can also pass a filename or extension, for eg: `"enum.ex"` or just `"ex"`. If no language is provided, the highlighter will try to guess it based on the content of the given source code. Use `Autumn.available_languages/0` to list all available languages. """ ], formatter: @formatter_schema, theme: [ type: {:or, [{:struct, Autumn.Theme}, :string, nil]}, deprecated: "Use :formatter instead." ], inline_style: [ type: :boolean, deprecated: "Use :formatter instead." ], pre_class: [ type: {:or, [:string, nil]}, deprecated: "Use :formatter instead." ] ] @doc false def formatter_schema, do: @formatter_schema @doc false def options_schema, do: @options_schema @doc false def formatter_type(formatter) when formatter in [:html_inline, :html_linked, :terminal] do formatter_type({formatter, []}) end def formatter_type({:html_inline, options}) when is_list(options) do schema = [ theme: [type: {:or, [{:struct, Autumn.Theme}, :string, nil]}, default: @default_theme], pre_class: [type: {:or, [:string, nil]}, default: nil], italic: [type: :boolean, default: false], include_highlights: [type: :boolean, default: false], highlight_lines: [ type: {:or, [ nil, map: [ lines: [type: {:list, {:custom, Autumn, :highlight_lines_type, []}}], style: [type: {:or, [:string, {:in, [:theme]}, nil]}, default: :theme], class: [type: {:or, [:string, nil]}, default: nil] ] ]}, default: nil ], header: [ type: {:or, [ nil, map: [ open_tag: [type: :string], close_tag: [type: :string] ] ]}, default: nil ] ] case NimbleOptions.validate(options, schema) do {:ok, validated_opts} -> case convert_html_inline_options(validated_opts) do {:ok, converted_opts} -> {:ok, {:html_inline, converted_opts}} {:error, error} -> {:error, "invalid options given to html_inline: #{error}"} end {:error, error} -> {:error, "invalid options given to html_inline: #{inspect(error)}"} end end def formatter_type({:html_linked, options}) when is_list(options) do schema = [ pre_class: [type: {:or, [:string, nil]}, default: nil], highlight_lines: [ type: {:or, [ nil, map: [ lines: [type: {:list, {:custom, Autumn, :highlight_lines_type, []}}], class: [type: :string, default: "highlighted"] ] ]}, default: nil ], header: [ type: {:or, [ nil, map: [ open_tag: [type: :string], close_tag: [type: :string] ] ]}, default: nil ] ] case NimbleOptions.validate(options, schema) do {:ok, validated_opts} -> case convert_html_linked_options(validated_opts) do {:ok, converted_opts} -> {:ok, {:html_linked, converted_opts}} {:error, error} -> {:error, "invalid options given to html_linked: #{error}"} end {:error, error} -> {:error, "invalid options given to html_linked: #{inspect(error)}"} end end def formatter_type({:terminal, options}) when is_list(options) do case Keyword.keys(options) -- [:theme] do [] -> default_opts = [theme: @default_theme] opts = Keyword.merge(default_opts, options) {:ok, {:terminal, opts}} invalid -> {:error, "invalid options given to terminal: #{inspect(invalid)}"} end end def formatter_type(other) do {:error, "invalid formatter option: #{inspect(other)}"} end @doc false defp convert_html_inline_options(opts) do with {:ok, opts} <- convert_highlight_lines_inline(opts), {:ok, opts} <- convert_header(opts) do {:ok, opts} end end @doc false defp convert_html_linked_options(opts) do with {:ok, opts} <- convert_highlight_lines_linked(opts), {:ok, opts} <- convert_header(opts) do {:ok, opts} end end @doc false defp convert_highlight_lines_inline(opts) do case opts[:highlight_lines] do nil -> {:ok, opts} hl -> lines = Enum.map(hl[:lines] || [], fn %Range{} = range -> {:range, %{start: range.first, end: range.last}} n when is_integer(n) -> {:single, n} end) style = case hl[:style] do :theme -> :theme str when is_binary(str) -> {:style, %{style: str}} nil -> nil _ -> :theme end class = hl[:class] opts |> Keyword.put(:highlight_lines, %Autumn.HtmlInlineHighlightLines{ lines: lines, style: style, class: class }) |> then(&{:ok, &1}) end end @doc false defp convert_highlight_lines_linked(opts) do case opts[:highlight_lines] do nil -> {:ok, opts} hl -> lines = Enum.map(hl[:lines] || [], fn %Range{} = range -> {:range, %{start: range.first, end: range.last}} n when is_integer(n) -> {:single, n} end) class = hl[:class] || "highlighted" opts |> Keyword.put(:highlight_lines, %Autumn.HtmlLinkedHighlightLines{ lines: lines, class: class }) |> then(&{:ok, &1}) end end @doc false defp convert_header(opts) do case opts[:header] do nil -> {:ok, opts} %{open_tag: open_tag, close_tag: close_tag} -> opts |> Keyword.put(:header, %Autumn.HtmlElement{ open_tag: open_tag, close_tag: close_tag }) |> then(&{:ok, &1}) _ -> {:error, "invalid value for :header option, must be a map with :open_tag and :close_tag keys"} end end @doc false def highlight_lines_type(line) when is_integer(line), do: {:ok, line} def highlight_lines_type(%Range{} = range), do: {:ok, range} def highlight_lines_type(other), do: {:error, "invalid highlight line type: #{inspect(other)}"} @typedoc """ #{NimbleOptions.docs(@options_schema)} See each option type for more info. """ @type options() :: [unquote(NimbleOptions.option_typespec(@options_schema))] @doc """ Returns all default options. """ @spec default_options() :: options() def default_options, do: validate_options!([]) @doc """ Returns the list of all available languages. ## Example iex> Autumn.available_languages() %{ "diff" => {"Diff", ["*.diff"]}, "lua" => {"Lua", ["*.lua"]}, "javascript" => {"JavaScript", ["*.cjs", "*.js", "*.mjs", "*.snap", "*.jsx"]}, "elixir" => {"Elixir", ["*.ex", "*.exs"]}, ... } iex> Autumn.available_languages()["elixir"] {"Elixir", ["*.ex", "*.exs"]} """ @spec available_languages() :: %{ (id :: String.t()) => {name :: String.t(), [extension :: String.t()]} } def available_languages, do: Autumn.Native.available_languages() @doc """ Returns the list of all available themes. Use `Autumn.Theme.get/1` to get the actual theme struct. ## Example iex> Autumn.available_themes() ["github_light", "github_dark", "catppuccin_frappe", "catppuccin_latte", "nightfox", ...] """ @spec available_themes() :: [name :: String.t()] def available_themes, do: Autumn.Native.available_themes() @deprecated "Use highlight/2 instead" def highlight(language, source, options) do IO.warn(""" passing the language in the first argument is deprecated, pass a `:language` option instead: Autumn.highlight("import Kernel", language: "elixir") """) {_, options} = Keyword.get_and_update(options, :theme, fn nil -> {nil, nil} current -> {current, String.capitalize(current)} end) options = Keyword.put(options, :language, language) highlight(source, options) end @deprecated "Use highlight!/2 instead" def highlight!(language, source, options) do IO.warn(""" passing the language in the first argument is deprecated, pass a `:language` option instead: Autumn.highlight!("import Kernel", language: "elixir") """) {_, options} = Keyword.get_and_update(options, :theme, fn nil -> {nil, nil} current -> {current, String.capitalize(current)} end) options = Keyword.put(options, :language, language) highlight!(source, options) end @doc """ Highlights `source` code and outputs into a formatted string. ## Options See `t:options/0`. ## Examples Defining the language name: iex> Autumn.highlight("Atom.to_string(:elixir)", language: "elixir") { :ok,
Atom.to_string(:elixir)
} Guessing the language based on the provided source code: iex> Autumn.highlight("#!/usr/bin/env bash\\nID=1") {:ok, "
...
"} With custom options: iex> Autumn.highlight("Atom.to_string(:elixir)", language: "example.ex", formatter: {:html_inline, pre_class: "example-elixir"}) {:ok, "
...
"} Terminal formatter: iex> Autumn.highlight("Atom.to_string(:elixir)", language: "elixir", formatter: :terminal) {:ok, "\e[0m\e[38;2;229;192;123mAtom\e[0m\e[0m\e[38;2;86;182;194m.\e[0m\e[0m\e[38;2;97;175;239mto_string\e[0m\e[0m\e[38;2;198;120;221m(\e[0m\e[0m\e[38;2;224;108;117m:elixir\e[0m\e[0m\e[38;2;198;120;221m)\e[0m"} Highlighting specific lines in HTML Inline formatter: iex> code = \""" ...> defmodule Example do ...> @lang = :elixir ...> def lang, do: @lang ...> end ...> \""" iex> highlight_lines = %{lines: [2]} iex> Autumn.highlight(code, language: "elixir", formatter: {:html_inline, highlight_lines: highlight_lines}) # Line 2 will be highlighted with the theme's `highlighted` style:
...
Highlighting specific lines in HTML Linked formatter: iex> code = \""" ...> defmodule Example do ...> @lang = :elixir ...> def lang, do: @lang ...> end ...> \""" iex> highlight_lines = %{lines: [2]} iex> Autumn.highlight(code, language: "elixir", formatter: {:html_linked, highlight_lines: highlight_lines}) # Line 2 will contain a `highlighted` class:
... Wrapping with custom HTML: iex> header = %{ ...> open_tag: "
file: example.exs", ...> close_tag: "
" ...> } iex> Autumn.highlight("IO.puts('hello')", language: "elixir", formatter: {:html_inline, header: header}) # Returns: "
...
" {:ok, "
file: example.exs...
"} See https://docs.rs/autumnus/latest/autumnus/fn.highlight.html for more info. """ @spec highlight(String.t(), options()) :: {:ok, String.t()} | {:error, term()} def highlight(source, options \\ []) def highlight(source, options) when is_binary(source) and is_list(options) do options = options |> validate_options!() |> rust_options!() case Autumn.Native.highlight(source, options) do {:error, error} -> raise Autumn.HighlightError, error: error output -> output end end def highlight(language, source) when is_binary(language) and is_binary(source) do highlight(source, language: language) end @doc """ Validates the given options against the options schema. This function validates the provided options using NimbleOptions and the defined schema. It ensures that all options are valid and properly typed before being passed to the highlighting functions. ## Examples iex> Autumn.validate_options!(language: "elixir") [language: "elixir", formatter: {:html_inline, [header: nil, highlight_lines: nil, include_highlights: false, italic: false, pre_class: nil, theme: "onedark"]}] iex> Autumn.validate_options!(formatter: {:html_inline, theme: "dracula"}) [language: nil, formatter: {:html_inline, [theme: "dracula", ...]}] iex> Autumn.validate_options!(language: :invalid) ** (NimbleOptions.ValidationError) """ @spec validate_options!(options()) :: options() def validate_options!(options) do NimbleOptions.validate!(options, @options_schema) end @doc false def rust_options!(options) do {formatter, formatter_opts} = options[:formatter] {theme, options} = Keyword.pop(options, :theme) theme = build_theme(theme || Keyword.get(formatter_opts, :theme)) {pre_class, options} = Keyword.pop(options, :pre_class) pre_class = pre_class || Keyword.get(formatter_opts, :pre_class) {inline_style, options} = Keyword.pop(options, :inline_style) {formatter, formatter_opts} = case inline_style do true -> {:ok, {_type, default_opts}} = formatter_type(:html_inline) {:html_inline, Keyword.merge(default_opts, formatter_opts)} false -> {:ok, {_type, default_opts}} = formatter_type(:html_linked) {:html_linked, Keyword.merge(default_opts, formatter_opts)} nil -> {formatter, formatter_opts} end rust_formatter = convert_formatter_for_nif( formatter, Map.merge(Map.new(formatter_opts), %{theme: theme, pre_class: pre_class}) ) options |> Keyword.put(:formatter, rust_formatter) |> Map.new() end @doc false def build_theme(theme) do cond do match?(%Theme{}, theme) -> {:theme, theme} is_binary(theme) && String.contains?(theme, " ") -> Logger.warning(""" Helix themes are deprecated, use Neovim theme names instead. See `Autumn.available_themes/0` for a list of available themes. """) theme |> String.downcase() |> String.replace(" ", "") |> then(&{:string, &1}) is_binary(theme) -> theme |> String.downcase() |> then(&{:string, &1}) :else -> nil end end defp convert_formatter_for_nif(:html_inline, opts) do opts = convert_theme_for_nif(opts) {:html_inline, Map.take(opts, [ :theme, :pre_class, :italic, :include_highlights, :highlight_lines, :header ])} end defp convert_formatter_for_nif(:html_linked, opts) do {:html_linked, Map.take(opts, [:pre_class, :highlight_lines, :header])} end defp convert_formatter_for_nif(:terminal, opts) do opts = convert_theme_for_nif(opts) {:terminal, Map.take(opts, [:theme])} end defp convert_theme_for_nif(opts) do case opts[:theme] do {:theme, %Theme{} = theme} -> Map.put(opts, :theme, {:theme, theme}) {:string, theme_name} when is_binary(theme_name) -> Map.put(opts, :theme, {:string, theme_name}) nil -> Map.put(opts, :theme, nil) theme_name when is_binary(theme_name) -> Map.put(opts, :theme, {:string, theme_name}) end end @doc """ Same as `highlight/2` but raises in case of failure. """ @spec highlight!(String.t(), keyword()) :: String.t() def highlight!(source, options \\ []) def highlight!(source, options) when is_binary(source) and is_list(options) do case highlight(source, options) do {:ok, highlighted} -> highlighted {:error, error} -> raise """ failed to highlight source code Got: #{inspect(error)} """ end end def highlight!(language, source) when is_binary(language) and is_binary(source) do highlight!(source, language: language) end end