Skip to content

Support a configurable language module#21

Open
Gazler wants to merge 1 commit into
WhatsApp:mainfrom
Gazler:feat/edb_dap_language
Open

Support a configurable language module#21
Gazler wants to merge 1 commit into
WhatsApp:mainfrom
Gazler:feat/edb_dap_language

Conversation

@Gazler

@Gazler Gazler commented Jun 19, 2026

Copy link
Copy Markdown

Erlang has a constraint that ensures there is a mapping from filename to module name:

foo.erl => -module(foo)
bar.erl => -module(bar)

This makes looking up a module based on a path name deterministic. This is not the case in other BEAM languages such as Elixir. In Elixir, there is no mapping between the file name and the module name. It is also possible for an Elixir file to contain multiple modules.

In order to allow edb to be used in Elixir, we need a way to configure the mapping from path to module. However, instead of baking in support just for Elixir, it would be ideal to have this be generic.

This commit introduces a configurable module which can be provided as start options for edb. This is implemented with a behaviour, and a default erlang implementation is provided which is just the code that previously existed. The behaviour implements init/0 and source_to_modules/3.

To allow passing this option through, the application config is required to be set before starting edb. This is should be handled by the caller.

The intention here, is for other languages to wrap edb with their own escript which provides a language implementation.

For example, an Elixir wrapper can use:

defmodule EdbElixir.CLI do
  @moduledoc false

  def main(args) do
    Application.put_env(:edb, :dap_language, EdbElixir.DapLanguage)
    :edb_main.main(args)
  end
end

Erlang has a constraint that ensures there is a mapping from filename to
module name:

```
foo.erl => -module(foo)
bar.erl => -module(bar)
```

This makes looking up a module based on a path name deterministic. This
is not the case in other BEAM languages such as Elixir. In Elixir, there
is no mapping between the file name and the module name. It is also
possible for an Elixir file to contain multiple modules.

In order to allow edb to be used in Elixir, we need a way to configure
the mapping from path to module. However, instead of baking in support
just for Elixir, it would be ideal to have this be generic.

This commit introduces a configurable module which can be provided as
start options for edb. This is implemented with a behaviour, and a
default erlang implementation is provided which is just the code that
previously existed. The behaviour implements `init/0` and
`source_to_modules/3`.

To allow passing this option through, the application config is required
to be set before starting edb. This is should be handled by the caller.

The intention here, is for other languages to wrap edb with their own
escript which provides a language implementation.

For example, an Elixir wrapper can use:

```
defmodule EdbElixir.CLI do
  @moduledoc false

  def main(args) do
    Application.put_env(:edb, :dap_language, EdbElixir.DapLanguage)
    :edb_main.main(args)
  end
end
```

@jcpetruzza jcpetruzza left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry it too me a bit long to look into this. It looks good. If we can move the language-callback handling outside of the main server, I think it would be much cleaner. Wdyt?


-type state() :: dynamic().

-callback init() -> dynamic().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return type should be state()

Path :: binary(),
Lines :: [edb:line()],
Modules :: [module()],
State :: dynamic().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

-type state() :: dynamic().

-callback init() -> dynamic().
-callback source_to_modules(Path, Lines, State) -> {Modules, State} when

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though the types are clear, could we add short docs about what this function is supposed to return?

-type state() :: dynamic().

-callback init() -> dynamic().
-callback source_to_modules(Path, Lines, State) -> {Modules, State} when

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case of errors, the callback can only ever return [], and we can't distinguish things like "the callback thinks this source doesn't correspond to any elixir module" from "there is something misconfigured and we couldn't check".

Also, on an [] response, the DAP server can only that no breakpoint was set, but can't tell the user why.

All this to say, maybe the return type should be something like {ok,[Module]} | {error, Reason} , with Reason a binary(), or maybe a term() plus a format_error() callback

-behaviour(edb_dap_language).

-export([init/0, source_to_modules/3]).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: define a state() type and use that in specs instead of #{}

Path :: binary(),
Lines :: [edb:line()],
Modules :: [module()],
State :: dynamic().

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking out loud: could we define also an implementation for each callback, that uses application:get_env() to do the dispatching? Something like

-spec init() -> state().
init() ->
    Impl = impl(),
    Imp:init().

...

-spec impl() -> module().
impl() ->
    application:get_env(edb, dap_language, edb_dap_language_erlang).

Then we no longer need to store the module in the dap state, and we can just write:

{Modules, LangState1} = edb_dap_language:source_to_modules(Source, LangState0),

which means eqwalizer will still type-check the calls (instead of using dynamic() for everything), we can still use code-navigation to find the callback defs, etc.

Actually, if we make edb_dap_language a gen_server, we can also store the language state in the gen_server state and avoid having to thread it through the dap server state, which will make the code more readable, I think

edb:set_breakpoints(Module, Lines);
set_breakpoints_in_modules(Modules, Lines) ->
ResultsByModule = [edb:set_breakpoints(Module, Lines) || Module <- Modules],
[{Line, merge_line_results(Line, ResultsByModule)} || Line <- Lines].

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks quadratic? Could we instead convert each result-by-module into a map #{Line => Result}, then use maps:merge_with() to accumulate, and finally flatten or something like that?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants