diff --git a/lib/absinthe/formatter.ex b/lib/absinthe/formatter.ex new file mode 100644 index 0000000000..cbc662d40d --- /dev/null +++ b/lib/absinthe/formatter.ex @@ -0,0 +1,34 @@ +defmodule Absinthe.Formatter do + @moduledoc """ + Formatter task for graphql + + Will format files with the extensions .graphql or .gql + + ## Example + + Absinthe.Formatter.format("{ version }") + "{\n version\n}\n" + + + From Elixir 1.13 onwards the Absinthe.Formatter can be added to + the formatter as a plugin: + + # .formatter.exs + [ + # Define the desired plugins + plugins: [Absinthe.Formatter], + # Remember to update the inputs list to include the new extensions + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}", "{lib, priv}/**/*.{gql,graphql}"] + ] + + """ + + def features(_opts) do + [sigils: [], extensions: [".graphql", ".gql"]] + end + + def format(contents, _opts \\ []) do + {:ok, blueprint} = Absinthe.Phase.Parse.run(contents, []) + inspect(blueprint.input, pretty: true) + end +end diff --git a/lib/absinthe/language/document.ex b/lib/absinthe/language/document.ex index aea0309cb6..4dd409f17b 100644 --- a/lib/absinthe/language/document.ex +++ b/lib/absinthe/language/document.ex @@ -83,4 +83,9 @@ defmodule Absinthe.Language.Document do update_in(blueprint.fragments, &[Blueprint.Draft.convert(node, doc) | &1]) end end + + defimpl Inspect do + defdelegate inspect(term, options), + to: Absinthe.Language.Render + end end diff --git a/lib/absinthe/language/operation_definition.ex b/lib/absinthe/language/operation_definition.ex index 09b27c6e46..b47b782621 100644 --- a/lib/absinthe/language/operation_definition.ex +++ b/lib/absinthe/language/operation_definition.ex @@ -8,6 +8,7 @@ defmodule Absinthe.Language.OperationDefinition do variable_definitions: [], directives: [], selection_set: nil, + shorthand: false, loc: %{line: nil} @type t :: %__MODULE__{ @@ -16,6 +17,7 @@ defmodule Absinthe.Language.OperationDefinition do variable_definitions: [Language.VariableDefinition.t()], directives: [Language.Directive.t()], selection_set: Language.SelectionSet.t(), + shorthand: boolean(), loc: Language.loc_t() } diff --git a/lib/absinthe/language/render.ex b/lib/absinthe/language/render.ex new file mode 100644 index 0000000000..7abe22260e --- /dev/null +++ b/lib/absinthe/language/render.ex @@ -0,0 +1,279 @@ +defmodule Absinthe.Language.Render do + @moduledoc false + import Inspect.Algebra + import Absinthe.Utils.Render + + @line_width 120 + + def inspect(term, %{pretty: true}) do + term + |> render() + |> concat(line()) + |> format(@line_width) + |> to_string + end + + def inspect(term, options) do + Inspect.Any.inspect(term, options) + end + + defp render(bp) + + defp render(%Absinthe.Language.Document{} = doc) do + doc.definitions |> Enum.map(&render/1) |> join([line(), line()]) + end + + defp render(%Absinthe.Language.OperationDefinition{} = op) do + if op.shorthand do + concat(operation_definition(op), block(render_list(op.selection_set.selections))) + else + glue( + concat([to_string(op.operation), operation_definition(op)]), + block(render_list(op.selection_set.selections)) + ) + end + end + + defp render(%Absinthe.Language.Field{} = field) do + case field.selection_set do + nil -> + field_definition(field) + + selection_set -> + concat([ + field_definition(field), + " ", + block(render_list(selection_set.selections)) + ]) + end + end + + defp render(%Absinthe.Language.VariableDefinition{} = variable_definition) do + concat([ + "$", + variable_definition.variable.name, + ": ", + render(variable_definition.type), + default_value(variable_definition) + ]) + end + + defp render(%Absinthe.Language.NamedType{} = named_type) do + named_type.name + end + + defp render(%Absinthe.Language.NonNullType{} = non_null) do + concat(render(non_null.type), "!") + end + + defp render(%Absinthe.Language.Argument{} = argument) do + concat([argument.name, ": ", render(argument.value)]) + end + + defp render(%Absinthe.Language.Directive{} = directive) do + concat([" @", directive.name, arguments(directive.arguments)]) + end + + defp render(%Absinthe.Language.FragmentSpread{} = spread) do + concat(["...", spread.name, directives(spread.directives)]) + end + + defp render(%Absinthe.Language.InlineFragment{} = fragment) do + concat([ + "...", + inline_fragment_name(fragment), + directives(fragment.directives), + " ", + block(render_list(fragment.selection_set.selections)) + ]) + end + + defp render(%Absinthe.Language.Variable{} = variable) do + concat("$", variable.name) + end + + defp render(%Absinthe.Language.StringValue{value: value}) do + render_string_value(value) + end + + defp render(%Absinthe.Language.FloatValue{value: value}) do + "#{value}" + end + + defp render(%Absinthe.Language.ObjectField{} = object_field) do + concat([object_field.name, ": ", render(object_field.value)]) + end + + defp render(%Absinthe.Language.ObjectValue{fields: fields}) do + fields = fields |> Enum.map(&render(&1)) |> join(", ") + + concat(["{ ", fields, " }"]) + end + + defp render(%Absinthe.Language.NullValue{}) do + "null" + end + + defp render(%Absinthe.Language.ListType{type: type}) do + concat(["[", render(type), "]"]) + end + + defp render(%Absinthe.Language.ListValue{values: values}) do + values = values |> Enum.map(&render(&1)) |> join(", ") + + concat(["[", values, "]"]) + end + + defp render(%Absinthe.Language.Fragment{} = fragment) do + concat([ + "fragment ", + fragment.name, + " on ", + fragment.type_condition.name, + directives(fragment.directives) + ]) + |> block(render_list(fragment.selection_set.selections)) + end + + defp render(%{value: value}) do + to_string(value) + end + + defp operation_definition(%{name: nil} = op) do + case op.variable_definitions do + [] -> + concat( + variable_definitions(op.variable_definitions), + directives(op.directives) + ) + + _ -> + operation_definition(%{op | name: ""}) + end + end + + defp operation_definition(%{name: name} = op) do + concat([" ", name, variable_definitions(op.variable_definitions), directives(op.directives)]) + end + + defp variable_definitions([]) do + empty() + end + + defp variable_definitions(definitions) do + definitions = Enum.map(definitions, &render(&1)) + + concat([ + "(", + join(definitions, ", "), + ")" + ]) + end + + defp field_definition(field) do + concat([ + field_alias(field), + field.name, + arguments(field.arguments), + directives(field.directives) + ]) + end + + defp default_value(%{default_value: nil}) do + empty() + end + + defp default_value(%{default_value: value}) do + concat(" = ", render(value)) + end + + defp directives([]) do + empty() + end + + defp directives(directives) do + directives |> Enum.map(&render(&1)) |> join(" ") + end + + defp inline_fragment_name(%{type_condition: nil}) do + empty() + end + + defp inline_fragment_name(%{type_condition: %{name: name}}) do + " on #{name}" + end + + defp field_alias(%{alias: nil}) do + empty() + end + + defp field_alias(%{alias: alias}) do + concat(alias, ": ") + end + + defp arguments([]) do + empty() + end + + defp arguments(args) do + group( + glue( + nest( + glue( + "(", + "", + render_list(args, ", ") + ), + 2, + :break + ), + "", + ")" + ) + ) + end + + # Helpers + + defp block(docs) do + do_block(docs) + end + + defp block(:doc_nil, docs) do + do_block(docs) + end + + defp block(name, docs) do + glue( + name, + do_block(docs) + ) + end + + defp do_block(docs) do + group( + glue( + nest( + force_unfit( + glue( + "{", + "", + docs + ) + ), + 2, + :always + ), + "", + "}" + ) + ) + end + + defp render_list(items, separator \\ line()) do + List.foldr(items, :doc_nil, fn + item, :doc_nil -> render(item) + item, acc -> concat([render(item)] ++ [separator] ++ [acc]) + end) + end +end diff --git a/lib/absinthe/schema/notation/sdl_render.ex b/lib/absinthe/schema/notation/sdl_render.ex index 83496b2d5b..abc85a2629 100644 --- a/lib/absinthe/schema/notation/sdl_render.ex +++ b/lib/absinthe/schema/notation/sdl_render.ex @@ -1,6 +1,7 @@ defmodule Absinthe.Schema.Notation.SDL.Render do @moduledoc false import Inspect.Algebra + import Absinthe.Utils.Render alias Absinthe.Blueprint @@ -450,53 +451,6 @@ defmodule Absinthe.Schema.Notation.SDL.Render do defp render_value(%{value: value}), do: to_string(value) - defp render_string_value(string, indent \\ 2) do - string - |> String.trim() - |> String.split("\n") - |> case do - [string_line] -> - concat([~s("), escape_string(string_line), ~s(")]) - - string_lines -> - concat( - nest( - block_string([~s(""")] ++ string_lines), - indent, - :always - ), - concat(line(), ~s(""")) - ) - end - end - - @escaped_chars [?", ?\\, ?/, ?\b, ?\f, ?\n, ?\r, ?\t] - - defp escape_string(string) do - escape_string(string, []) - end - - defp escape_string(<>, acc) when char in @escaped_chars do - escape_string(rest, [acc | escape_char(char)]) - end - - defp escape_string(<>, acc) do - escape_string(rest, [acc | <>]) - end - - defp escape_string(<<>>, acc) do - to_string(acc) - end - - defp escape_char(?"), do: [?\\, ?"] - defp escape_char(?\\), do: [?\\, ?\\] - defp escape_char(?/), do: [?\\, ?/] - defp escape_char(?\b), do: [?\\, ?b] - defp escape_char(?\f), do: [?\\, ?f] - defp escape_char(?\n), do: [?\\, ?n] - defp escape_char(?\r), do: [?\\, ?r] - defp escape_char(?\t), do: [?\\, ?t] - # Algebra Helpers defp multiline(docs, true) do @@ -536,24 +490,4 @@ defmodule Absinthe.Schema.Notation.SDL.Render do ) ) end - - defp block_string([string]) do - string(string) - end - - defp block_string([string | rest]) do - string - |> string() - |> concat(block_string_line(rest)) - |> concat(block_string(rest)) - end - - defp block_string_line(["", _ | _]), do: nest(line(), :reset) - defp block_string_line(_), do: line() - - def join(docs, joiner) do - fold_doc(docs, fn doc, acc -> - concat([doc, concat(List.wrap(joiner)), acc]) - end) - end end diff --git a/lib/absinthe/utils/render.ex b/lib/absinthe/utils/render.ex new file mode 100644 index 0000000000..4f1476023c --- /dev/null +++ b/lib/absinthe/utils/render.ex @@ -0,0 +1,72 @@ +defmodule Absinthe.Utils.Render do + @moduledoc false + + import Inspect.Algebra + + def join(docs, joiner) do + fold_doc(docs, fn doc, acc -> + concat([doc, concat(List.wrap(joiner)), acc]) + end) + end + + def render_string_value(string, indent \\ 2) do + string + |> String.trim() + |> String.split("\n") + |> case do + [string_line] -> + concat([~s("), escape_string(string_line), ~s(")]) + + string_lines -> + concat( + nest( + block_string([~s(""")] ++ string_lines), + indent, + :always + ), + concat(line(), ~s(""")) + ) + end + end + + @escaped_chars [?", ?\\, ?/, ?\b, ?\f, ?\n, ?\r, ?\t] + + defp escape_string(string) do + escape_string(string, []) + end + + defp escape_string(<>, acc) when char in @escaped_chars do + escape_string(rest, [acc | escape_char(char)]) + end + + defp escape_string(<>, acc) do + escape_string(rest, [acc | <>]) + end + + defp escape_string(<<>>, acc) do + to_string(acc) + end + + defp escape_char(?"), do: [?\\, ?"] + defp escape_char(?\\), do: [?\\, ?\\] + defp escape_char(?/), do: [?\\, ?/] + defp escape_char(?\b), do: [?\\, ?b] + defp escape_char(?\f), do: [?\\, ?f] + defp escape_char(?\n), do: [?\\, ?n] + defp escape_char(?\r), do: [?\\, ?r] + defp escape_char(?\t), do: [?\\, ?t] + + defp block_string([string]) do + string(string) + end + + defp block_string([string | rest]) do + string + |> string() + |> concat(block_string_line(rest)) + |> concat(block_string(rest)) + end + + defp block_string_line(["", _ | _]), do: nest(line(), :reset) + defp block_string_line(_), do: line() +end diff --git a/priv/graphql/introspection.graphql b/priv/graphql/introspection.graphql index 0d4b8680d7..3f8b0fd5e9 100644 --- a/priv/graphql/introspection.graphql +++ b/priv/graphql/introspection.graphql @@ -24,6 +24,7 @@ query IntrospectionQuery { } } } + fragment FullType on __Type { kind name @@ -56,6 +57,7 @@ fragment FullType on __Type { ...TypeRef } } + fragment InputValue on __InputValue { name description @@ -64,6 +66,7 @@ fragment InputValue on __InputValue { } defaultValue } + fragment TypeRef on __Type { kind name diff --git a/src/absinthe_parser.yrl b/src/absinthe_parser.yrl index 5ca6eb28a1..d77a3115f8 100644 --- a/src/absinthe_parser.yrl +++ b/src/absinthe_parser.yrl @@ -36,7 +36,7 @@ OperationType -> 'query' : '$1'. OperationType -> 'mutation' : '$1'. OperationType -> 'subscription' : '$1'. -OperationDefinition -> SelectionSet : build_ast_node('OperationDefinition', #{'operation' => 'query', 'selection_set' => '$1'}, extract_child_location('$1')). +OperationDefinition -> SelectionSet : build_ast_node('OperationDefinition', #{'operation' => 'query', 'selection_set' => '$1', 'shorthand' => true}, extract_child_location('$1')). OperationDefinition -> OperationType SelectionSet : build_ast_node('OperationDefinition', #{'operation' => extract_atom('$1'), 'selection_set' => '$2'}, extract_location('$1')). OperationDefinition -> OperationType VariableDefinitions SelectionSet : build_ast_node('OperationDefinition', #{'operation' => extract_atom('$1'), 'variable_definitions' => '$2', 'selection_set' => '$3'}, extract_child_location('$1')). OperationDefinition -> OperationType VariableDefinitions Directives SelectionSet : build_ast_node('OperationDefinition', #{'operation' => extract_atom('$1'), 'variable_definitions' => '$2', 'directives' => '$3', 'selection_set' => '$4'}, extract_child_location('$1')). diff --git a/test/absinthe/formatter_test.exs b/test/absinthe/formatter_test.exs new file mode 100644 index 0000000000..309dbc6870 --- /dev/null +++ b/test/absinthe/formatter_test.exs @@ -0,0 +1,12 @@ +defmodule Absinthe.FormatterTest do + use Absinthe.Case, async: true + + @query """ + { + version + } + """ + test "formats a document" do + assert Absinthe.Formatter.format(@query) == "{\n version\n}\n" + end +end diff --git a/test/absinthe/language/render_test.exs b/test/absinthe/language/render_test.exs new file mode 100644 index 0000000000..add322c007 --- /dev/null +++ b/test/absinthe/language/render_test.exs @@ -0,0 +1,126 @@ +defmodule Absinthe.Language.RenderTest do + use ExUnit.Case, async: true + + describe "renders graphql" do + test "for unnamed query" do + assert_rendered(""" + { + version + } + """) + end + + test "for fragment typing" do + assert_rendered(""" + query FragmentTyping { + profiles(handles: ["zuck", "cocacola"]) { + handle + ...userFragment + ...pageFragment + } + } + + fragment userFragment on User @defer { + friends { + count + } + } + + fragment pageFragment on Page { + likers { + count + } + } + """) + end + + test "for inline fragment with type query" do + assert_rendered(""" + query inlineFragmentTyping { + profiles(handles: ["zuck", "cocacola"]) { + handle + ... on User @onInlineFragment { + friends { + count + } + } + ... on Page { + likers { + count + } + } + } + } + """) + end + + test "for inline fragments without type query" do + assert_rendered(""" + query inlineFragmentNoType($expandedInfo: Boolean) { + user(handle: "zuck") { + id + name + ... @include(if: $expandedInfo) { + firstName + lastName + birthday + } + } + } + """) + end + + test "for block strings" do + assert_rendered(""" + mutation { + sendEmail(message: \"\"\" + Hello, + World! + + Yours, + GraphQL. + \"\"\") + } + """) + end + + test "for null values" do + assert_rendered(""" + query { + field(arg: null) + field + } + """) + end + + test "for input objects" do + assert_rendered(""" + query { + nearestThing(location: { lon: 12.43, lat: -53.211 }) + } + """) + end + + test "for variables" do + assert_rendered(""" + query ($id: ID, $mult: Int = 6, $list: [Int!]! = [1, 2], $customScalar: CustomScalar!) { + times(base: 4, multiplier: $mult) + } + """) + end + + test "for introspection query" do + assert_rendered( + Path.join(__DIR__, "../../../priv/graphql/introspection.graphql") + |> File.read!() + ) + end + end + + defp assert_rendered(graphql) do + {:ok, blueprint} = Absinthe.Phase.Parse.run(graphql, []) + rendered_graphql = inspect(blueprint.input, pretty: true) + + assert graphql == rendered_graphql + end +end