Skip to content

Commit

Permalink
Better typespecs (#367)
Browse files Browse the repository at this point in the history
* Added more stringent typing to protocol

Our protocol types were rather weak, relying on most of the structs
defining their type as `@type t :: %__MODULE__{}`. We can do _much_
better than that, since we have detailed infomration about the entire
type hierarchy.

This commit does that providing detailed type information for the
created structs and their constructors.

I also found a couple of cases where the generators would miss type
aliases that were just aliases to "base" types, like strings,
integers, etc.
  • Loading branch information
scohen authored Sep 14, 2023
1 parent 94af9dd commit 2ea7be2
Show file tree
Hide file tree
Showing 19 changed files with 322 additions and 39 deletions.
3 changes: 3 additions & 0 deletions apps/proto/lib/lexical/proto/alias.ex
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
defmodule Lexical.Proto.Alias do
alias Lexical.Proto.CompileMetadata
alias Lexical.Proto.Field
alias Lexical.Proto.Macros.Typespec

defmacro defalias(alias_definition) do
caller_module = __CALLER__.module
CompileMetadata.add_type_alias_module(caller_module)

quote location: :keep do
@type t :: unquote(Typespec.typespec(alias_definition, __CALLER__))

def parse(lsp_map) do
Field.extract(unquote(alias_definition), :alias, lsp_map)
end
Expand Down
32 changes: 32 additions & 0 deletions apps/proto/lib/lexical/proto/enum.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
defmodule Lexical.Proto.Enum do
alias Lexical.Proto.Macros.Typespec

defmacro defenum(opts) do
names =
opts
|> Keyword.keys()
|> Enum.map(&{:literal, [], &1})

value_type =
opts
|> Keyword.values()
|> List.first()
|> Macro.expand(__CALLER__)
|> determine_type()

name_type = Typespec.choice(names, __CALLER__)

quote location: :keep do
@type name :: unquote(name_type)
@type value :: unquote(value_type)
@type t :: name() | value()

unquote(parse_functions(opts))

def parse(unknown) do
Expand All @@ -25,6 +45,18 @@ defmodule Lexical.Proto.Enum do
end
end

defp determine_type(i) when is_integer(i) do
quote do
integer()
end
end

defp determine_type(s) when is_binary(s) do
quote do
String.t()
end
end

defp parse_functions(opts) do
for {name, value} <- opts do
quote location: :keep do
Expand Down
7 changes: 4 additions & 3 deletions apps/proto/lib/lexical/proto/macros/message.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,20 @@ defmodule Lexical.Proto.Macros.Message do
Typespec
}

def build(meta_type, method, types, param_names, opts \\ []) do
def build(meta_type, method, types, param_names, env, opts \\ []) do
parse_fn =
if Keyword.get(opts, :include_parse?, true) do
Parse.build(types)
end

quote do
unquote(Struct.build(types))
unquote(Typespec.build())
unquote(Struct.build(types, env))
unquote(Access.build())
unquote(parse_fn)
unquote(Meta.build(types))

@type t :: unquote(Typespec.typespec())

def method do
unquote(method)
end
Expand Down
8 changes: 7 additions & 1 deletion apps/proto/lib/lexical/proto/macros/struct.ex
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
defmodule Lexical.Proto.Macros.Struct do
def build(opts) do
alias Lexical.Proto.Macros.Typespec

def build(opts, env) do
keys = Keyword.keys(opts)
required_keys = required_keys(opts)

Expand All @@ -23,7 +25,11 @@ defmodule Lexical.Proto.Macros.Struct do
quote location: :keep do
@enforce_keys unquote(required_keys)
defstruct unquote(keys)
@type option :: unquote(Typespec.keyword_constructor_options(opts, env))
@type options :: [option]

@spec new() :: t()
@spec new(options()) :: t()
def new(opts \\ []) do
struct!(__MODULE__, opts)
end
Expand Down
199 changes: 197 additions & 2 deletions apps/proto/lib/lexical/proto/macros/typespec.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,202 @@
defmodule Lexical.Proto.Macros.Typespec do
def build(_opts \\ []) do
def typespec(opts \\ [], env \\ nil)

def typespec([], _) do
quote do
@type t :: %__MODULE__{}
%__MODULE__{}
end
end

def typespec(opts, env) when is_list(opts) do
typespecs =
for {name, type} <- opts,
name != :.. do
{name, do_typespec(type, env)}
end

quote do
%__MODULE__{unquote_splicing(typespecs)}
end
end

def typespec(typespec, env) do
do_typespec(typespec, env)
end

def choice(options, env) do
do_typespec({:one_of, [], [options]}, env)
end

def keyword_constructor_options(opts, env) do
for {name, type} <- opts,
name != :.. do
{name, do_typespec(type, env)}
end
|> or_types()
end

defp do_typespec([], _env) do
# This is what's presented to typespec when a response has no results, as in the Shutdown response
nil
end

defp do_typespec(nil, _env) do
quote(do: nil)
end

defp do_typespec({:boolean, _, _}, _env) do
quote(do: boolean())
end

defp do_typespec({:string, _, _}, _env) do
quote(do: String.t())
end

defp do_typespec({:integer, _, _}, _env) do
quote(do: integer())
end

defp do_typespec({:float, _, _}, _env) do
quote(do: float())
end

defp do_typespec({:__MODULE__, _, nil}, env) do
env.module
end

defp do_typespec({:optional, _, [optional_type]}, env) do
quote do
unquote(do_typespec(optional_type, env)) | nil
end
end

