Current section
Files
Jump to
Current section
Files
lib/style/comment_directives.ex
# Copyright 2024 Adobe. All rights reserved.
# Copyright 2025 SmartRent. All rights reserved.
# This file is licensed to you under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. You may obtain a copy
# of the License at http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software distributed under
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
# OF ANY KIND, either express or implied. See the License for the specific language
# governing permissions and limitations under the License.
defmodule Quokka.Style.CommentDirectives do
@moduledoc """
Leave a comment for Quokka asking it to maintain code in a certain way.
`# quokka:sort` maintains sorting of wordlists (by string comparison) and lists (string comparison of code representation)
"""
@behaviour Quokka.Style
alias Quokka.Style
alias Quokka.Zipper
def run(zipper, ctx) do
{zipper, comments} =
ctx.comments
|> Enum.filter(&(&1.text == "# quokka:sort"))
|> Enum.map(& &1.line)
|> Enum.reduce({zipper, ctx.comments}, fn line, {zipper, comments} ->
found = Zipper.find(zipper, &(line + 1 == Style.meta(&1)[:line]))
if found do
{sorted, comments} = found |> Zipper.node() |> sort(comments)
{Zipper.replace(found, sorted), comments}
else
{zipper, comments}
end
end)
autosort_types = Quokka.Config.autosort()
if Enum.empty?(autosort_types) do
{:skip, zipper, %{ctx | comments: comments}}
else
skip_sort_lines = collect_skip_sort_lines(ctx.comments)
node = Zipper.node(zipper)
node_line = Style.meta(node)[:line]
should_skip = node_line && MapSet.member?(skip_sort_lines, node_line)
has_comments = has_comments_inside?(node, ctx.comments)
is_sortable = get_node_type(node) in autosort_types
is_query = ecto_from_query?(node)
cond do
is_query and Quokka.Config.autosort_exclude_ecto?() ->
{:skip, zipper, %{ctx | comments: comments}}
should_skip || has_comments || !is_sortable ->
{:cont, zipper, %{ctx | comments: comments}}
true ->
{sorted, _} = sort(node, [])
{:cont, Zipper.replace(zipper, sorted), %{ctx | comments: comments}}
end
end
end
defp ecto_from_query?(node) do
case node do
# Matches the remote call: `Ecto.Query.from(...)`
{{:., _, [{:__aliases__, _, [:Ecto, :Query]}, :from]}, _, _} ->
true
# Matches the local call `from ...` and checks for the `in` expression
# to ensure it's likely an Ecto query.
{:from, _, [{:in, _, _} | _]} ->
true
_ ->
false
end
end
defp collect_skip_sort_lines(comments) do
comments
|> Enum.filter(&(&1.text == "# quokka:skip-sort"))
|> Enum.reduce(MapSet.new(), fn comment, lines ->
lines |> MapSet.put(comment.line) |> MapSet.put(comment.line + 1)
end)
end
defp get_node_type(node) do
case node do
{schema, _, [_, [{{:__block__, _, [:do]}, _}]]} when schema in [:schema, :typed_schema] -> :schema
{:embedded_schema, _, [[{{:__block__, _, [:do]}, _}]]} -> :schema
{:%{}, _, _} -> :map
{:%, _, [_, {:%{}, _, _}]} -> :map
{:defstruct, _, _} -> :defstruct
_ -> nil
end
end
defp has_comments_inside?(node, comments) do
start_line = Style.meta(node)[:line] || 0
end_line = Style.max_line(node) || start_line
end_line > start_line && Enum.any?(comments, &(&1.line > start_line && &1.line < end_line))
end
# Sorts a list with numeric-aware comparison for map keys
# Preserves existing behavior of sorting mixed atom/string keys alphabetically
defp numeric_aware_sort(list) do
# Split into numeric and non-numeric keys, sort each group appropriately
{numeric_items, non_numeric_items} = Enum.split_with(list, &numeric_key?/1)
sorted_numeric = Enum.sort_by(numeric_items, &extract_key/1)
sorted_non_numeric = Enum.sort_by(non_numeric_items, &Macro.to_string/1)
sorted_numeric ++ sorted_non_numeric
end
defp numeric_key?({{:__block__, _, [key]}, _}) when is_number(key), do: true
defp numeric_key?({key, _}) when is_number(key), do: true
defp numeric_key?({{:.., _, [{:__block__, _, [start]}, {:__block__, _, [_stop]}]}, _}) when is_number(start), do: true
defp numeric_key?({{:.., _, [start, _stop]}, _}) when is_number(start), do: true
defp numeric_key?(_), do: false
defp extract_key({{:__block__, _, [key]}, _}), do: key
defp extract_key({{:.., _, [{:__block__, _, [start]}, {:__block__, _, [_stop]}]}, _}), do: start
defp extract_key({{:.., _, [start, _stop]}, _}), do: start
defp extract_key({key, _}), do: key
# defstruct with a syntax-sugared keyword list hits here
defp sort({parent, meta, [list]} = node, comments) when parent in ~w(defstruct __block__)a and is_list(list) do
list = numeric_aware_sort(list)
line = meta[:line]
# no need to fix line numbers if it's a single line structure
{list, comments} =
if line == Style.max_line(node),
do: {list, comments},
else: Style.order_line_meta_and_comments(list, comments, line)
{{parent, meta, [list]}, comments}
end
# defstruct with a literal list
defp sort({:defstruct, meta, [{:__block__, _, [_]} = list]}, comments) do
{list, comments} = sort(list, comments)
{{:defstruct, meta, [list]}, comments}
end
# map update with a keyword list
defp sort({:%{}, meta, [{:|, _, [var, keyword_list]}]}, comments) do
{{:__block__, meta, [keyword_list]}, comments} = sort({:__block__, meta, [keyword_list]}, comments)
{{:%{}, meta, [{:|, meta, [var, keyword_list]}]}, comments}
end
# map
defp sort({:%{}, meta, list}, comments) when is_list(list) do
{{:__block__, meta, [list]}, comments} = sort({:__block__, meta, [list]}, comments)
{{:%{}, meta, list}, comments}
end
# struct map
defp sort({:%, m, [struct, map]}, comments) do
{map, comments} = sort(map, comments)
{{:%, m, [struct, map]}, comments}
end
defp sort({:sigil_w, sm, [{:<<>>, bm, [string]}, modifiers]}, comments) do
# ew. gotta be a better way.
# this keeps indentation for the sigil via joiner, while prepend and append are the bookending whitespace
{prepend, joiner, append} =
case Regex.run(~r|^\s+|, string) do
# oneliner like `~w|c a b|`
nil -> {"", " ", ""}
# multiline like
# `"\n a\n list\n long\n of\n static\n values\n"`
# ^^^^ `prepend` ^^^^ `joiner` ^^ `append`
# note that joiner and prepend are the same in a multiline (unsure if this is always true)
# @TODO: get all 3 in one pass of a regex. probably have to turn off greedy or something...
[joiner] -> {joiner, joiner, ~r|\s+$| |> Regex.run(string) |> hd()}
end
string = string |> String.split() |> Enum.sort() |> Enum.join(joiner)
{{:sigil_w, sm, [{:<<>>, bm, [prepend, string, append]}, modifiers]}, comments}
end
defp sort({:=, m, [lhs, rhs]}, comments) do
{rhs, comments} = sort(rhs, comments)
{{:=, m, [lhs, rhs]}, comments}
end
defp sort({:@, m, [{a, am, [assignment]}]}, comments) do
{assignment, comments} = sort(assignment, comments)
{{:@, m, [{a, am, [assignment]}]}, comments}
end
defp sort({schema_type, meta, [table_name, [{{:__block__, _, [:do]}, {:__block__, block_meta, fields}}]]}, comments)
when schema_type in [:schema, :typed_schema, :embedded_schema] do
field_type_order = Quokka.Config.autosort_schema_order()
grouped_fields =
fields
|> Enum.group_by(fn
{field_type, _, _}
when field_type in [
:belongs_to,
:embeds_many,
:embeds_one,
:has_many,
:has_one,
:many_to_many,
:field
] ->
field_type
other ->
other
end)
sorted_groups =
field_type_order
|> Enum.map(fn type ->
grouped_fields |> Map.get(type, []) |> numeric_aware_sort()
end)
|> Enum.reject(&(&1 == []))
other_fields =
grouped_fields
|> Map.drop(field_type_order)
|> Map.values()
|> List.flatten()
|> numeric_aware_sort()
sorted_fields =
sorted_groups
|> Enum.map(&Style.reset_newlines/1)
|> Enum.reduce(fn group, acc ->
acc ++ Style.reset_newlines(group)
end)
|> Kernel.++(other_fields)
{sorted_fields, comments} = Style.order_line_meta_and_comments(sorted_fields, comments, meta[:line])
{{schema_type, meta, [table_name, [{{:__block__, [], [:do]}, {:__block__, block_meta, sorted_fields}}]]}, comments}
end
defp sort({key, value}, comments) do
{value, comments} = sort(value, comments)
{{key, value}, comments}
end
# sorts arbitrary ast nodes within a `do end` list
defp sort({f, m, args} = node, comments) do
if m[:do] && m[:end] && match?([{{:__block__, _, [:do]}, {:__block__, _, _}}], List.last(args)) do
{[{{:__block__, m1, [:do]}, {:__block__, m2, nodes}}], args} = List.pop_at(args, -1)
{nodes, comments} =
nodes
|> numeric_aware_sort()
|> Style.order_line_meta_and_comments(comments, m[:line])
args = List.insert_at(args, -1, [{{:__block__, m1, [:do]}, {:__block__, m2, nodes}}])
{{f, m, args}, comments}
else
{node, comments}
end
end
defp sort(x, comments), do: {x, comments}
end