defp do_typespec({:__aliases__, _, raw_alias} = aliased_module, env) do
expanded_alias = Macro.expand(aliased_module, env)

case List.last(raw_alias) do
:Position ->
other_alias =
case expanded_alias do
Lexical.Document.Range ->
Lexical.Protocol.Types.Range

_ ->
Lexical.Document.Range
end

quote do
unquote(expanded_alias).t() | unquote(other_alias).t()
end

:Range ->
other_alias =
case expanded_alias do
Lexical.Document.Range ->
Lexical.Protocol.Types.Range

_ ->
Lexical.Document.Range
end

quote do
unquote(expanded_alias).t() | unquote(other_alias).t()
end

_ ->
quote do
unquote(expanded_alias).t()
end
end
end

defp do_typespec({:literal, _, value}, _env) when is_atom(value) do
value
end

defp do_typespec({:literal, _, [value]}, _env) do
literal_type(value)
end

defp do_typespec({:type_alias, _, [alias_dest]}, env) do
do_typespec(alias_dest, env)
end

defp do_typespec({:one_of, _, [type_list]}, env) do
type_list
|> Enum.map(&do_typespec(&1, env))
|> or_types()
end

defp do_typespec({:list_of, _, items}, env) do
refined =
items
|> Enum.map(&do_typespec(&1, env))
|> or_types()

quote do
[unquote(refined)]
end
end

defp do_typespec({:tuple_of, _, [items]}, env) do
refined = Enum.map(items, &do_typespec(&1, env))

quote do
{unquote_splicing(refined)}
end
end

defp do_typespec({:map_of, _, items}, env) do
value_types =
items
|> Enum.map(&do_typespec(&1, env))
|> or_types()

quote do
%{String.t() => unquote(value_types)}
end
end

defp do_typespec({:any, _, _}, _env) do
quote do
any()
end
end

defp or_types(list_of_types) do
Enum.reduce(list_of_types, nil, fn
type, nil ->
type

type, acc ->
quote do
unquote(type) | unquote(acc)
end
end)
end

defp literal_type(thing) do
case thing do
string when is_binary(string) ->
quote(do: String.t())

integer when is_integer(integer) ->
quote(do: integer())

float when is_binary(float) ->
quote(do: float())

boolean when is_boolean(boolean) ->
quote(do: boolean())

atom when is_atom(atom) ->
atom

[] ->
quote(do: [])

[elem | _] ->
quote(do: [unquote(literal_type(elem))])
end
end
end
4 changes: 2 additions & 2 deletions apps/proto/lib/lexical/proto/notification.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ defmodule Lexical.Proto.Notification do

quote location: :keep do
defmodule LSP do
unquote(Message.build({:notification, :lsp}, method, lsp_types, param_names))
unquote(Message.build({:notification, :lsp}, method, lsp_types, param_names, caller))

def new(opts \\ []) do
opts
Expand All @@ -41,7 +41,7 @@ defmodule Lexical.Proto.Notification do
end

unquote(
Message.build({:notification, :elixir}, method, elixir_types, param_names,
Message.build({:notification, :elixir}, method, elixir_types, param_names, caller,
include_parse?: false
)
)
Expand Down
8 changes: 6 additions & 2 deletions apps/proto/lib/lexical/proto/request.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,13 @@ defmodule Lexical.Proto.Request do
param_names = Keyword.keys(types)
lsp_module_name = Module.concat(caller.module, LSP)

Message.build({:request, :elixir}, method, elixir_types, param_names, caller,
include_parse?: false
)

quote location: :keep do
defmodule LSP do
unquote(Message.build({:request, :lsp}, method, lsp_types, param_names))
unquote(Message.build({:request, :lsp}, method, lsp_types, param_names, caller))

def new(opts \\ []) do
opts
Expand All @@ -50,7 +54,7 @@ defmodule Lexical.Proto.Request do
alias Lexical.Protocol.Types

unquote(
Message.build({:request, :elixir}, method, elixir_types, param_names,
Message.build({:request, :elixir}, method, elixir_types, param_names, caller,
include_parse?: false
)
)
Expand Down
6 changes: 3 additions & 3 deletions apps/proto/lib/lexical/proto/response.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ defmodule Lexical.Proto.Response do

jsonrpc_types = [
id: quote(do: optional(one_of([integer(), string()]))),
error: quote(do: optional(LspTypes.ResponseError)),
error: quote(do: optional(Lexical.Proto.LspTypes.ResponseError)),
result: quote(do: optional(unquote(response_type)))
]

quote location: :keep do
alias Lexical.Proto.LspTypes
unquote(Access.build())
unquote(Struct.build(jsonrpc_types))
unquote(Typespec.build())
unquote(Struct.build(jsonrpc_types, __CALLER__))
@type t :: unquote(Typespec.typespec())
unquote(Meta.build(jsonrpc_types))

unquote(constructors())
Expand Down
6 changes: 4 additions & 2 deletions apps/proto/lib/lexical/proto/type.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ defmodule Lexical.Proto.Type do
unquote(Json.build(caller_module))
unquote(Inspect.build(caller_module))
unquote(Access.build())
unquote(Struct.build(types))
unquote(Typespec.build(types))
unquote(Struct.build(types, __CALLER__))

@type t :: unquote(Typespec.typespec(types, __CALLER__))

unquote(Parse.build(types))
unquote(Match.build(types, caller_module))
unquote(Meta.build(types))
Expand Down
Loading

0 comments on commit 2ea7be2

Please sign in to comment.